fix(merger): build commit messages from actual content, not branch range

The merge commit message was built from `commitLog`/`diffStat` computed
against `merge-base(branch, main)`. Under squash-merge workflows, when an
earlier task is squash-merged onto main first, branches that forked off
the pre-squash main no longer share ancestry with it — `merge-base`
resolves to a point before the earlier task, and the message describes
work already merged via the prior squash. FN-2952's commit body claimed
11 files / 557 insertions when the actual diff was 2 files / 55 lines.

Subject was also a generic `merge <branch>` regardless of content.

- packages/engine/src/merger.ts: new `computeActualMergeCommitContext`
  helper that derives commitLog/diffStat from the actual integration
  delta (`git diff --cached <integrationTarget> --stat`), filtering
  branch commits by patch-id against the target's recent history to
  drop already-squashed siblings. Wired into both commit-finalization
  sites (`commitOrAmendMergeWithFixes` uses `preAttemptHeadSha`; the
  final amend in `runMergeAttempt` uses `HEAD~1`). Agent-context use of
  the wide range is unchanged.
- packages/engine/src/merger.ts: `buildDeterministicMergeMessage` now
  generates subject and body in parallel via `Promise.all`. Subject is
  composed as `feat(taskId): <ai summary>`, capped at 72 chars, with
  fallback to the legacy `merge <branch>` form on any AI failure.
- packages/core/src/ai-summarize.ts: new `summarizeCommitSubject` and
  `sanitizeCommitSubject` mirroring the body summarizer's structure.
  Same title-summarizer lane, 15s timeout. Sanitizer strips quotes,
  bullets, re-added conventional-commit prefixes, and trailing periods;
  hard-caps at 60 chars.
- packages/core/src/__tests__/ai-summarize.test.ts: 9 tests covering
  the sanitizer's behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-29 15:22:20 -07:00
parent 4c82399a6a
commit 2febbc9d3a
4 changed files with 416 additions and 11 deletions

View File

