feat(merger): generate richer merge commit messages via AI summarizer

Squash merge commits previously landed with only a bare "merge
fusion/fn-XXXX" subject and a single bullet from the branch's commit
log, leaving git log readers without insight into what actually
changed. Now buildDeterministicMergeMessage calls summarizeCommitBody
(title-summarizer lane when configured, default model otherwise) with
the step commits + diffstat, and emits a three-section body: AI summary
+ Commits merged + Files changed. The deterministic sections always
ship so AI failure / timeout still yields a substantive message.

Also extends summarizeCommitBody to take an optional commitLog and
loosens its prompt for more detail when the change warrants it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-29 09:54:54 -07:00
parent ea90bd6030
commit 212d1bc9bd
2 changed files with 105 additions and 26 deletions

View File

@@ -314,16 +314,18 @@ export async function summarizeTitle(
// ── Commit Body Summarization ──────────────────────────────────────────── // ── Commit Body Summarization ────────────────────────────────────────────
/** System prompt for fallback merge commit body generation. */ /** System prompt for fallback merge commit body generation. */
export const COMMIT_BODY_SYSTEM_PROMPT = `You write concise commit message bodies for merge commits. export const COMMIT_BODY_SYSTEM_PROMPT = `You write commit message bodies for merge commits.
Your job is to summarize the changes described in a \`git diff --stat\` into a short, useful body. Your job is to summarize what landed — using the branch's step commit subjects (when provided) and the \`git diff --stat\` — into a useful body that lets a reader understand what changed without reading the diff.
## Guidelines ## Guidelines
- Output ONLY the body text — no code fences, no preamble, no subject line - Output ONLY the body text — no code fences, no preamble, no subject line
- 2–6 short bullet points starting with "- " - Bullet points starting with "- "; use as many as the change warrants (typically 3–10)
- Be specific about what changed; reference filenames where helpful - Be specific: reference modules, components, or filenames that meaningfully changed
- Keep total output under 600 characters - Group related edits when it aids clarity; keep each bullet a single line
- Do not invent details that aren't in the input — if uncertain, stay general`; - Lead with the most consequential changes; trivial bumps go last or get omitted
- Do not invent details that aren't in the input — if uncertain, stay general
- Hard cap: 1500 characters total; aim for the level of detail the change actually needs`;
/** /**
* Maximum input length for commit body summarization. Diff stats can be * Maximum input length for commit body summarization. Diff stats can be
@@ -374,26 +376,38 @@ export async function summarizeCommitBody(
opts?: { opts?: {
branch?: string; branch?: string;
taskId?: string; taskId?: string;
commitLog?: string;
signal?: AbortSignal; signal?: AbortSignal;
timeoutMs?: number; timeoutMs?: number;
}, },
): Promise<string | null> { ): Promise<string | null> {
const trimmedStat = (diffStat ?? "").trim(); const trimmedStat = (diffStat ?? "").trim();
if (trimmedStat.length === 0) { const trimmedCommitLog = (opts?.commitLog ?? "").trim();
if (trimmedStat.length === 0 && trimmedCommitLog.length === 0) {
return null; return null;
} }
const truncatedStat = trimmedStat.length > MAX_COMMIT_BODY_INPUT_LENGTH const truncatedStat = trimmedStat.length > MAX_COMMIT_BODY_INPUT_LENGTH
? trimmedStat.slice(0, MAX_COMMIT_BODY_INPUT_LENGTH) + "\n…(truncated)" ? trimmedStat.slice(0, MAX_COMMIT_BODY_INPUT_LENGTH) + "\n…(truncated)"
: trimmedStat; : trimmedStat;
const truncatedCommitLog = trimmedCommitLog.length > MAX_COMMIT_BODY_INPUT_LENGTH
? trimmedCommitLog.slice(0, MAX_COMMIT_BODY_INPUT_LENGTH) + "\n…(truncated)"
: trimmedCommitLog;
const userPromptParts: string[] = []; const userPromptParts: string[] = [];
if (opts?.branch) userPromptParts.push(`Branch: ${opts.branch}`); if (opts?.branch) userPromptParts.push(`Branch: ${opts.branch}`);
if (opts?.taskId) userPromptParts.push(`Task: ${opts.taskId}`); if (opts?.taskId) userPromptParts.push(`Task: ${opts.taskId}`);
if (userPromptParts.length > 0) userPromptParts.push(""); if (userPromptParts.length > 0) userPromptParts.push("");
userPromptParts.push("Files changed (`git diff --stat`):"); if (truncatedCommitLog.length > 0) {
userPromptParts.push(truncatedStat); userPromptParts.push("Step commits being merged in (most recent first):");
userPromptParts.push(""); userPromptParts.push(truncatedCommitLog);
userPromptParts.push("");
}
if (truncatedStat.length > 0) {
userPromptParts.push("Files changed (`git diff --stat`):");
userPromptParts.push(truncatedStat);
userPromptParts.push("");
}
userPromptParts.push("Write the commit body now."); userPromptParts.push("Write the commit body now.");
const userPrompt = userPromptParts.join("\n"); const userPrompt = userPromptParts.join("\n");

View File

@@ -1055,23 +1055,75 @@ function resetMergeWithWarn(rootDir: string, taskId: string, label: string): voi
/** /**
* Build the canonical merge commit message from the branch's step commits. * Build the canonical merge commit message from the branch's step commits.
* Subject is always `feat[(taskId)]: merge <branch>`; body is one bullet per * Subject is always `feat[(taskId)]: merge <branch>`. Body has three parts so
* step commit subject (already constructed upstream as `commitLog`). This is * `git log` shows what actually landed instead of a bare "merge":
* deterministic — no LLM involvement — so the recorded message can be trusted * 1. AI-generated summary (via the title-summarizer model lane), built from
* to reflect the actual diff. * the branch's step-commit subjects + diffstat. Best-effort — bounded by
* timeout, falls through silently on any failure.
* 2. The raw step-commit list (always included as ground truth so a reader
* can verify the AI summary against the actual commits).
* 3. The diffstat block so file-level changes are visible inline.
*
* The AI summary is additive context, never the sole source of truth — the
* step commits and diffstat below it are deterministic.
*/ */
function buildDeterministicMergeMessage(params: { async function buildDeterministicMergeMessage(params: {
taskId: string; taskId: string;
branch: string; branch: string;
commitLog: string; commitLog: string;
diffStat?: string;
includeTaskId: boolean; includeTaskId: boolean;
}): { subjectArg: string; bodyArg: string } { rootDir?: string;
const { taskId, branch, commitLog, includeTaskId } = params; settings?: Settings;
signal?: AbortSignal;
}): Promise<{ subjectArg: string; bodyArg: string }> {
const { taskId, branch, commitLog, diffStat, includeTaskId, rootDir, settings, signal } = params;
const prefix = includeTaskId ? `feat(${taskId})` : "feat"; const prefix = includeTaskId ? `feat(${taskId})` : "feat";
const subject = `${prefix}: merge ${branch}`; const subject = `${prefix}: merge ${branch}`;
const body = commitLog && commitLog.trim().length > 0
? commitLog.trim() const trimmedCommitLog = commitLog?.trim() ?? "";
const trimmedDiffStat = diffStat?.trim() ?? "";
const commitsSection = trimmedCommitLog.length > 0
? trimmedCommitLog
: `- merge ${branch}`; : `- merge ${branch}`;
// Best-effort AI summary using the title-summarizer lane (small/fast model).
// Falls back to the project default when not configured. Any failure (no
// runtime, timeout, empty response) returns null and we skip the summary.
let aiSummary: string | null = null;
if (rootDir && settings && (trimmedCommitLog.length > 0 || trimmedDiffStat.length > 0)) {
const useTitleSummarizer =
!!settings.titleSummarizerProvider && !!settings.titleSummarizerModelId;
const provider = useTitleSummarizer
? settings.titleSummarizerProvider!
: (settings.defaultProviderOverride && settings.defaultModelIdOverride
? settings.defaultProviderOverride
: settings.defaultProvider);
const modelId = useTitleSummarizer
? settings.titleSummarizerModelId!
: (settings.defaultProviderOverride && settings.defaultModelIdOverride
? settings.defaultModelIdOverride
: settings.defaultModelId);
aiSummary = await summarizeCommitBody(trimmedDiffStat, rootDir, provider, modelId, {
branch,
taskId,
commitLog: trimmedCommitLog,
signal,
}).catch(() => null);
}
const sections: string[] = [];
if (aiSummary && aiSummary.trim().length > 0) {
sections.push(aiSummary.trim());
}
sections.push(`Commits merged:\n${commitsSection}`);
if (trimmedDiffStat.length > 0) {
sections.push(`Files changed:\n${trimmedDiffStat}`);
}
const body = sections.join("\n\n");
// -m args are double-quoted in the shell command, so escape backslashes, // -m args are double-quoted in the shell command, so escape backslashes,
// double quotes, dollar signs, and backticks. // double quotes, dollar signs, and backticks.
const escape = (s: string) => s.replace(/(["\\$`])/g, "\\$1"); const escape = (s: string) => s.replace(/(["\\$`])/g, "\\$1");
@@ -1106,6 +1158,9 @@ async function commitOrAmendMergeWithFixes(
includeTaskId: boolean, includeTaskId: boolean,
preAttemptHeadSha: string, preAttemptHeadSha: string,
authorArg: string, authorArg: string,
diffStat?: string,
settings?: Settings,
signal?: AbortSignal,
): Promise<boolean> { ): Promise<boolean> {
try { try {
// Stage everything (squash state + verification fixes the agent left // Stage everything (squash state + verification fixes the agent left
@@ -1159,11 +1214,15 @@ async function commitOrAmendMergeWithFixes(
return false; return false;
} }
const { subjectArg, bodyArg } = buildDeterministicMergeMessage({ const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({
taskId, taskId,
branch, branch,
commitLog, commitLog,
diffStat,
includeTaskId, includeTaskId,
rootDir,
settings,
signal,
}); });
const trailerArg = buildTaskIdTrailerArg(taskId); const trailerArg = buildTaskIdTrailerArg(taskId);
@@ -3268,6 +3327,9 @@ export async function aiMergeTask(
includeTaskId, includeTaskId,
preAttemptHeadSha, preAttemptHeadSha,
authorArg, authorArg,
diffStat,
settings,
options.signal,
); );
if (!finalized) { if (!finalized) {
// Phantom-merge guard: refused to fabricate a commit. Reset // Phantom-merge guard: refused to fabricate a commit. Reset
@@ -3370,6 +3432,9 @@ export async function aiMergeTask(
includeTaskId, includeTaskId,
preAttemptHeadSha, preAttemptHeadSha,
authorArg, authorArg,
diffStat,
settings,
options.signal,
); );
if (!finalized) { if (!finalized) {
// Phantom-merge guard: the verification fix passed but no // Phantom-merge guard: the verification fix passed but no
@@ -3883,11 +3948,7 @@ interface MergeAttemptParams {
attemptNum: 1 | 2 | 3; attemptNum: 1 | 2 | 3;
options: MergerOptions; options: MergerOptions;
result: MergeResult; result: MergeResult;
settings: { settings: Settings;
commitAuthorEnabled?: boolean;
commitAuthorName?: string;
commitAuthorEmail?: string;
};
testCommand?: string; testCommand?: string;
buildCommand?: string; buildCommand?: string;
/** Source of the test command: 'explicit' from settings or 'inferred' from project files */ /** Source of the test command: 'explicit' from settings or 'inferred' from project files */
@@ -4231,11 +4292,15 @@ async function executeMergeAttempt(
// of mergeDetails surface. Subject keeps the conventional-commit shape. // of mergeDetails surface. Subject keeps the conventional-commit shape.
try { try {
const authorArg = getCommitAuthorArg(params.settings); const authorArg = getCommitAuthorArg(params.settings);
const { subjectArg, bodyArg } = buildDeterministicMergeMessage({ const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({
taskId, taskId,
branch, branch,
commitLog, commitLog,
diffStat,
includeTaskId, includeTaskId,
rootDir,
settings: params.settings,
signal: options.signal,
}); });
const trailerArg = buildTaskIdTrailerArg(taskId); const trailerArg = buildTaskIdTrailerArg(taskId);
await execAsync( await execAsync(