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:
@@ -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";
|
||||||
|
|||||||
@@ -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 40–55
|
||||||
|
- 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 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user