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:
197
packages/engine/src/__tests__/merger-auto-sync.slow.test.ts
Normal file
197
packages/engine/src/__tests__/merger-auto-sync.slow.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user