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 {
|
||||
summarizeTitle,
|
||||
summarizeCommitBody,
|
||||
sanitizeCommitSubject,
|
||||
MAX_COMMIT_SUBJECT_LENGTH,
|
||||
checkRateLimit,
|
||||
getRateLimitResetTime,
|
||||
validateDescription,
|
||||
@@ -266,6 +268,63 @@ describe("ai-summarize", () => {
|
||||
|
||||
// ── 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", () => {
|
||||
it("should clear all rate limit entries", () => {
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -185,11 +185,16 @@ export type {
|
||||
export {
|
||||
summarizeTitle,
|
||||
summarizeCommitBody,
|
||||
summarizeCommitSubject,
|
||||
sanitizeCommitSubject,
|
||||
checkRateLimit,
|
||||
getRateLimitResetTime,
|
||||
validateDescription,
|
||||
SUMMARIZE_SYSTEM_PROMPT,
|
||||
COMMIT_BODY_SYSTEM_PROMPT,
|
||||
COMMIT_SUBJECT_SYSTEM_PROMPT,
|
||||
MAX_COMMIT_SUBJECT_LENGTH,
|
||||
DEFAULT_COMMIT_SUBJECT_TIMEOUT_MS,
|
||||
MAX_DESCRIPTION_LENGTH,
|
||||
MIN_DESCRIPTION_LENGTH,
|
||||
MAX_TITLE_LENGTH,
|
||||
|
||||
@@ -139,6 +139,7 @@ import {
|
||||
resolveProjectDefaultModel,
|
||||
resolveAgentPrompt,
|
||||
summarizeCommitBody,
|
||||
summarizeCommitSubject,
|
||||
type TaskStore,
|
||||
type MergeResult,
|
||||
type MergeDetails,
|
||||
@@ -1079,7 +1080,7 @@ async function buildDeterministicMergeMessage(params: {
|
||||
}): Promise<{ subjectArg: string; bodyArg: string }> {
|
||||
const { taskId, branch, commitLog, diffStat, includeTaskId, rootDir, settings, signal } = params;
|
||||
const prefix = includeTaskId ? `feat(${taskId})` : "feat";
|
||||
const subject = `${prefix}: merge ${branch}`;
|
||||
const fallbackSubject = `${prefix}: merge ${branch}`;
|
||||
|
||||
const trimmedCommitLog = commitLog?.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).
|
||||
// Falls back to the project default when not configured. Any failure (no
|
||||
// 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 aiSubject: string | null = null;
|
||||
if (rootDir && settings && (trimmedCommitLog.length > 0 || trimmedDiffStat.length > 0)) {
|
||||
const useTitleSummarizer =
|
||||
!!settings.titleSummarizerProvider && !!settings.titleSummarizerModelId;
|
||||
@@ -1106,12 +1110,32 @@ async function buildDeterministicMergeMessage(params: {
|
||||
? settings.defaultModelIdOverride
|
||||
: settings.defaultModelId);
|
||||
|
||||
aiSummary = await summarizeCommitBody(trimmedDiffStat, rootDir, provider, modelId, {
|
||||
branch,
|
||||
taskId,
|
||||
commitLog: trimmedCommitLog,
|
||||
signal,
|
||||
}).catch(() => null);
|
||||
const [bodyResult, subjectResult] = await Promise.all([
|
||||
summarizeCommitBody(trimmedDiffStat, rootDir, provider, modelId, {
|
||||
branch,
|
||||
taskId,
|
||||
commitLog: trimmedCommitLog,
|
||||
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[] = [];
|
||||
@@ -1214,11 +1238,26 @@ async function commitOrAmendMergeWithFixes(
|
||||
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({
|
||||
taskId,
|
||||
branch,
|
||||
commitLog,
|
||||
diffStat,
|
||||
commitLog: messageCommitLog,
|
||||
diffStat: messageDiffStat,
|
||||
includeTaskId,
|
||||
rootDir,
|
||||
settings,
|
||||
@@ -2046,6 +2085,89 @@ async function collectPatchIds(
|
||||
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
|
||||
* can be cherry-picked in order.
|
||||
@@ -4292,11 +4414,32 @@ async function executeMergeAttempt(
|
||||
// of mergeDetails surface. Subject keeps the conventional-commit shape.
|
||||
try {
|
||||
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({
|
||||
taskId,
|
||||
branch,
|
||||
commitLog,
|
||||
diffStat,
|
||||
commitLog: actualContext.commitLog || commitLog,
|
||||
diffStat: actualContext.diffStat || diffStat,
|
||||
includeTaskId,
|
||||
rootDir,
|
||||
settings: params.settings,
|
||||
|
||||
Reference in New Issue
Block a user