fix(engine,dashboard): close 7 review findings on merger auto-sync
Data-loss fixes in syncWorktreeToHead:
- Untracked-restore checks `git ls-tree -r --name-only HEAD` to skip
paths the new tip added as tracked files; user bytes stay in the
stage dir instead of clobbering merged content.
- Apply-failure on a deleted/renamed file: conflictedFiles falls back
to parsing `diff --git a/<p> b/<p>` headers when --diff-filter=U
returns nothing.
- All git invocations pass `-c core.quotePath=false` so non-ASCII
paths round-trip through copyFileSync.
- Stash-and-ff re-verifies rev-parse HEAD === newSha right before
each `reset --hard HEAD` (TOCTOU). On mismatch we bail with patch
preserved on disk.
- Stage dir lifecycle moved into try/finally with preserveStageDir
flag — kept whenever the user's edits live only in patchPath; rm'd
on all clean exits.
- Patch written to disk before the apply attempt, not only on
failure, so a crash between snapshot and apply doesn't lose edits.
Multi-worktree-same-branch fix:
- New getRegisteredWorktreeBranches returns Array<{branch,path}>
instead of collapsing into a Map. Multiple worktrees can share a
branch via `git worktree add --force -b`; merger now syncs all of
them rather than silently skipping all but the last.
Contract + surfacing fixes:
- JSDoc on merge:auto-sync GitMutationType now lists the actually-
emitted outcome strings + stage enum.
- GET /api/tasks/merge-advance-events joins merge:auto-sync events
within ±5min of the advance and returns them in a new
`autoSync: AutoSyncOutcome[]` field; useMergeAdvanceNotice exposes
the same shape so the banner can surface pop-conflicts (including
patchPath) instead of dropping them.
Hygiene:
- Merger now reads the setting via normalizeMergeAdvanceAutoSyncMode
instead of an inline check + `as unknown` cast.
New tests:
- Untracked-collides-with-tracked preserves merged content.
- Apply failure on deleted file populates conflictedFiles from
patch header.
- Route surfaces autoSync outcomes (clean-sync + pop-conflict)
joined within the time window.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -181,6 +181,101 @@ describe("runMergeAdvanceAutoSync (post-local-ref-advance reconciliation)", () =
|
||||
}
|
||||
});
|
||||
|
||||
it("untracked file colliding with a newly-tracked path is NOT overwritten — merged content is preserved", async () => {
|
||||
// User has an untracked `feature.txt` (locally meaningful) BEFORE the
|
||||
// task merge. The task's commit adds `feature.txt` as a tracked file with
|
||||
// different content. Without the collision guard, the auto-sync would
|
||||
// clobber the merged version with the user's stale untracked bytes.
|
||||
writeFileSync(join(fx.projectRoot, "feature.txt"), "USER stale local\n");
|
||||
|
||||
await runMergeAdvanceAutoSync({
|
||||
store: fx.store,
|
||||
audit: makeAudit(fx.store, "FN-COLLIDE"),
|
||||
taskId: "FN-COLLIDE",
|
||||
projectRootDir: fx.projectRoot,
|
||||
integrationBranch: "main",
|
||||
previousSha: fx.previousSha,
|
||||
newSha: fx.newSha,
|
||||
mode: "stash-and-ff",
|
||||
});
|
||||
|
||||
// The merged version (from the task commit) must win.
|
||||
expect(readFileSync(join(fx.projectRoot, "feature.txt"), "utf-8")).toBe("task work\n");
|
||||
|
||||
const autoSync = fx.recorded.filter((e) => e.mutationType === "merge:auto-sync");
|
||||
expect(autoSync).toHaveLength(1);
|
||||
// Auto-sync surfaces the collision as synced-with-pop-conflict so the
|
||||
// dashboard's existing conflict UI hooks fire.
|
||||
expect(autoSync[0].metadata).toMatchObject({
|
||||
outcome: "synced-with-pop-conflict",
|
||||
untrackedSkippedAsTracked: ["feature.txt"],
|
||||
});
|
||||
});
|
||||
|
||||
it("apply --3way failure on a deleted/renamed file: conflictedFiles populated from patch header, not left empty", async () => {
|
||||
// Set up a scenario where the user edits a tracked file that the merge
|
||||
// deletes. `git apply --3way` fails without staging unmerged entries, so
|
||||
// --diff-filter=U would otherwise return []. The patch-header fallback
|
||||
// must surface the affected path.
|
||||
//
|
||||
// Build fresh fixture: project has `doomed.txt` at previousSha, user
|
||||
// edits it, task commit deletes it.
|
||||
rmSync(fx.root, { recursive: true, force: true });
|
||||
const root = mkdtempSync(join(testTempParent(), "merger-auto-sync-delete-"));
|
||||
const upstream = join(root, "upstream.git");
|
||||
const projectRoot = join(root, "project");
|
||||
const taskWorktree = join(root, "task");
|
||||
git(root, `git init --bare -b main "${upstream}"`);
|
||||
git(root, `git clone "${upstream}" "${projectRoot}"`);
|
||||
git(projectRoot, 'git config user.email "u@e.com"');
|
||||
git(projectRoot, 'git config user.name "U"');
|
||||
writeFileSync(join(projectRoot, "doomed.txt"), "v1\n");
|
||||
git(projectRoot, "git add doomed.txt");
|
||||
git(projectRoot, 'git commit -m "init"');
|
||||
git(projectRoot, "git push -u origin main");
|
||||
const previousSha = git(projectRoot, "git rev-parse HEAD");
|
||||
|
||||
git(projectRoot, `git worktree add -b fusion/fn-test "${taskWorktree}"`);
|
||||
// Task commit deletes doomed.txt
|
||||
execSync(`rm "${join(taskWorktree, "doomed.txt")}"`);
|
||||
git(taskWorktree, "git add -A");
|
||||
git(taskWorktree, 'git commit -m "delete doomed"');
|
||||
const newSha = git(taskWorktree, "git rev-parse HEAD");
|
||||
git(projectRoot, `git update-ref refs/heads/main ${newSha}`);
|
||||
|
||||
// User modified doomed.txt before merge
|
||||
writeFileSync(join(projectRoot, "doomed.txt"), "v1\nuser edit\n");
|
||||
|
||||
const recorded: RunAuditEventInput[] = [];
|
||||
const store = {
|
||||
recordRunAuditEvent: vi.fn(async (input: RunAuditEventInput) => { recorded.push(input); }),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
try {
|
||||
await runMergeAdvanceAutoSync({
|
||||
store,
|
||||
audit: makeAudit(store, "FN-DELETED"),
|
||||
taskId: "FN-DELETED",
|
||||
projectRootDir: projectRoot,
|
||||
integrationBranch: "main",
|
||||
previousSha,
|
||||
newSha,
|
||||
mode: "stash-and-ff",
|
||||
});
|
||||
|
||||
const autoSync = recorded.filter((e) => e.mutationType === "merge:auto-sync");
|
||||
expect(autoSync).toHaveLength(1);
|
||||
expect(autoSync[0].metadata).toMatchObject({ outcome: "synced-with-pop-conflict" });
|
||||
const metadata = autoSync[0].metadata as { conflictedFiles?: string[]; patchPath?: string };
|
||||
// Patch-header fallback must surface doomed.txt even though git apply
|
||||
// failed before staging any unmerged index entries.
|
||||
expect(metadata.conflictedFiles).toContain("doomed.txt");
|
||||
expect(typeof metadata.patchPath).toBe("string");
|
||||
} finally {
|
||||
try { rmSync(root, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
});
|
||||
|
||||
it("no other worktrees on integration branch → no audit emissions", async () => {
|
||||
await runMergeAdvanceAutoSync({
|
||||
store: fx.store,
|
||||
|
||||
Reference in New Issue
Block a user