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:
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user