feat(engine): merger auto-syncs project-root checkout after ref advance

After advanceIntegrationBranchRef ff-updates refs/heads/<integrationBranch>,
the merger now enumerates other worktrees on that branch and reconciles
each one's index + working tree to the new tip via syncWorktreeToHead.

Not a git pull — origin may still be at the previous tip without
pushAfterMerge, so pull --ff-only is a no-op and a naive stash/pull/pop
ends with the worktree restored to the old state. Instead the new
worktree-ref-sync helper:

  1. Diffs the worktree against the previous tip to isolate real edits
     from the stale-index "phantom diff" against the new HEAD.
  2. Snaps clean worktrees forward via reset --hard HEAD.
  3. In stash-and-ff mode with real edits, captures them as a binary patch
     against the previous tip, snaps to HEAD, then git apply --3way to
     restore. Untracked files are saved + restored separately. Patch
     conflicts surface as synced-with-pop-conflict with the patch left on
     disk for manual recovery.

Per-worktree outcome emitted as merge:auto-sync (new GitMutationType).
Per-step pull:fast-forward / stash:push / stash:pop / stash:pop-conflict
that pass through the auditor are tagged metadata.autoSync=true.

Isolated in its own try-catch so an auto-sync failure can't fail the
already-landed merge. Default behavior is mergeAdvanceAutoSync="stash-and-ff";
"off" preserves the legacy surprise behavior.

Backstopped by merger-auto-sync.slow.test.ts: clean-sync snaps both index
and files forward, ff-only with real edits is a no-op, stash-and-ff
preserves untracked locals across the snap, task worktrees on fusion/fn-*
are skipped, empty branch map emits nothing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-23 14:41:11 -07:00
parent db9928a4b1
commit 4c31e885bd
7 changed files with 689 additions and 3 deletions

View File