@@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { import {
summarizeTitle, summarizeTitle,
summarizeCommitBody, summarizeCommitBody,
sanitizeCommitSubject,
MAX_COMMIT_SUBJECT_LENGTH,
checkRateLimit, checkRateLimit,
getRateLimitResetTime, getRateLimitResetTime,
validateDescription, validateDescription,
@@ -266,6 +268,63 @@ describe("ai-summarize", () => {
// ── State Reset ─────────────────────────────────────────────────────────── // ── State Reset ───────────────────────────────────────────────────────────
describe("sanitizeCommitSubject", () => {
it("returns null for empty / whitespace input", () => {
expect(sanitizeCommitSubject("")).toBeNull();
expect(sanitizeCommitSubject(" \n ")).toBeNull();
});
it("keeps a clean subject as-is", () => {
expect(sanitizeCommitSubject("add unavailable-node validation")).toBe(
"add unavailable-node validation",
);
});
it("uses only the first non-empty line", () => {
expect(sanitizeCommitSubject("add validation\n\nbody text here")).toBe(
"add validation",
);
expect(sanitizeCommitSubject("\n\n refactor merger\nignored second line")).toBe(
"refactor merger",
);
});
it("strips surrounding quotes and backticks", () => {
expect(sanitizeCommitSubject('"add tests for store"')).toBe("add tests for store");
expect(sanitizeCommitSubject("'fix race in heartbeat'")).toBe("fix race in heartbeat");
expect(sanitizeCommitSubject("`add caching layer`")).toBe("add caching layer");
});
it("strips a leading bullet marker", () => {
expect(sanitizeCommitSubject("- add caching layer")).toBe("add caching layer");
expect(sanitizeCommitSubject("* fix bug")).toBe("fix bug");
});
it("drops a leading conventional-commit prefix the model adds back", () => {
expect(sanitizeCommitSubject("feat: add validation")).toBe("add validation");
expect(sanitizeCommitSubject("feat(FN-123): add validation")).toBe("add validation");
expect(sanitizeCommitSubject("fix(scope): something")).toBe("something");
expect(sanitizeCommitSubject("FEAT: shouty")).toBe("shouty");
});
it("drops a trailing period", () => {
expect(sanitizeCommitSubject("add validation.")).toBe("add validation");
expect(sanitizeCommitSubject("add validation...")).toBe("add validation");
});
it("hard-caps at MAX_COMMIT_SUBJECT_LENGTH", () => {
const long = "a".repeat(MAX_COMMIT_SUBJECT_LENGTH + 20);
const result = sanitizeCommitSubject(long);
expect(result).not.toBeNull();
expect(result!.length).toBeLessThanOrEqual(MAX_COMMIT_SUBJECT_LENGTH);
});
it("returns null when stripping leaves nothing", () => {
expect(sanitizeCommitSubject('""')).toBeNull();
expect(sanitizeCommitSubject("feat: ")).toBeNull();
});
});
describe("__resetSummarizeState", () => { describe("__resetSummarizeState", () => {
it("should clear all rate limit entries", () => { it("should clear all rate limit entries", () => {
const ip = "192.168.1.1"; const ip = "192.168.1.1";

View File

@@ -493,6 +493,204 @@ export async function summarizeCommitBody(
} }
} }
// ── Commit Subject Summarization ─────────────────────────────────────────
/** System prompt for merge commit subject generation. */
export const COMMIT_SUBJECT_SYSTEM_PROMPT = `You write commit message subjects for merge commits.
Your job is to summarize what landed — using the branch's step commit subjects (when provided) and the \`git diff --stat\` — into a single subject line that conveys the change's essence at a glance.
## Guidelines
- Output ONLY the subject text — no quotes, no markdown, no body, no trailing period
- Do NOT include any \`feat:\`, \`fix:\`, scope, or task-id prefix — the caller adds that
- Imperative mood ("add X", "fix Y", "refactor Z") and lower-case first word
- Hard cap: 60 characters; aim for 4055
- Be specific: name the most consequential module/feature/behavior that changed
- If the branch has one clear theme, describe it; if it's mixed, lead with the largest change
- Do not invent details that aren't in the input`;
/** Maximum output length for the generated commit subject, in characters. */
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.
*/
export const DEFAULT_COMMIT_SUBJECT_TIMEOUT_MS = 15_000;
/**
* Summarize a `git diff --stat` (and optional commit log) into a short commit
* subject via AI.
*
* Used by the merger to replace the legacy `merge <branch>` subject with one
* that actually describes what landed. The caller is responsible for adding
* the conventional-commit prefix (e.g. `feat(FN-123): `) — this function
* returns only the summary portion.
*
* Best-effort: returns null on any failure (no AI runtime, timeout, empty
* response, error). Caller falls back to the legacy `merge <branch>` form.
*
* @param diffStat - Output of `git diff --stat` describing what changed.
* @param rootDir - Project root directory for AI agent context.
* @param provider - AI model provider (typically the title-summarizer lane).
* @param modelId - AI model ID.
* @param opts - Optional context (branch, taskId), abort signal, timeout.
* @returns The generated subject (≤60 chars, no prefix), or null on failure.
*/
export async function summarizeCommitSubject(
diffStat: string,
rootDir: string,
provider?: string,
modelId?: string,
opts?: {
branch?: string;
taskId?: string;
commitLog?: string;
signal?: AbortSignal;
timeoutMs?: number;
},
): Promise<string | null> {
const trimmedStat = (diffStat ?? "").trim();
const trimmedCommitLog = (opts?.commitLog ?? "").trim();
if (trimmedStat.length === 0 && trimmedCommitLog.length === 0) {
return null;
}
const truncatedStat = trimmedStat.length > MAX_COMMIT_BODY_INPUT_LENGTH
? trimmedStat.slice(0, MAX_COMMIT_BODY_INPUT_LENGTH) + "\n…(truncated)"
: trimmedStat;
const truncatedCommitLog = trimmedCommitLog.length > MAX_COMMIT_BODY_INPUT_LENGTH
? trimmedCommitLog.slice(0, MAX_COMMIT_BODY_INPUT_LENGTH) + "\n…(truncated)"
: trimmedCommitLog;
const userPromptParts: string[] = [];
if (opts?.branch) userPromptParts.push(`Branch: ${opts.branch}`);
if (opts?.taskId) userPromptParts.push(`Task: ${opts.taskId}`);
if (userPromptParts.length > 0) userPromptParts.push("");
if (truncatedCommitLog.length > 0) {
userPromptParts.push("Step commits being merged in (most recent first):");
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 subject now.");
const userPrompt = userPromptParts.join("\n");
const timeoutMs = opts?.timeoutMs ?? DEFAULT_COMMIT_SUBJECT_TIMEOUT_MS;
const aborter = new AbortController();
const timer = setTimeout(() => aborter.abort(), timeoutMs);
if (opts?.signal) {
if (opts.signal.aborted) aborter.abort();
else opts.signal.addEventListener("abort", () => aborter.abort(), { once: true });
}
let session: Awaited<ReturnType<NonNullable<Awaited<ReturnType<typeof getFnAgent>>>>>["session"] | undefined;
try {
const createFnAgent = await getFnAgent();
if (!createFnAgent) {
if (DEBUG) console.log("[ai-summarize] AI engine not available for commit subject");
return null;
}
const agentOptions: {
cwd: string;
systemPrompt: string;
tools: "readonly";
defaultProvider?: string;
defaultModelId?: string;
} = {
cwd: rootDir,
systemPrompt: COMMIT_SUBJECT_SYSTEM_PROMPT,
tools: "readonly",
};
if (provider && modelId) {
agentOptions.defaultProvider = provider;
agentOptions.defaultModelId = modelId;
}
const agentResult = await createFnAgent(agentOptions);
if (!agentResult?.session) return null;
session = agentResult.session;
await session.prompt(userPrompt);
if (aborter.signal.aborted) return null;
if (session.state?.error) {
if (DEBUG) console.log(`[ai-summarize] Commit-subject session error: ${session.state.error}`);
return null;
}
const messages: AgentMessage[] = session.state?.messages ?? [];
const assistant = messages.filter((m: AgentMessage) => m.role === "assistant").pop();
if (!assistant?.content) return null;
let raw = "";
if (typeof assistant.content === "string") {
raw = assistant.content;
} else if (Array.isArray(assistant.content)) {
raw = assistant.content
.filter((c: { type: string; text?: string }): c is { type: "text"; text: string } =>
c.type === "text" && typeof c.text === "string",
)
.map((c) => c.text)
.join("");
}
return sanitizeCommitSubject(raw);
} catch (err) {
if (DEBUG) {
const message = err instanceof Error ? err.message : String(err);
console.log(`[ai-summarize] Commit-subject generation failed: ${message}`);
}
return null;
} finally {
clearTimeout(timer);
try {
session?.dispose?.();
} catch {
// ignore disposal errors
}
}
}
/**
* Sanitize a raw AI subject response into a clean commit subject:
* - first non-empty line only
* - strip surrounding quotes / backticks / markdown bullets
* - drop conventional-commit prefixes the model may have re-added
* (e.g. `feat:`, `feat(FN-123):`, `fix(scope):`)
* - drop trailing period
* - hard cap at MAX_COMMIT_SUBJECT_LENGTH
*
* Exported for unit testing; merger calls this only via
* `summarizeCommitSubject`.
*/
export function sanitizeCommitSubject(raw: string): string | null {
if (!raw) return null;
const firstLine = raw.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0);
if (!firstLine) return null;
let subject = firstLine
.replace(/^[-*]\s+/, "")
.replace(/^["'`]+|["'`]+$/g, "")
.trim();
// Strip a leading `type(scope): ` or `type: ` prefix the model may add back.
subject = subject.replace(/^[a-z]+(?:\([^)]+\))?:\s*/i, "").trim();
// Drop trailing period.
subject = subject.replace(/\.+$/, "").trim();
if (!subject) return null;
if (subject.length > MAX_COMMIT_SUBJECT_LENGTH) {
subject = subject.slice(0, MAX_COMMIT_SUBJECT_LENGTH).trim();
}
return subject || null;
}
// ── Test Helpers ─────────────────────────────────────────────────────────── // ── Test Helpers ───────────────────────────────────────────────────────────
/** /**

View File

@@ -185,11 +185,16 @@ export type {
export { export {
summarizeTitle, summarizeTitle,
summarizeCommitBody, summarizeCommitBody,
summarizeCommitSubject,
sanitizeCommitSubject,
checkRateLimit, checkRateLimit,
getRateLimitResetTime, getRateLimitResetTime,
validateDescription, validateDescription,
SUMMARIZE_SYSTEM_PROMPT, SUMMARIZE_SYSTEM_PROMPT,
COMMIT_BODY_SYSTEM_PROMPT, COMMIT_BODY_SYSTEM_PROMPT,
COMMIT_SUBJECT_SYSTEM_PROMPT,
MAX_COMMIT_SUBJECT_LENGTH,
DEFAULT_COMMIT_SUBJECT_TIMEOUT_MS,
MAX_DESCRIPTION_LENGTH, MAX_DESCRIPTION_LENGTH,
MIN_DESCRIPTION_LENGTH, MIN_DESCRIPTION_LENGTH,
MAX_TITLE_LENGTH, MAX_TITLE_LENGTH,

View File

@@ -139,6 +139,7 @@ import {
resolveProjectDefaultModel, resolveProjectDefaultModel,
resolveAgentPrompt, resolveAgentPrompt,
summarizeCommitBody, summarizeCommitBody,
summarizeCommitSubject,
type TaskStore, type TaskStore,
type MergeResult, type MergeResult,
type MergeDetails, type MergeDetails,
@@ -1079,7 +1080,7 @@ async function buildDeterministicMergeMessage(params: {
}): Promise<{ subjectArg: string; bodyArg: string }> { }): Promise<{ subjectArg: string; bodyArg: string }> {
const { taskId, branch, commitLog, diffStat, includeTaskId, rootDir, settings, signal } = params; 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 fallbackSubject = `${prefix}: merge ${branch}`;
const trimmedCommitLog = commitLog?.trim() ?? ""; const trimmedCommitLog = commitLog?.trim() ?? "";
const trimmedDiffStat = diffStat?.trim() ?? ""; const trimmedDiffStat = diffStat?.trim() ?? "";
@@ -1091,7 +1092,10 @@ async function buildDeterministicMergeMessage(params: {
// Best-effort AI summary using the title-summarizer lane (small/fast model). // Best-effort AI summary using the title-summarizer lane (small/fast model).
// Falls back to the project default when not configured. Any failure (no // Falls back to the project default when not configured. Any failure (no
// runtime, timeout, empty response) returns null and we skip the summary. // runtime, timeout, empty response) returns null and we skip the summary.
// Subject and body are generated in parallel so the extra subject call
// doesn't serialize merge time.
let aiSummary: string | null = null; let aiSummary: string | null = null;
let aiSubject: string | null = null;
if (rootDir && settings && (trimmedCommitLog.length > 0 || trimmedDiffStat.length > 0)) { if (rootDir && settings && (trimmedCommitLog.length > 0 || trimmedDiffStat.length > 0)) {
const useTitleSummarizer = const useTitleSummarizer =
!!settings.titleSummarizerProvider && !!settings.titleSummarizerModelId; !!settings.titleSummarizerProvider && !!settings.titleSummarizerModelId;
@@ -1106,12 +1110,32 @@ async function buildDeterministicMergeMessage(params: {
? settings.defaultModelIdOverride ? settings.defaultModelIdOverride
: settings.defaultModelId); : settings.defaultModelId);
aiSummary = await summarizeCommitBody(trimmedDiffStat, rootDir, provider, modelId, { const [bodyResult, subjectResult] = await Promise.all([
branch, summarizeCommitBody(trimmedDiffStat, rootDir, provider, modelId, {
taskId, branch,
commitLog: trimmedCommitLog, taskId,
signal, commitLog: trimmedCommitLog,
}).catch(() => null); signal,
}).catch(() => null),
summarizeCommitSubject(trimmedDiffStat, rootDir, provider, modelId, {
branch,
taskId,
commitLog: trimmedCommitLog,
signal,
}).catch(() => null),
]);
aiSummary = bodyResult;
aiSubject = subjectResult;
}
// Compose subject: prefer the AI summary; fall back to the legacy
// `merge <branch>` form on any failure so a wedged summarizer can never
// block a merge. Hard cap at 72 chars (subject + prefix) — git's soft
// limit is 72; the AI is already capped at 60 by sanitizeCommitSubject.
let subject = fallbackSubject;
if (aiSubject && aiSubject.length > 0) {
const candidate = `${prefix}: ${aiSubject}`;
subject = candidate.length > 72 ? candidate.slice(0, 72).trimEnd() : candidate;
} }
const sections: string[] = []; const sections: string[] = [];
@@ -1214,11 +1238,26 @@ async function commitOrAmendMergeWithFixes(
return false; return false;
} }
// Build the message from the actual commit content rather than the
// wide-range branch context that was gathered before merge. The
// pre-merge commitLog/diffStat use `merge-base(branch, main)` as base,
// which under squash-merge workflows can predate already-merged sibling
// tasks — leading to messages that describe files not in the diff.
// `preAttemptHeadSha` is the integration target (main's tip just before
// this merge), so diffing against it gives content truth.
const actualContext = await computeActualMergeCommitContext({
rootDir,
integrationTargetSha: preAttemptHeadSha,
branch,
});
const messageCommitLog = actualContext.commitLog || commitLog;
const messageDiffStat = actualContext.diffStat || diffStat;
const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({ const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({
taskId, taskId,
branch, branch,
commitLog, commitLog: messageCommitLog,
diffStat, diffStat: messageDiffStat,
includeTaskId, includeTaskId,
rootDir, rootDir,
settings, settings,
@@ -2046,6 +2085,89 @@ async function collectPatchIds(
return ids; return ids;
} }
/**
* Compute the actual content of the merge commit being finalized, expressed as
* `{ commitLog, diffStat }` ready to feed into `buildDeterministicMergeMessage`.
*
* The wide-range values gathered before merge (`baseCommitSha..branch`) are
* unreliable as commit-message context in a squash-merge workflow: when an
* earlier task is squash-merged onto `main`, branches that forked off the
* pre-squash `main` no longer share ancestry with it, so `merge-base(branch,
* main)` resolves to a point *before* the earlier task — and the resulting
* diffstat/commitLog describe work that was already merged via the prior
* squash. The commit message then talks about files that aren't in the diff.
*
* This helper computes truth from content:
* - `diffStat` = `git diff --cached <integrationTargetSha> --stat` when there
* are staged changes (covers both the pre-commit and amend-with-staged
* paths), otherwise `git diff <integrationTargetSha> HEAD --stat` (covers
* the message-only amend path where the commit already exists).
* - `commitLog` = subjects of `git log integrationTarget..branch`, with
* already-squashed commits filtered out by patch-id (using
* `collectPatchIds` / `commitPatchId`, the same primitives the rest of the
* merger uses for orphan detection).
*
* Best-effort: any git failure returns an empty string for that field, and
* the caller's downstream fallback (`buildDeterministicMergeMessage`) handles
* empty inputs gracefully.
*/
async function computeActualMergeCommitContext(params: {
rootDir: string;
integrationTargetSha: string;
branch: string;
}): Promise<{ commitLog: string; diffStat: string }> {
const { rootDir, integrationTargetSha, branch } = params;
const targetArg = quoteArg(integrationTargetSha);
let diffStat = "";
try {
const { stdout: stagedStat } = await execAsync(
`git diff --cached ${targetArg} --stat`,
{ cwd: rootDir, encoding: "utf-8" },
);
diffStat = stagedStat.trim();
if (diffStat.length === 0) {
const { stdout: headStat } = await execAsync(
`git diff ${targetArg} HEAD --stat`,
{ cwd: rootDir, encoding: "utf-8" },
);
diffStat = headStat.trim();
}
} catch {
// best-effort
}
let commitLog = "";
try {
const targetPatchIds = await collectPatchIds(rootDir, integrationTargetSha, 200);
const { stdout: branchShas } = await execAsync(
`git log ${targetArg}..${quoteArg(branch)} --format=%H`,
{ cwd: rootDir, encoding: "utf-8" },
);
const shas = branchShas.trim().split("\n").filter(Boolean);
const lines: string[] = [];
for (const sha of shas) {
const pid = await commitPatchId(rootDir, sha);
if (pid && targetPatchIds.has(pid)) continue;
try {
const { stdout: subj } = await execAsync(
`git log -1 ${quoteArg(sha)} --format=%s`,
{ cwd: rootDir, encoding: "utf-8" },
);
const s = subj.trim();
if (s) lines.push(`- ${s}`);
} catch {
// skip this commit on failure
}
}
commitLog = lines.join("\n");
} catch {
// best-effort
}
return { commitLog, diffStat };
}
/** /**
* List commits unique to `branch` relative to `target`, oldest-first so they * List commits unique to `branch` relative to `target`, oldest-first so they
* can be cherry-picked in order. * can be cherry-picked in order.
@@ -4292,11 +4414,32 @@ 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);
// Recompute context against the AI commit's parent (= integration
// target) so the message describes only what this commit actually
// adds — not the wide branch range, which under squash-merge can
// include work already landed via prior task merges.
let integrationTargetSha: string | undefined;
try {
const { stdout } = await execAsync("git rev-parse HEAD~1", {
cwd: rootDir,
encoding: "utf-8",
});
integrationTargetSha = stdout.trim() || undefined;
} catch {
// Root commit / detached state — fall through to wide-range values.
}
const actualContext = integrationTargetSha
? await computeActualMergeCommitContext({
rootDir,
integrationTargetSha,
branch,
})
: { commitLog: "", diffStat: "" };
const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({ const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({
taskId, taskId,
branch, branch,
commitLog, commitLog: actualContext.commitLog || commitLog,
diffStat, diffStat: actualContext.diffStat || diffStat,
includeTaskId, includeTaskId,
rootDir, rootDir,
settings: params.settings, settings: params.settings,