fix(engine): keep mergeDetails.commitSha honest on empty/post-push paths

Two cases where mergeDetails.commitSha was wrong:

1. Empty-squash success paths (mergeAttempt + attemptWithSideStrategy
   return true when nothing was staged) recorded pre-merge HEAD as the
   task's commitSha. That commit had nothing to do with this task —
   misleading the dashboard, audit log, and recovery scans.

2. pushAfterMerge can trigger an internal pull --rebase that rewrites
   HEAD; mergeDetails was captured before push, so the stored sha
   referenced a now-orphaned commit.

Fix:
- Track an empty-merge flag on AiInvocationTracker, set at the three
  squashIsEmpty/staged===0 sites. Metadata block omits commitSha when
  the flag is set.
- After successful pushAfterMerge, recapture HEAD and update
  mergeDetails.commitSha if it changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-28 17:29:21 -07:00
parent 6d9dcdd755
commit 502fddf20e
2 changed files with 109 additions and 10 deletions

View File

@@ -867,6 +867,35 @@ describe("aiMergeTask — empty squash merge (branch already merged via dep)", (
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("does not record commitSha when squash is empty (would be pre-merge HEAD)", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "premergehead999";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return " 5 files changed, 12 insertions(+), 3 deletions(-)\n" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore();
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// mergeDetails should be stored without commitSha (which would be the
// pre-merge HEAD, unrelated to this task).
const updateCalls = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls;
const mergeDetailsCall = updateCalls.find((call: any[]) => call[1]?.mergeDetails !== undefined);
expect(mergeDetailsCall).toBeDefined();
expect(mergeDetailsCall![1].mergeDetails.commitSha).toBeUndefined();
});
it("still cleans up branch and worktree when squash is empty", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);