fix(engine): harden smart-prefer-main against silent rebase skips

The smart-prefer-main strategy depends on a successful pre-merge rebase
to honor main's deletions. Previously, three failure modes silently fell
through to the -X ours merge, which would re-introduce code main had
just removed (because branch additions vs main deletions don't textually
conflict and -X ours only resolves content conflicts, not modify/delete).

- Hard-fail when prefer-main is paired with worktreeRebaseBeforeMerge=false
  (semantically incoherent combination)
- Hard-fail when the pre-merge rebase starts and aborts (any of the three
  rebase paths: remote, nested local-base, or fallback local-only)
- Warn (not throw) on environmental silent skips — no remote resolvable
  or no worktreePath — so the gap is observable in logs without breaking
  common test/setup environments

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-28 16:57:17 -07:00
parent cc9181db47
commit d6d5aa570f
2 changed files with 143 additions and 0 deletions

View File

@@ -578,6 +578,11 @@ describe("aiMergeTask — pre-merge rebase abort observability", () => {
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// Fall-through is only allowed for prefer-branch; prefer-main hard-fails (see test below).
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeConflictStrategy: "smart-prefer-branch",
});
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
@@ -624,6 +629,10 @@ describe("aiMergeTask — pre-merge rebase abort observability", () => {
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeConflictStrategy: "smart-prefer-branch",
});
const warnSpy = vi.spyOn(mergerLog, "warn");
const abortFailureMessage = "fatal: no rebase in progress";
@@ -667,6 +676,63 @@ describe("aiMergeTask — pre-merge rebase abort observability", () => {
warnSpy.mockRestore();
});
it("hard-fails when prefer-main is paired with worktreeRebaseBeforeMerge=false", async () => {
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],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeConflictStrategy: "smart-prefer-main",
worktreeRebaseBeforeMerge: false,
});
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git rev-parse --abbrev-ref")) return "main" as any;
return Buffer.from("");
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
/Incompatible settings.*smart-prefer-main.*worktreeRebaseBeforeMerge/i,
);
});
it("hard-fails when smart-prefer-main rebase aborts (no silent fall-through)", async () => {
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],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeConflictStrategy: "smart-prefer-main",
});
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
if (cmdStr.includes("git rev-parse --abbrev-ref origin/HEAD")) return "origin/main" as any;
if (cmdStr === "git rev-parse --abbrev-ref HEAD") return "main" as any;
if (cmdStr.includes("git config --get branch.main.remote")) return "origin" as any;
if (cmdStr === 'git fetch "origin"') return Buffer.from("");
if (cmdStr === 'git rebase "origin/main"') {
throw new Error("pre-merge rebase conflict");
}
if (cmdStr === "git rebase --abort") return Buffer.from("");
return Buffer.from("");
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
/smart-prefer-main.*rebase/i,
);
// Critical: the unsafe -X ours fallback must not have run.
expect(
mockedExec.mock.calls.some(([command]) => String(command).includes("merge -X ours")),
).toBe(false);
});
});
describe("aiMergeTask — task.branch field", () => {