@@ -0,0 +1,197 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, realpathSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import type { RunAuditEventInput, TaskStore } from "@fusion/core";
import { createRunAuditor } from "../run-audit.js";
import { __test__ } from "../merger.js";
const { runMergeAdvanceAutoSync } = __test__;
function git(cwd: string, cmd: string): string {
return execSync(cmd, { cwd, stdio: "pipe" }).toString("utf-8").trim();
}
function testTempParent(): string {
return process.env.FUSION_TEST_WORKER_ROOT ?? tmpdir();
}
interface Fixture {
root: string;
upstream: string;
projectRoot: string;
taskWorktree: string;
previousSha: string;
newSha: string;
recorded: RunAuditEventInput[];
store: TaskStore;
}
function setupFixture(): Fixture {
const root = mkdtempSync(join(testTempParent(), "merger-auto-sync-"));
const upstream = join(root, "upstream.git");
const projectRoot = join(root, "project");
git(root, `git init --bare -b main "${upstream}"`);
git(root, `git clone "${upstream}" "${projectRoot}"`);
git(projectRoot, 'git config user.email "user@example.com"');
git(projectRoot, 'git config user.name "User"');
writeFileSync(join(projectRoot, "base.txt"), "v1\n");
git(projectRoot, "git add base.txt");
git(projectRoot, 'git commit -m "init"');
git(projectRoot, "git push -u origin main");
const previousSha = git(projectRoot, "git rev-parse HEAD");
// Build a task worktree on a fusion/fn-X branch and add a commit there —
// this emulates the merger's task worktree. Then advance refs/heads/main
// locally (no origin push) to that commit, leaving projectRoot's index +
// working tree pinned to `previousSha` while HEAD now resolves to `newSha`.
const taskWorktree = join(root, "task");
git(projectRoot, `git worktree add -b fusion/fn-test "${taskWorktree}"`);
writeFileSync(join(taskWorktree, "feature.txt"), "task work\n");
writeFileSync(join(taskWorktree, "base.txt"), "v2 from task\n");
git(taskWorktree, "git add -A");
git(taskWorktree, 'git commit -m "task commit"');
const newSha = git(taskWorktree, "git rev-parse HEAD");
git(projectRoot, `git update-ref refs/heads/main ${newSha}`);
const recorded: RunAuditEventInput[] = [];
const store = {
recordRunAuditEvent: vi.fn(async (input: RunAuditEventInput) => {
recorded.push(input);
}),
} as unknown as TaskStore;
return { root, upstream, projectRoot, taskWorktree, previousSha, newSha, recorded, store };
}
function makeAudit(store: TaskStore, taskId: string) {
return createRunAuditor(store, { runId: `run-${Date.now()}`, agentId: "merger", taskId, phase: "merge" });
}
describe("runMergeAdvanceAutoSync (post-local-ref-advance reconciliation)", () => {
let fx: Fixture;
beforeEach(() => {
fx = setupFixture();
});
afterEach(() => {
try {
rmSync(fx.root, { recursive: true, force: true });
} catch {
// best-effort
}
});
it("clean projectRoot: snaps index + worktree forward to newSha and emits outcome=clean-sync", async () => {
await runMergeAdvanceAutoSync({
store: fx.store,
audit: makeAudit(fx.store, "FN-TEST-1"),
taskId: "FN-TEST-1",
projectRootDir: fx.projectRoot,
integrationBranch: "main",
previousSha: fx.previousSha,
newSha: fx.newSha,
mode: "stash-and-ff",
});
const autoSync = fx.recorded.filter((e) => e.mutationType === "merge:auto-sync");
expect(autoSync).toHaveLength(1);
expect(autoSync[0].metadata).toMatchObject({
outcome: "clean-sync",
worktreePath: realpathSync(fx.projectRoot),
});
// The actual fix: worktree files now match newSha's tree.
expect(readFileSync(join(fx.projectRoot, "base.txt"), "utf-8")).toBe("v2 from task\n");
expect(readFileSync(join(fx.projectRoot, "feature.txt"), "utf-8")).toBe("task work\n");
// `git status` is now clean.
expect(git(fx.projectRoot, "git status --porcelain=v1")).toBe("");
});
it("ff-only mode + real edits: skipped-dirty, worktree untouched, no destructive operations", async () => {
writeFileSync(join(fx.projectRoot, "local.txt"), "user edit\n");
await runMergeAdvanceAutoSync({
store: fx.store,
audit: makeAudit(fx.store, "FN-TEST-2"),
taskId: "FN-TEST-2",
projectRootDir: fx.projectRoot,
integrationBranch: "main",
previousSha: fx.previousSha,
newSha: fx.newSha,
mode: "ff-only",
});
const autoSync = fx.recorded.filter((e) => e.mutationType === "merge:auto-sync");
expect(autoSync).toHaveLength(1);
expect(autoSync[0].metadata).toMatchObject({ outcome: "skipped-dirty" });
// worktree still pinned at previousSha — `base.txt` has the original v1.
expect(readFileSync(join(fx.projectRoot, "base.txt"), "utf-8")).toBe("v1\n");
// The untracked local file survives untouched.
expect(readFileSync(join(fx.projectRoot, "local.txt"), "utf-8")).toBe("user edit\n");
});
it("stash-and-ff + real edits on a non-conflicting file: edits restored on top of newSha", async () => {
// The user added a brand-new untracked file that doesn't conflict with
// the task's changes. After auto-sync the worktree should be at newSha
// AND the local file should still be present.
writeFileSync(join(fx.projectRoot, "local.txt"), "user edit\n");
await runMergeAdvanceAutoSync({
store: fx.store,
audit: makeAudit(fx.store, "FN-TEST-3"),
taskId: "FN-TEST-3",
projectRootDir: fx.projectRoot,
integrationBranch: "main",
previousSha: fx.previousSha,
newSha: fx.newSha,
mode: "stash-and-ff",
});
const autoSync = fx.recorded.filter((e) => e.mutationType === "merge:auto-sync");
expect(autoSync).toHaveLength(1);
expect(autoSync[0].metadata).toMatchObject({ outcome: "synced-with-edits-restored" });
// Task's content landed.
expect(readFileSync(join(fx.projectRoot, "base.txt"), "utf-8")).toBe("v2 from task\n");
expect(readFileSync(join(fx.projectRoot, "feature.txt"), "utf-8")).toBe("task work\n");
// Local untracked edit survived.
expect(readFileSync(join(fx.projectRoot, "local.txt"), "utf-8")).toBe("user edit\n");
});
it("emits structured merge:auto-sync per worktree and skips task worktrees on a different branch", async () => {
await runMergeAdvanceAutoSync({
store: fx.store,
audit: makeAudit(fx.store, "FN-TEST-4"),
taskId: "FN-TEST-4",
projectRootDir: fx.projectRoot,
integrationBranch: "main",
previousSha: fx.previousSha,
newSha: fx.newSha,
mode: "stash-and-ff",
});
const autoSync = fx.recorded.filter((e) => e.mutationType === "merge:auto-sync");
expect(autoSync).toHaveLength(1);
// Task worktree (on fusion/fn-test) is not in branchMap for `main`, so no
// event mentions it.
for (const event of autoSync) {
expect(event.target).not.toBe(fx.taskWorktree);
}
});
it("no other worktrees on integration branch → no audit emissions", async () => {
await runMergeAdvanceAutoSync({
store: fx.store,
audit: makeAudit(fx.store, "FN-TEST-5"),
taskId: "FN-TEST-5",
projectRootDir: fx.projectRoot,
integrationBranch: "nonexistent-branch",
previousSha: fx.previousSha,
newSha: fx.newSha,
mode: "stash-and-ff",
});
expect(fx.recorded).toHaveLength(0);
});
});