fix(engine): refuse to treat non-conflict squash failures as no-op merges

Attempt 2 of the merge cascade caught any git merge --squash failure into
mergeExitedWithConflicts=true. If the failure was non-conflict (pre-commit
hook rejection, IO error, locked repo) and produced no U files, the code
fell into the "all conflicts auto-resolved" branch with empty classified
arrays, ran deterministic verification on pre-merge HEAD, and returned
true — recording merge metadata for a merge that never happened.

Distinguish "exit code 1 with U files" (recoverable) from "any other
failure" (real). When a real failure surfaces with no conflicts, raise a
sentinel MergeNonConflictError that the outer mergeAttempt catch propagates
without retrying — retrying just re-runs the same broken command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-28 17:19:12 -07:00
parent a0f9e9019e
commit 9d09b4c599
2 changed files with 66 additions and 3 deletions

View File

@@ -2103,6 +2103,42 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
expect(agentCallCount).toBe(1);
});
it("attempt 2 throws when squash fails for a non-conflict reason (no U files)", async () => {
// Regression: previously any squash error was treated as conflicts. If
// the failure was non-conflict (hook, IO, lock) and no U files existed,
// the cascade fell into "all conflicts auto-resolved" and returned true,
// recording merge metadata for a merge that never happened.
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
let mergeCallCount = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) {
mergeCallCount++;
// Simulate a non-conflict failure on every squash attempt
// (e.g. pre-commit hook rejected, repo locked).
throw new Error("fatal: pre-commit hook returned non-zero status");
}
// Critical: no conflicted files surface
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("reset --merge")) return Buffer.from("");
return Buffer.from("");
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
/failed without producing conflicts/i,
);
expect(mergeCallCount).toBeGreaterThanOrEqual(1);
});
it("attempt 1 fails, attempt 2 auto-resolves lock files: sets resolutionStrategy to 'auto-resolve'", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },