fix(engine): give merge commits a real subject in fallback paths
Three merger fallback commit paths (auto-resolve-all-conflicts, -X theirs/ours side strategy, AI-agent-didn't-commit) hard-coded `feat(FN-XXXX): merge fusion/fn-xxxx` as the subject and never used the AI subject summarizer. Route them through buildDeterministicMergeMessage so they pick up aiSubject when available. When the AI subject summarizer returns null, derive the subject from the branch's first step commit (with conventional-commit prefix stripped, plus `(+N more)` for multi-commit branches) instead of the bare `merge <branch>` template. Bump DEFAULT_COMMIT_SUBJECT_TIMEOUT_MS 15s → 30s so slow-first-token providers complete instead of silently falling back. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/fix-merge-commit-subject.md
Normal file
5
.changeset/fix-merge-commit-subject.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix merge commits landing with the bare `feat(FN-XXXX): merge fusion/fn-xxxx` subject. Three fallback commit paths in the merger (auto-resolve-all-conflicts, `-X theirs/ours` side strategy, AI-agent-didn't-commit) now route through the same deterministic message builder as the happy path, so they pick up the AI-generated subject when available. When the AI subject summarizer returns null, the subject is now derived from the branch's first step-commit (with conventional-commit prefix stripped, plus `(+N more)` when multiple commits) instead of falling back to `merge <branch>`. Subject-summarizer timeout raised from 15s to 30s so slow-first-token providers complete instead of silently falling back.
|
||||
@@ -648,11 +648,13 @@ Your ONLY job is to summarize what landed — using the branch's step commit sub
|
||||
export const MAX_COMMIT_SUBJECT_LENGTH = 60;
|
||||
|
||||
/**
|
||||
* Default timeout for commit subject summarization, in milliseconds. Tighter
|
||||
* than the body timeout because the subject is short and we don't want it
|
||||
* meaningfully slowing down merges.
|
||||
* Default timeout for commit subject summarization, in milliseconds. Generous
|
||||
* enough that slow first-token providers still produce a real subject — the
|
||||
* deterministic fallback (`merge <branch>`) is the user-visible regression we
|
||||
* are trying to avoid, so favoring AI completion over latency is the right
|
||||
* trade here.
|
||||
*/
|
||||
export const DEFAULT_COMMIT_SUBJECT_TIMEOUT_MS = 15_000;
|
||||
export const DEFAULT_COMMIT_SUBJECT_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Summarize a `git diff --stat` (and optional commit log) into a short commit
|
||||
|
||||
@@ -1489,8 +1489,13 @@ describe("aiMergeTask — includeTaskIdInCommit setting", () => {
|
||||
(call) => String(call[0]).includes("git commit"),
|
||||
);
|
||||
expect(commitCall).toBeDefined();
|
||||
expect(String(commitCall![0])).toContain("feat: merge");
|
||||
// Subject must use bare `feat:` prefix (no task-id scope) when
|
||||
// includeTaskIdInCommit=false. The summary portion is derived from the
|
||||
// step commit log or AI subject, so we don't pin its exact text — just
|
||||
// assert the prefix shape.
|
||||
expect(String(commitCall![0])).toMatch(/git commit -m "feat: \S/);
|
||||
expect(String(commitCall![0])).not.toContain("feat(KB-050)");
|
||||
expect(String(commitCall![0])).not.toContain("feat(FN-050)");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1109,10 +1109,39 @@ async function generateAiMergeSubject(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a non-AI subject summary from the branch's step commit log. The log
|
||||
* is `- subj1\n- subj2\n…` (most recent first). We use the first subject with
|
||||
* its conventional-commit prefix stripped (to avoid `feat: feat(...): …`),
|
||||
* and tack on `(+N more)` when the branch has multiple step commits. This is
|
||||
* the fallback used when `summarizeCommitSubject` returns null — it conveys
|
||||
* what landed instead of the bare `merge <branch>` template.
|
||||
*/
|
||||
function deriveDeterministicSubjectSummary(commitLog: string): string | null {
|
||||
const lines = commitLog
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
const stripBullet = (l: string) => l.replace(/^[-*]\s+/, "").trim();
|
||||
const stripConventional = (l: string) =>
|
||||
l.replace(/^[a-z]+(?:\([^)]+\))?!?:\s*/i, "").trim();
|
||||
|
||||
const first = stripConventional(stripBullet(lines[0]));
|
||||
if (!first) return null;
|
||||
|
||||
const extras = lines.length - 1;
|
||||
const summary = extras > 0 ? `${first} (+${extras} more)` : first;
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical merge commit message from the branch's step commits.
|
||||
* Subject is `feat[(taskId)]: <aiSubject>` when the AI subject summarizer
|
||||
* produced one, else falls back to `feat[(taskId)]: merge <branch>`.
|
||||
* Subject preference order:
|
||||
* 1. AI summarizer (`summarizeCommitSubject`) when it succeeded
|
||||
* 2. First step commit subject (with conventional prefix stripped) + `(+N more)`
|
||||
* 3. `merge <branch>` (last-resort, only when no step commits exist)
|
||||
*/
|
||||
async function buildDeterministicMergeMessage(params: {
|
||||
taskId: string;
|
||||
@@ -1125,7 +1154,13 @@ async function buildDeterministicMergeMessage(params: {
|
||||
}): Promise<{ subjectArg: string; bodyArg: string }> {
|
||||
const { taskId, branch, commitLog, diffStat, includeTaskId, aiSummary, aiSubject } = params;
|
||||
const prefix = includeTaskId ? `feat(${taskId})` : "feat";
|
||||
const subjectSummary = aiSubject?.trim().length ? aiSubject.trim() : `merge ${branch}`;
|
||||
const trimmedAiSubject = aiSubject?.trim() ?? "";
|
||||
const derived = trimmedAiSubject.length === 0
|
||||
? deriveDeterministicSubjectSummary(commitLog ?? "")
|
||||
: null;
|
||||
const subjectSummary = trimmedAiSubject.length > 0
|
||||
? trimmedAiSubject
|
||||
: (derived ?? `merge ${branch}`);
|
||||
const subject = `${prefix}: ${subjectSummary}`;
|
||||
|
||||
const trimmedCommitLog = commitLog?.trim() ?? "";
|
||||
@@ -4258,12 +4293,19 @@ async function executeMergeAttempt(
|
||||
settings: settings as Settings,
|
||||
signal: options.signal,
|
||||
});
|
||||
const escapedLog = safeBody.replace(/"/g, '\\"');
|
||||
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
|
||||
const authorArg = getCommitAuthorArg(settings);
|
||||
const trailerArg = buildTaskIdTrailerArg(taskId);
|
||||
const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({
|
||||
taskId,
|
||||
branch,
|
||||
commitLog,
|
||||
diffStat,
|
||||
includeTaskId,
|
||||
aiSummary: safeBody,
|
||||
aiSubject,
|
||||
});
|
||||
await execAsync(
|
||||
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"${trailerArg}${authorArg}`,
|
||||
`git commit ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
|
||||
{ cwd: rootDir },
|
||||
);
|
||||
mergerLog.log(`${taskId}: committed after auto-resolving all conflicts`);
|
||||
@@ -4391,6 +4433,8 @@ async function executeMergeAttempt(
|
||||
branch,
|
||||
commitLog,
|
||||
diffStat,
|
||||
aiSummary,
|
||||
aiSubject,
|
||||
includeTaskId,
|
||||
hasConflicts,
|
||||
simplifiedContext: attemptNum === 2,
|
||||
@@ -4521,7 +4565,7 @@ async function attemptWithSideStrategy(
|
||||
side: "theirs" | "ours" = "theirs",
|
||||
aiTracker?: AiInvocationTracker,
|
||||
): Promise<boolean> {
|
||||
const { rootDir, branch, commitLog, diffStat, includeTaskId, sourceIssueRef, taskId, store, settings, testCommand, buildCommand, testSource, buildSource } = params;
|
||||
const { rootDir, branch, commitLog, diffStat, aiSummary, aiSubject, includeTaskId, sourceIssueRef, taskId, store, settings, testCommand, buildCommand, testSource, buildSource } = params;
|
||||
|
||||
mergerLog.log(`${taskId}: attempting merge with -X ${side} strategy`);
|
||||
|
||||
@@ -4583,13 +4627,20 @@ async function attemptWithSideStrategy(
|
||||
settings: settings as Settings,
|
||||
signal: params.options.signal,
|
||||
});
|
||||
const escapedLog = safeBody.replace(/"/g, '\\"');
|
||||
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
|
||||
const authorArg = getCommitAuthorArg(settings);
|
||||
const trailerArg = buildTaskIdTrailerArg(taskId);
|
||||
const issueRefBodyArg = sourceIssueRef ? ` -m "Ref: ${sourceIssueRef}"` : "";
|
||||
const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({
|
||||
taskId,
|
||||
branch,
|
||||
commitLog,
|
||||
diffStat,
|
||||
includeTaskId,
|
||||
aiSummary: aiSummary?.trim().length ? aiSummary : safeBody,
|
||||
aiSubject,
|
||||
});
|
||||
await execAsync(
|
||||
`git commit -m "${fallbackPrefix}: merge ${branch} (auto-resolved)" -m "${escapedLog}"${issueRefBodyArg}${trailerArg}${authorArg}`,
|
||||
`git commit ${subjectArg} ${bodyArg}${issueRefBodyArg}${trailerArg}${authorArg}`,
|
||||
{ cwd: rootDir },
|
||||
);
|
||||
mergerLog.log(`${taskId}: committed with -X ${side} auto-resolution`);
|
||||
@@ -4626,6 +4677,8 @@ interface AiAgentParams {
|
||||
branch: string;
|
||||
commitLog: string;
|
||||
diffStat: string;
|
||||
aiSummary?: string | null;
|
||||
aiSubject?: string | null;
|
||||
includeTaskId: boolean;
|
||||
hasConflicts: boolean;
|
||||
simplifiedContext: boolean;
|
||||
@@ -4668,6 +4721,8 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
||||
branch,
|
||||
commitLog,
|
||||
diffStat,
|
||||
aiSummary,
|
||||
aiSubject,
|
||||
includeTaskId,
|
||||
hasConflicts,
|
||||
simplifiedContext,
|
||||
@@ -4912,13 +4967,20 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
||||
settings: settings as Settings,
|
||||
signal: options.signal,
|
||||
});
|
||||
const escapedLog = safeBody.replace(/"/g, '\\"');
|
||||
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
|
||||
const authorArg = getCommitAuthorArg(settings);
|
||||
const trailerArg = buildTaskIdTrailerArg(taskId);
|
||||
const issueRefBodyArg = sourceIssueRef ? ` -m "Ref: ${sourceIssueRef}"` : "";
|
||||
const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({
|
||||
taskId,
|
||||
branch,
|
||||
commitLog,
|
||||
diffStat,
|
||||
includeTaskId,
|
||||
aiSummary: aiSummary?.trim().length ? aiSummary : safeBody,
|
||||
aiSubject,
|
||||
});
|
||||
await execAsync(
|
||||
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"${issueRefBodyArg}${trailerArg}${authorArg}`,
|
||||
`git commit ${subjectArg} ${bodyArg}${issueRefBodyArg}${trailerArg}${authorArg}`,
|
||||
{ cwd: rootDir },
|
||||
);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user