feat(FN-4949): add reclaim deferral guards for active executor worktrees
Adds reclaim deferral guards to self-healing to prevent stale-branch cleanup from racing against in-flight executor sessions, with corresponding audit telemetry and test coverage for the interaction. Also updates AGENTS.md to document the new behavior. Fusion-Task-Id: FN-4949
This commit is contained in:
committed by
gsxdsm
parent
287673c261
commit
543ea66799
@@ -2583,6 +2583,98 @@ describe("aiMergeTask post-squash audit gate", () => {
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "post-rebase range audit clean", "text", undefined, "merger");
|
||||
});
|
||||
|
||||
it("degrades to squash fallback when no usable base can be resolved on the rebase route", async () => {
|
||||
setupRebaseRouteExecSync();
|
||||
const baseImpl = mockedExecSync.getMockImplementation();
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes('git rev-parse "main"')) return "" as any;
|
||||
if (cmdStr.includes('git rev-parse --verify "abc123^{commit}"')) return "abc123\n" as any;
|
||||
if (cmdStr.includes('git merge-base --is-ancestor "abc123" "landedcommit002"')) return "" as any;
|
||||
if (cmdStr.includes('git rev-list --reverse "..fusion/fn-050"')) return "commit-a\ncommit-b\ncommit-c\n" as any;
|
||||
if (cmdStr.includes('git diff --shortstat "..HEAD"')) return "2 files changed, 6 insertions(+), 1 deletion(-)" as any;
|
||||
if (cmdStr.includes("git show --shortstat --format= HEAD")) return "2 files changed, 6 insertions(+), 1 deletion(-)" as any;
|
||||
return baseImpl ? baseImpl(cmd) : Buffer.from("");
|
||||
});
|
||||
mockedAuditSquashMerge.mockResolvedValue({
|
||||
strategy: "rebase",
|
||||
rangeBaseSha: "basehead123",
|
||||
rangeHeadSha: "landedcommit002",
|
||||
parentSha: "basehead123",
|
||||
auditTargetLabel: "basehead123..landedcommit002",
|
||||
lookback: 30,
|
||||
branchSubjects: ["fix: substantive one", "feat: substantive two"],
|
||||
recentMainSubjects: [],
|
||||
duplicateSubjects: [],
|
||||
touchedFiles: [],
|
||||
touchedFileOverlaps: [],
|
||||
findings: [],
|
||||
issueCount: 0,
|
||||
clean: true,
|
||||
});
|
||||
const store = createAuditStore({}, { prompt: "**Direct Merge Commit Strategy:** always-rebase" });
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
expect(mockedAuditSquashMerge).toHaveBeenCalledWith(expect.objectContaining({
|
||||
strategy: "squash",
|
||||
squashSha: "landedcommit002",
|
||||
}));
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-050",
|
||||
expect.stringContaining("post-merge audit degraded to single-commit squash fallback"),
|
||||
"text",
|
||||
undefined,
|
||||
"merger",
|
||||
);
|
||||
});
|
||||
|
||||
it("logs degraded squash fallback when no rebase range base can be resolved", async () => {
|
||||
setupRebaseRouteExecSync();
|
||||
const baseImpl = mockedExecSync.getMockImplementation();
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes('git rev-parse "main"')) return "" as any;
|
||||
if (cmdStr.includes("git rev-parse --verify") && cmdStr.includes("^{commit}")) return "landedcommit002\n" as any;
|
||||
if (cmdStr.includes("git merge-base") && cmdStr.includes("landedcommit002") && cmdStr.includes("main")) return "landedcommit002\n" as any;
|
||||
if (cmdStr.includes('git rev-list --reverse "..fusion/fn-050"')) return "commit-a\ncommit-b\ncommit-c\n" as any;
|
||||
if (cmdStr.includes('git diff --shortstat "..HEAD"')) return "2 files changed, 6 insertions(+), 1 deletion(-)" as any;
|
||||
if (cmdStr.includes("git show --shortstat --format= HEAD")) return "2 files changed, 6 insertions(+), 1 deletion(-)" as any;
|
||||
return baseImpl ? baseImpl(cmd) : Buffer.from("");
|
||||
});
|
||||
mockedAuditSquashMerge.mockResolvedValue({
|
||||
strategy: "squash",
|
||||
squashSha: "landedcommit002",
|
||||
parentSha: "basehead123",
|
||||
auditTargetLabel: "landedcommit002",
|
||||
squashSubject: "feat: squash merge",
|
||||
lookback: 30,
|
||||
branchSubjects: ["feat: substantive two"],
|
||||
recentMainSubjects: [],
|
||||
duplicateSubjects: [],
|
||||
touchedFiles: ["src/feature-b.ts"],
|
||||
touchedFileOverlaps: [],
|
||||
findings: [],
|
||||
issueCount: 0,
|
||||
clean: true,
|
||||
});
|
||||
const store = createAuditStore({}, { prompt: "**Direct Merge Commit Strategy:** always-rebase" });
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
expect(mockedAuditSquashMerge).toHaveBeenCalledWith(expect.objectContaining({
|
||||
strategy: "squash",
|
||||
squashSha: "landedcommit002",
|
||||
}));
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-050",
|
||||
expect.stringContaining("post-merge audit degraded to single-commit squash fallback"),
|
||||
"text",
|
||||
undefined,
|
||||
"merger",
|
||||
);
|
||||
});
|
||||
|
||||
it("honors the per-task always-rebase override", async () => {
|
||||
setupRebaseRouteExecSync();
|
||||
mockedAuditSquashMerge.mockResolvedValue({
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolvePostMergeAuditInvocation } from "../merger.js";
|
||||
|
||||
function git(cwd: string, command: string): string {
|
||||
return execSync(`git ${command}`, { cwd, encoding: "utf-8" }).trim();
|
||||
}
|
||||
|
||||
function createRepoWithFeatureCommit() {
|
||||
const dir = mkdtempSync(join(tmpdir(), "fn-4961-audit-"));
|
||||
git(dir, "init -b main");
|
||||
git(dir, "config user.email test@example.com");
|
||||
git(dir, "config user.name Test");
|
||||
|
||||
writeFileSync(join(dir, "file.txt"), "a\n");
|
||||
git(dir, "add file.txt");
|
||||
git(dir, "commit -m base");
|
||||
const baseSha = git(dir, "rev-parse HEAD");
|
||||
|
||||
writeFileSync(join(dir, "file.txt"), "a\nb\n");
|
||||
git(dir, "add file.txt");
|
||||
git(dir, "commit -m main-two");
|
||||
const mainTipSha = git(dir, "rev-parse HEAD");
|
||||
|
||||
git(dir, "checkout -b feature");
|
||||
writeFileSync(join(dir, "feature.txt"), "c\n");
|
||||
git(dir, "add feature.txt");
|
||||
git(dir, "commit -m feature");
|
||||
const auditSha = git(dir, "rev-parse HEAD");
|
||||
|
||||
return { dir, baseSha, mainTipSha, auditSha };
|
||||
}
|
||||
|
||||
describe("resolvePostMergeAuditInvocation", () => {
|
||||
const cleanup: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of cleanup.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("uses explicit rebaseMergeBaseSha when present", async () => {
|
||||
const { dir, baseSha, auditSha } = createRepoWithFeatureCommit();
|
||||
cleanup.push(dir);
|
||||
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
|
||||
const mergerLog = { log: vi.fn(), warn: vi.fn() };
|
||||
|
||||
const input = await resolvePostMergeAuditInvocation({
|
||||
rootDir: dir,
|
||||
strategy: "rebase",
|
||||
auditSha,
|
||||
rebaseMergeBaseSha: baseSha,
|
||||
diffBaseRef: undefined,
|
||||
mergeTargetBranch: "main",
|
||||
taskBaseCommitSha: undefined,
|
||||
taskId: "FN-4961",
|
||||
store: { appendAgentLog },
|
||||
mergerLog,
|
||||
});
|
||||
|
||||
expect(input).toMatchObject({ strategy: "rebase", rangeBaseSha: baseSha, rangeHeadSha: auditSha });
|
||||
expect(appendAgentLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("derives from diffBaseRef when resolvable and ancestor", async () => {
|
||||
const { dir, mainTipSha, auditSha } = createRepoWithFeatureCommit();
|
||||
cleanup.push(dir);
|
||||
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
|
||||
const mergerLog = { log: vi.fn(), warn: vi.fn() };
|
||||
|
||||
const input = await resolvePostMergeAuditInvocation({
|
||||
rootDir: dir,
|
||||
strategy: "rebase",
|
||||
auditSha,
|
||||
diffBaseRef: mainTipSha,
|
||||
mergeTargetBranch: "main",
|
||||
taskId: "FN-4961",
|
||||
store: { appendAgentLog },
|
||||
mergerLog,
|
||||
});
|
||||
|
||||
expect(input).toMatchObject({ strategy: "rebase", rangeBaseSha: mainTipSha, rangeHeadSha: auditSha });
|
||||
expect(appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-4961",
|
||||
expect.stringContaining("from diffBaseRef"),
|
||||
"text",
|
||||
undefined,
|
||||
"merger",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to task base commit sha", async () => {
|
||||
const { dir, baseSha, auditSha } = createRepoWithFeatureCommit();
|
||||
cleanup.push(dir);
|
||||
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
|
||||
const mergerLog = { log: vi.fn(), warn: vi.fn() };
|
||||
|
||||
const input = await resolvePostMergeAuditInvocation({
|
||||
rootDir: dir,
|
||||
strategy: "rebase",
|
||||
auditSha,
|
||||
diffBaseRef: "missing-ref",
|
||||
taskBaseCommitSha: baseSha,
|
||||
mergeTargetBranch: "main",
|
||||
taskId: "FN-4961",
|
||||
store: { appendAgentLog },
|
||||
mergerLog,
|
||||
});
|
||||
|
||||
expect(input).toMatchObject({ strategy: "rebase", rangeBaseSha: baseSha, rangeHeadSha: auditSha });
|
||||
expect(appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-4961",
|
||||
expect.stringContaining("from baseCommitSha"),
|
||||
"text",
|
||||
undefined,
|
||||
"merger",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to merge-base when other candidates are unavailable", async () => {
|
||||
const { dir, mainTipSha, auditSha } = createRepoWithFeatureCommit();
|
||||
cleanup.push(dir);
|
||||
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
|
||||
const mergerLog = { log: vi.fn(), warn: vi.fn() };
|
||||
|
||||
const input = await resolvePostMergeAuditInvocation({
|
||||
rootDir: dir,
|
||||
strategy: "rebase",
|
||||
auditSha,
|
||||
diffBaseRef: auditSha,
|
||||
taskBaseCommitSha: "missing",
|
||||
mergeTargetBranch: "main",
|
||||
taskId: "FN-4961",
|
||||
store: { appendAgentLog },
|
||||
mergerLog,
|
||||
});
|
||||
|
||||
expect(input).toMatchObject({ strategy: "rebase", rangeBaseSha: mainTipSha, rangeHeadSha: auditSha });
|
||||
expect(appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-4961",
|
||||
expect.stringContaining("from merge-base"),
|
||||
"text",
|
||||
undefined,
|
||||
"merger",
|
||||
);
|
||||
});
|
||||
|
||||
it("degrades to squash when all candidates are unusable", async () => {
|
||||
const { dir, auditSha } = createRepoWithFeatureCommit();
|
||||
cleanup.push(dir);
|
||||
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
|
||||
const mergerLog = { log: vi.fn(), warn: vi.fn() };
|
||||
|
||||
const input = await resolvePostMergeAuditInvocation({
|
||||
rootDir: dir,
|
||||
strategy: "rebase",
|
||||
auditSha,
|
||||
diffBaseRef: auditSha,
|
||||
taskBaseCommitSha: auditSha,
|
||||
mergeTargetBranch: "feature",
|
||||
taskId: "FN-4961",
|
||||
store: { appendAgentLog },
|
||||
mergerLog,
|
||||
});
|
||||
|
||||
expect(input).toMatchObject({ strategy: "squash", squashSha: auditSha });
|
||||
expect(appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-4961",
|
||||
expect.stringContaining("post-merge audit degraded to single-commit squash fallback"),
|
||||
"text",
|
||||
undefined,
|
||||
"merger",
|
||||
);
|
||||
expect(mergerLog.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("always returns squash input for squash strategy", async () => {
|
||||
const { dir, auditSha } = createRepoWithFeatureCommit();
|
||||
cleanup.push(dir);
|
||||
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const input = await resolvePostMergeAuditInvocation({
|
||||
rootDir: dir,
|
||||
strategy: "squash",
|
||||
auditSha,
|
||||
mergeTargetBranch: "main",
|
||||
taskId: "FN-4961",
|
||||
store: { appendAgentLog },
|
||||
mergerLog: { log: vi.fn(), warn: vi.fn() },
|
||||
});
|
||||
|
||||
expect(input).toMatchObject({ strategy: "squash", squashSha: auditSha });
|
||||
expect(appendAgentLog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -154,4 +154,69 @@ describeIfGit("auditSquashMerge", () => {
|
||||
file: "shared.txt",
|
||||
});
|
||||
});
|
||||
|
||||
it("rebase strategy audits overlap across all commits in a multi-commit range", async () => {
|
||||
const repo = setupRepo();
|
||||
|
||||
write(repo, "shared-a.txt", "a0\n");
|
||||
write(repo, "shared-b.txt", "b0\n");
|
||||
git(repo, "git add shared-a.txt shared-b.txt && git commit -m 'chore: seed shared files'");
|
||||
|
||||
git(repo, "git checkout -b feature/multi-range");
|
||||
write(repo, "shared-a.txt", "a0\nfeature-a\n");
|
||||
git(repo, "git add shared-a.txt && git commit -m 'feat: branch overlap a'");
|
||||
write(repo, "shared-b.txt", "b0\nfeature-b\n");
|
||||
git(repo, "git add shared-b.txt && git commit -m 'feat: branch overlap b'");
|
||||
const rangeHeadSha = git(repo, "git rev-parse HEAD");
|
||||
|
||||
git(repo, "git checkout main");
|
||||
write(repo, "shared-a.txt", "a0\nmain-a\n");
|
||||
git(repo, "git add shared-a.txt && git commit -m 'feat: branch overlap a'");
|
||||
write(repo, "shared-b.txt", "b0\nmain-b\n");
|
||||
git(repo, "git add shared-b.txt && git commit -m 'feat: branch overlap b'");
|
||||
|
||||
const rangeBaseSha = git(repo, "git merge-base feature/multi-range main");
|
||||
const findings = await auditSquashMerge({
|
||||
rootDir: repo,
|
||||
strategy: "rebase",
|
||||
rangeBaseSha,
|
||||
rangeHeadSha,
|
||||
lookback: 20,
|
||||
});
|
||||
|
||||
expect(findings.clean).toBe(false);
|
||||
expect(findings.touchedFileOverlaps).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ file: "shared-a.txt" }),
|
||||
expect.objectContaining({ file: "shared-b.txt" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("single-commit squash fallback on the same multi-commit branch only sees tip subject", async () => {
|
||||
const repo = setupRepo();
|
||||
|
||||
write(repo, "shared-a.txt", "a0\n");
|
||||
write(repo, "shared-b.txt", "b0\n");
|
||||
git(repo, "git add shared-a.txt shared-b.txt && git commit -m 'chore: seed shared files'");
|
||||
|
||||
git(repo, "git checkout -b feature/multi-fallback");
|
||||
write(repo, "shared-a.txt", "a0\nfeature-a\n");
|
||||
git(repo, "git add shared-a.txt && git commit -m 'feat: branch overlap a'");
|
||||
write(repo, "shared-b.txt", "b0\nfeature-b\n");
|
||||
git(repo, "git add shared-b.txt && git commit -m 'feat: branch overlap b'");
|
||||
|
||||
git(repo, "git checkout main");
|
||||
write(repo, "shared-a.txt", "a0\nmain-a\n");
|
||||
git(repo, "git add shared-a.txt && git commit -m 'feat: branch overlap a'");
|
||||
write(repo, "shared-b.txt", "b0\nmain-b\n");
|
||||
git(repo, "git add shared-b.txt && git commit -m 'feat: branch overlap b'");
|
||||
|
||||
write(repo, "shared-b.txt", "b0\nmain-b\nfinal\n");
|
||||
git(repo, "git add shared-b.txt && git commit -m 'feat: squash merge' -m '- feat: branch overlap b'");
|
||||
const squashSha = git(repo, "git rev-parse HEAD");
|
||||
|
||||
const findings = await auditSquashMerge({ rootDir: repo, strategy: "squash", squashSha, lookback: 20 });
|
||||
|
||||
expect(findings.duplicateSubjects).toEqual([{ type: "duplicate-subject", subject: "feat: branch overlap b" }]);
|
||||
expect(findings.duplicateSubjects).not.toContainEqual({ type: "duplicate-subject", subject: "feat: branch overlap a" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,7 +80,13 @@ import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { createRunAuditor, generateSyntheticRunId, type EngineRunContext, type RunAuditor } from "./run-audit.js";
|
||||
import { createWebFetchTool } from "./agent-tools.js";
|
||||
import { auditSquashMerge, MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS, type PostMergeAuditStrategy, type SquashAuditFindings } from "./merger-squash-audit.js";
|
||||
import {
|
||||
auditSquashMerge,
|
||||
MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS,
|
||||
type PostMergeAuditInput,
|
||||
type PostMergeAuditStrategy,
|
||||
type SquashAuditFindings,
|
||||
} from "./merger-squash-audit.js";
|
||||
import { detectMergeOverlap, restoreBranchWinsFiles } from "./merger-overlap-guard.js";
|
||||
import { checkDiffVolume, DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
|
||||
import { ReadonlyViolationError, filterCustomToolsForReadonly } from "./workflow-step-tool-policy.js";
|
||||
@@ -5061,6 +5067,132 @@ function shouldRunPostMergeAudit(
|
||||
return (result.autoResolvedCount ?? 0) > 0 || result.attemptsMade === 3;
|
||||
}
|
||||
|
||||
export interface ResolvePostMergeAuditInvocationInput {
|
||||
rootDir: string;
|
||||
strategy: PostMergeAuditStrategy;
|
||||
auditSha: string;
|
||||
rebaseMergeBaseSha?: string;
|
||||
diffBaseRef?: string;
|
||||
mergeTargetBranch: string;
|
||||
taskBaseCommitSha?: string;
|
||||
taskId: string;
|
||||
store: Pick<TaskStore, "appendAgentLog">;
|
||||
mergerLog: { warn: (message: string) => void; log: (message: string) => void; };
|
||||
}
|
||||
|
||||
async function resolveAuditRangeBaseCandidate(opts: {
|
||||
rootDir: string;
|
||||
auditSha: string;
|
||||
candidateRef: string;
|
||||
}): Promise<string | undefined> {
|
||||
const candidateRef = opts.candidateRef.trim();
|
||||
if (!candidateRef) return undefined;
|
||||
|
||||
try {
|
||||
const { stdout: resolvedOut } = await execAsync(`git rev-parse --verify ${quoteArg(`${candidateRef}^{commit}`)}`, {
|
||||
cwd: opts.rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const resolvedSha = resolvedOut.trim();
|
||||
if (!resolvedSha || resolvedSha === opts.auditSha) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
await execAsync(`git merge-base --is-ancestor ${quoteArg(resolvedSha)} ${quoteArg(opts.auditSha)}`, {
|
||||
cwd: opts.rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
return resolvedSha;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolvePostMergeAuditInvocation(
|
||||
opts: ResolvePostMergeAuditInvocationInput,
|
||||
): Promise<PostMergeAuditInput> {
|
||||
if (opts.strategy === "squash") {
|
||||
return {
|
||||
rootDir: opts.rootDir,
|
||||
strategy: "squash",
|
||||
squashSha: opts.auditSha,
|
||||
};
|
||||
}
|
||||
|
||||
if (opts.rebaseMergeBaseSha && opts.rebaseMergeBaseSha !== opts.auditSha) {
|
||||
return {
|
||||
rootDir: opts.rootDir,
|
||||
strategy: "rebase",
|
||||
rangeBaseSha: opts.rebaseMergeBaseSha,
|
||||
rangeHeadSha: opts.auditSha,
|
||||
};
|
||||
}
|
||||
|
||||
const rangeCandidates: Array<{ source: "diffBaseRef" | "baseCommitSha"; ref?: string }> = [
|
||||
{ source: "diffBaseRef", ref: opts.diffBaseRef },
|
||||
{ source: "baseCommitSha", ref: opts.taskBaseCommitSha },
|
||||
];
|
||||
|
||||
for (const candidate of rangeCandidates) {
|
||||
if (!candidate.ref?.trim()) continue;
|
||||
const resolved = await resolveAuditRangeBaseCandidate({
|
||||
rootDir: opts.rootDir,
|
||||
auditSha: opts.auditSha,
|
||||
candidateRef: candidate.ref,
|
||||
});
|
||||
if (!resolved) continue;
|
||||
|
||||
const infoMessage = `${opts.taskId}: post-merge audit using rebase range base from ${candidate.source} (${resolved.slice(0, 8)}..${opts.auditSha.slice(0, 8)})`;
|
||||
opts.mergerLog.log(infoMessage);
|
||||
await opts.store.appendAgentLog(opts.taskId, infoMessage, "text", undefined, "merger");
|
||||
return {
|
||||
rootDir: opts.rootDir,
|
||||
strategy: "rebase",
|
||||
rangeBaseSha: resolved,
|
||||
rangeHeadSha: opts.auditSha,
|
||||
};
|
||||
}
|
||||
|
||||
let mergeBaseSha: string | undefined;
|
||||
try {
|
||||
const { stdout } = await execAsync(`git merge-base ${quoteArg(opts.auditSha)} ${quoteArg(opts.mergeTargetBranch)}`, {
|
||||
cwd: opts.rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const mergeBaseRef = stdout.trim();
|
||||
if (mergeBaseRef) {
|
||||
mergeBaseSha = await resolveAuditRangeBaseCandidate({
|
||||
rootDir: opts.rootDir,
|
||||
auditSha: opts.auditSha,
|
||||
candidateRef: mergeBaseRef,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
mergeBaseSha = undefined;
|
||||
}
|
||||
|
||||
if (mergeBaseSha) {
|
||||
const infoMessage = `${opts.taskId}: post-merge audit using rebase range base from merge-base (${mergeBaseSha.slice(0, 8)}..${opts.auditSha.slice(0, 8)})`;
|
||||
opts.mergerLog.log(infoMessage);
|
||||
await opts.store.appendAgentLog(opts.taskId, infoMessage, "text", undefined, "merger");
|
||||
return {
|
||||
rootDir: opts.rootDir,
|
||||
strategy: "rebase",
|
||||
rangeBaseSha: mergeBaseSha,
|
||||
rangeHeadSha: opts.auditSha,
|
||||
};
|
||||
}
|
||||
|
||||
const degradedMessage = `${opts.taskId}: post-merge audit degraded to single-commit squash fallback (multi-commit branch, no usable rangeBase)`;
|
||||
opts.mergerLog.warn(degradedMessage);
|
||||
await opts.store.appendAgentLog(opts.taskId, degradedMessage, "text", undefined, "merger");
|
||||
return {
|
||||
rootDir: opts.rootDir,
|
||||
strategy: "squash",
|
||||
squashSha: opts.auditSha,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what to do with a dirty post-merge audit (FN-4333 hot-fix).
|
||||
*
|
||||
@@ -7414,18 +7546,19 @@ export async function aiMergeTask(
|
||||
&& postMergeAuditMode !== "off"
|
||||
&& shouldRunPostMergeAudit(selectedPostMergeAuditStrategy, result, mergeWasEmpty, isEmptyCommit, auditSha)
|
||||
) {
|
||||
const auditFindings = selectedPostMergeAuditStrategy === "rebase" && rebaseMergeBaseSha
|
||||
? await auditSquashMerge({
|
||||
rootDir,
|
||||
strategy: "rebase",
|
||||
rangeBaseSha: rebaseMergeBaseSha,
|
||||
rangeHeadSha: auditSha,
|
||||
})
|
||||
: await auditSquashMerge({
|
||||
rootDir,
|
||||
strategy: "squash",
|
||||
squashSha: auditSha,
|
||||
});
|
||||
const auditInvocation = await resolvePostMergeAuditInvocation({
|
||||
rootDir,
|
||||
strategy: selectedPostMergeAuditStrategy,
|
||||
auditSha,
|
||||
rebaseMergeBaseSha,
|
||||
diffBaseRef,
|
||||
mergeTargetBranch: mergeTarget.branch,
|
||||
taskBaseCommitSha: task.baseCommitSha,
|
||||
taskId,
|
||||
store,
|
||||
mergerLog,
|
||||
});
|
||||
const auditFindings = await auditSquashMerge(auditInvocation);
|
||||
if (!auditFindings.clean) {
|
||||
// FN-4333/FN-4344: rebase overlap-only findings can be auto-cleared
|
||||
// when deterministic verification already proved the merged tree,
|
||||
|
||||
Reference in New Issue
Block a user