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:
gsxdsm
2026-05-23 15:13:17 -07:00
parent 4c31e885bd
commit dc944949b1
9 changed files with 488 additions and 80 deletions

View File

@@ -0,0 +1,32 @@
---
"@fusion/engine": patch
"@fusion/dashboard": patch
---
fix(engine,dashboard): close 7 code-review findings on the mergeAdvanceAutoSync hook
Tightens the freshly-landed merger auto-sync feature based on a structured code review.
**Data-loss fixes in `syncWorktreeToHead`:**
- Untracked-file restore now compares against `git ls-tree -r --name-only HEAD` to detect when the new tip introduced a tracked file at the same path; collisions are reported in `untrackedSkippedAsTracked` and the user's bytes stay in the stage dir instead of clobbering the merged content.
- When `git apply --3way` fails because a patched file was deleted/renamed at the new tip (`--diff-filter=U` returns nothing because nothing got staged), `conflictedFiles` falls back to parsing `diff --git a/<p> b/<p>` headers out of the captured patch — so the conflict surfaces with the right file names instead of `[]`.
- `git ls-files` / `diff` calls now pass `-c core.quotePath=false` so paths with non-ASCII or special characters round-trip through `copyFileSync` instead of failing on backslash-escaped octal tokens.
- The stash-and-ff path re-verifies `rev-parse HEAD === newSha` immediately before each destructive `reset --hard HEAD`; a concurrent merger advance now bails with `skipped-head-not-at-new-sha` (with the captured patch preserved on disk) instead of applying the patch against the wrong tree.
- The stage dir is now tracked with a `preserveStageDir` flag in a `try/finally`: it is rm'd on all clean paths and on `skipped-head-not-at-new-sha` exits, but preserved whenever the user's edits live only in `patchPath` (pop-conflict, untracked-collides-with-tracked, reset failure, outer exception).
- Patch is written to disk before the apply attempt, not only on failure, so a crash between snapshot and apply doesn't lose the user's edits.
**Multi-worktree-same-branch fix:**
- New `getRegisteredWorktreeBranches` helper in `worktree-pool.ts` returns ALL `(branch, worktreePath)` entries rather than collapsing duplicates into a `Map<branch, path>`. Multiple worktrees can legitimately share a branch when the user created secondary checkouts via `git worktree add --force -b`; the merger now syncs every one of them instead of silently skipping all but the last.
**Contract + surfacing fixes:**
- JSDoc on `merge:auto-sync` GitMutationType now documents the actually-emitted outcome strings (`clean-sync`, `synced-with-edits-restored`, `synced-with-pop-conflict`, `skipped-*`, `failed`, `enumeration-failed`, `exception`) and the actual `stage` enum, replacing the obsolete `smartPull`-shaped strings.
- `GET /api/tasks/merge-advance-events` now joins `merge:auto-sync` events within a ±5min window of each advance and returns them in a new `autoSync: AutoSyncOutcome[]` field; `useMergeAdvanceNotice` exposes the same shape so the banner can surface pop-conflicts (including `patchPath` pointing at the user's saved edits) instead of leaving them in a black hole.
**Hygiene:**
- Merger's setting read now uses `normalizeMergeAdvanceAutoSyncMode(settings.mergeAdvanceAutoSync)` (the exported normalizer) instead of an inline equality check + `as unknown` cast that bypassed type-checking.
**New backstop tests** in `merger-auto-sync.slow.test.ts`:
- Untracked file colliding with a newly-tracked path is NOT overwritten and the merged content survives.
- `git apply --3way` failure on a file deleted at the new tip populates `conflictedFiles` from the patch header.
**Route test** asserts `autoSync` outcomes are joined onto the matching advance event within the time window.

View File

@@ -2,6 +2,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ApiRequestError, api } from "../api";
import { subscribeSse } from "../sse-bus";
export interface AutoSyncOutcome {
worktreePath: string | null;
outcome: string;
mode: string;
stashedFiles?: string[];
untrackedRestored?: string[];
untrackedSkippedAsTracked?: string[];
conflictedFiles?: string[];
patchPath?: string;
stage?: string;
error?: string;
}
interface MergeAdvanceEvent {
taskId: string;
integrationBranch: string;
@@ -16,6 +29,7 @@ interface MergeAdvanceEvent {
dirty: boolean;
untrackedCount: number;
} | null;
autoSync?: AutoSyncOutcome[];
}
interface MergeAdvanceEventsResponse {

View File

@@ -99,11 +99,86 @@ describe("merge advance events route", () => {
dirty: true,
untrackedCount: 2,
},
autoSync: [],
},
],
});
});
it("surfaces merge:auto-sync outcomes (clean-sync + synced-with-pop-conflict) alongside the advance event", async () => {
const advance = makeEvent({
id: "evt-advance",
mutationType: "merge:integration-ref-advance",
timestamp: "2026-05-21T10:00:00.000Z",
metadata: {
integrationBranch: "main",
refName: "refs/heads/main",
toSha: "newSha",
fromSha: "prevSha",
advanceMode: "update-ref",
succeeded: true,
},
});
const clean = makeEvent({
id: "evt-auto-clean",
mutationType: "merge:auto-sync",
timestamp: "2026-05-21T10:00:01.000Z",
metadata: {
worktreePath: "/repo",
mode: "stash-and-ff",
outcome: "clean-sync",
integrationBranch: "main",
},
});
const conflict = makeEvent({
id: "evt-auto-conflict",
mutationType: "merge:auto-sync",
timestamp: "2026-05-21T10:00:02.000Z",
metadata: {
worktreePath: "/secondary",
mode: "stash-and-ff",
outcome: "synced-with-pop-conflict",
integrationBranch: "main",
conflictedFiles: ["packages/foo/old.ts"],
patchPath: "/tmp/fusion-worktree-sync-abc/edits.patch",
untrackedSkippedAsTracked: [],
},
});
// Stale event outside the 5-minute window must be excluded.
const stale = makeEvent({
id: "evt-auto-stale",
mutationType: "merge:auto-sync",
timestamp: "2026-05-20T10:00:00.000Z",
metadata: { worktreePath: "/old", mode: "stash-and-ff", outcome: "clean-sync" },
});
const store: TaskStore = {
getRootDir: vi.fn(() => process.cwd()),
getRunAuditEvents: vi.fn((filters?: { mutationType?: string }) => {
if (filters?.mutationType === "merge:integration-ref-advance") return [advance];
if (filters?.mutationType === "merge:auto-sync") return [clean, conflict, stale];
return [];
}),
} as unknown as TaskStore;
const app = express();
app.use("/api", createApiRoutes(store));
const res = await REQUEST(app, "GET", "/api/tasks/merge-advance-events");
expect(res.status).toBe(200);
const body = res.body as { events: Array<{ autoSync: Array<Record<string, unknown>> }> };
expect(body.events).toHaveLength(1);
expect(body.events[0].autoSync).toHaveLength(2);
expect(body.events[0].autoSync).toEqual(expect.arrayContaining([
expect.objectContaining({ worktreePath: "/repo", outcome: "clean-sync" }),
expect.objectContaining({
worktreePath: "/secondary",
outcome: "synced-with-pop-conflict",
conflictedFiles: ["packages/foo/old.ts"],
patchPath: "/tmp/fusion-worktree-sync-abc/edits.patch",
}),
]));
});
it("maps succeeded false from metadata", async () => {
const advance = makeEvent({
id: "evt-advance-fail",

View File

@@ -50,6 +50,19 @@ const REVIEW_VERDICT_RE = /###\s+Verdict:\s*(APPROVE|REVISE|RETHINK|UNAVAILABLE)
const REVIEW_STEP_RE = /^(plan|code) review Step (\d+): (APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
const DUPLICATE_STOPWORDS = new Set(["a", "an", "the", "and", "or", "of", "to", "for", "in", "is", "on", "with", "fn"]);
interface AutoSyncOutcome {
worktreePath: string | null;
outcome: string;
mode: string;
stashedFiles?: string[];
untrackedRestored?: string[];
untrackedSkippedAsTracked?: string[];
conflictedFiles?: string[];
patchPath?: string;
stage?: string;
error?: string;
}
interface MergeAdvanceEvent {
taskId: string;
integrationBranch: string;
@@ -64,6 +77,12 @@ interface MergeAdvanceEvent {
dirty: boolean;
untrackedCount: number;
} | null;
/** Per-worktree outcomes of the merger's post-advance auto-sync hook. Empty
* array when `mergeAdvanceAutoSync: "off"` or no other worktree was on the
* integration branch. A `synced-with-pop-conflict` entry carries
* `patchPath` pointing at the user's saved edits and `conflictedFiles` /
* `untrackedSkippedAsTracked` for surfacing in the conflict modal. */
autoSync: AutoSyncOutcome[];
}
interface MergeAdvanceEventsResponse {
@@ -249,7 +268,39 @@ function extractUserCheckout(metadata: unknown): MergeAdvanceEvent["userCheckout
};
}
function extractMergeAdvanceEvent(event: RunAuditEvent): Omit<MergeAdvanceEvent, "userCheckout"> | null {
function extractAutoSyncOutcome(event: RunAuditEvent): AutoSyncOutcome | null {
const metadata = event.metadata;
if (!metadata || typeof metadata !== "object") return null;
const candidate = metadata as {
worktreePath?: unknown;
outcome?: unknown;
mode?: unknown;
stashedFiles?: unknown;
untrackedRestored?: unknown;
untrackedSkippedAsTracked?: unknown;
conflictedFiles?: unknown;
patchPath?: unknown;
stage?: unknown;
error?: unknown;
};
if (typeof candidate.outcome !== "string" || candidate.outcome.length === 0) return null;
const stringArray = (v: unknown): string[] | undefined =>
Array.isArray(v) && v.every((x) => typeof x === "string") ? (v as string[]) : undefined;
return {
worktreePath: typeof candidate.worktreePath === "string" ? candidate.worktreePath : null,
outcome: candidate.outcome,
mode: typeof candidate.mode === "string" ? candidate.mode : "stash-and-ff",
stashedFiles: stringArray(candidate.stashedFiles),
untrackedRestored: stringArray(candidate.untrackedRestored),
untrackedSkippedAsTracked: stringArray(candidate.untrackedSkippedAsTracked),
conflictedFiles: stringArray(candidate.conflictedFiles),
patchPath: typeof candidate.patchPath === "string" ? candidate.patchPath : undefined,
stage: typeof candidate.stage === "string" ? candidate.stage : undefined,
error: typeof candidate.error === "string" ? candidate.error : undefined,
};
}
function extractMergeAdvanceEvent(event: RunAuditEvent): Omit<MergeAdvanceEvent, "userCheckout" | "autoSync"> | null {
const metadata = event.metadata;
if (!metadata || typeof metadata !== "object") {
console.warn(`[merge-advance-events] dropping run-audit event ${event.id}: missing metadata`);
@@ -555,9 +606,31 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
userCheckout = extractUserCheckout(matchingState.metadata);
}
// Join in any per-worktree auto-sync outcomes for this task. We keep
// events whose timestamp falls in a small window around the advance
// so a `synced-with-pop-conflict` (carrying patchPath) surfaces to
// the dashboard banner even when the sync ran slightly after the
// advance event was recorded.
const autoSyncEvents = storeWithRunAudit.getRunAuditEvents({
taskId: extracted.taskId,
domain: "git",
mutationType: "merge:auto-sync",
limit,
});
const advanceMs = Date.parse(advanceEvent.timestamp);
const AUTO_SYNC_WINDOW_MS = 5 * 60 * 1000;
const autoSync: AutoSyncOutcome[] = [];
for (const ev of autoSyncEvents) {
const evMs = Date.parse(ev.timestamp);
if (Math.abs(evMs - advanceMs) > AUTO_SYNC_WINDOW_MS) continue;
const outcome = extractAutoSyncOutcome(ev);
if (outcome) autoSync.push(outcome);
}
events.push({
...extracted,
userCheckout,
autoSync,
});
}

View File

@@ -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,

View File

@@ -83,13 +83,14 @@ import {
type TaskSourceIssue,
type Task,
type AutostashOrphanRecord,
normalizeMergeAdvanceAutoSyncMode,
} from "@fusion/core";
import { describeModel, promptWithFallback } from "./pi.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
import { createResolvedAgentSession, extractRuntimeHint, resolveMergerSessionModel } from "./agent-session-helpers.js";
import { createFallbackModelObserver } from "./fallback-model-observer.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import { classifyTaskWorktree, getRegisteredWorktreeBranchMap, RemovalReason, removeWorktree, type WorktreePool } from "./worktree-pool.js";
import { classifyTaskWorktree, getRegisteredWorktreeBranches, RemovalReason, removeWorktree, type WorktreePool } from "./worktree-pool.js";
import { activeSessionRegistry } from "./active-session-registry.js";
import { AgentLogger } from "./agent-logger.js";
import { mergerLog } from "./logger.js";
@@ -158,9 +159,14 @@ async function runMergeAdvanceAutoSync(input: {
mode: "ff-only" | "stash-and-ff";
}): Promise<void> {
const { audit, taskId, projectRootDir, integrationBranch, previousSha, newSha, mode } = input;
let branchMap: Map<string, string>;
// `getRegisteredWorktreeBranches` returns ALL (branch, path) pairs, not a
// Map keyed by branch — multiple worktrees can share a branch when the user
// created secondary checkouts via `git worktree add --force -b`. Collapsing
// to a Map would silently skip all but the last-iterated of those, which is
// exactly the surprise-`git status` bug this hook was meant to fix.
let entries: Array<{ branch: string; worktreePath: string }>;
try {
branchMap = await getRegisteredWorktreeBranchMap(projectRootDir);
entries = await getRegisteredWorktreeBranches(projectRootDir);
} catch (err: unknown) {
await audit.git({
type: "merge:auto-sync",
@@ -177,9 +183,9 @@ async function runMergeAdvanceAutoSync(input: {
}
const matchingWorktrees: string[] = [];
for (const [branch, worktreePath] of branchMap.entries()) {
if (branch === integrationBranch) {
matchingWorktrees.push(worktreePath);
for (const entry of entries) {
if (entry.branch === integrationBranch) {
matchingWorktrees.push(entry.worktreePath);
}
}
@@ -238,10 +244,10 @@ async function runMergeAdvanceAutoSync(input: {
worktreePath,
outcome: result.kind,
...(result.kind === "synced-with-pop-conflict"
? { conflictedFiles: result.conflictedFiles, patchPath: result.patchPath, stashedFiles: result.stashedFiles }
? { conflictedFiles: result.conflictedFiles, patchPath: result.patchPath, stashedFiles: result.stashedFiles, untrackedSkippedAsTracked: result.untrackedSkippedAsTracked }
: {}),
...(result.kind === "synced-with-edits-restored"
? { stashedFiles: result.stashedFiles, untrackedRestored: result.untrackedRestored }
? { stashedFiles: result.stashedFiles, untrackedRestored: result.untrackedRestored, untrackedSkippedAsTracked: result.untrackedSkippedAsTracked }
: {}),
...(result.kind === "failed"
? { stage: result.stage, error: result.error }
@@ -9726,10 +9732,7 @@ export async function aiMergeTask(
// the merge has already landed at this point: failing the merger run
// because a downstream worktree sync threw would leave the project in
// a worse state than just emitting the failure as an audit event.
const autoSyncSetting = (settings as { mergeAdvanceAutoSync?: unknown }).mergeAdvanceAutoSync;
const autoSyncMode = autoSyncSetting === "off" || autoSyncSetting === "ff-only" || autoSyncSetting === "stash-and-ff"
? autoSyncSetting
: "stash-and-ff";
const autoSyncMode = normalizeMergeAdvanceAutoSyncMode(settings.mergeAdvanceAutoSync);
if (autoSyncMode !== "off") {
try {
await runMergeAdvanceAutoSync({

View File

@@ -238,21 +238,29 @@ export type GitMutationType =
* newSha?: string;
* worktreePath?: string;
* outcome:
* | "clean-pull"
* | "stash-pull-pop"
* | "stash-pop-conflict"
* | "skipped-dirty"
* | "skipped-not-on-branch"
* | "failed"
* | "enumeration-failed"
* | "exception";
* stashSha?: string;
* stashLabel?: string;
* conflictedFiles?: string[];
* stage?: "stash" | "pull" | "pop";
* | "clean-sync" // worktree was clean against previousSha; reset --hard HEAD snapped it forward
* | "synced-with-edits-restored" // real edits captured as patch, snapped to HEAD, patch re-applied cleanly
* | "synced-with-pop-conflict" // patch failed to reapply OR untracked file collided with newly-tracked path
* | "skipped-dirty" // ff-only mode + real edits → no-op (banner surfaces for manual handling)
* | "skipped-not-on-branch" // worktree's HEAD is on a different branch than integrationBranch
* | "skipped-head-not-at-new-sha" // concurrent advance moved HEAD past newSha between guard and reset
* | "failed" // git command exited non-zero; see stage + error
* | "enumeration-failed" // `git worktree list --porcelain` failed in the project root
* | "exception"; // syncWorktreeToHead threw outside its own try/catch
* stashedFiles?: string[]; // tracked-file edits captured into patchPath
* patchPath?: string; // /tmp/fusion-worktree-sync-<id>/edits.patch (preserved when outcome surfaces a conflict)
* conflictedFiles?: string[]; // paths git apply --3way couldn't reconcile; falls back to patch-header parsing when the index has no unmerged entries
* untrackedRestored?: string[]; // untracked files copied back into the worktree after the snap
* untrackedSkippedAsTracked?: string[]; // untracked files whose paths collided with newly-tracked files at HEAD; left in the stage dir
* stage?: "snapshot" | "reset" | "apply" | "untracked-restore"; // only on outcome === "failed"
* error?: string;
* }
* ```
*
* Per-step `pull:fast-forward`, `stash:push`, `stash:pop`, and
* `stash:pop-conflict` events that flow through the merger's auditor as
* part of this auto-sync carry `metadata.autoSync = true` so consumers can
* filter them apart from user-triggered git operations.
*/
| "merge:auto-sync"
/**

View File

@@ -128,8 +128,25 @@ export async function getRegisteredWorktreePaths(rootDir: string): Promise<Set<s
}
export async function getRegisteredWorktreeBranchMap(rootDir: string): Promise<Map<string, string>> {
const { rawOutput } = await describeRegisteredWorktrees(rootDir);
const branchMap = new Map<string, string>();
for (const entry of await getRegisteredWorktreeBranches(rootDir)) {
branchMap.set(entry.branch, entry.worktreePath);
}
return branchMap;
}
/**
* Same source as `getRegisteredWorktreeBranchMap` but returns ALL
* (branch, worktreePath) pairs rather than collapsing duplicates by branch.
* Multiple worktrees can legitimately share a branch when the user has
* created secondary checkouts via `git worktree add --force -b <branch>`;
* callers that need to act on every such worktree (e.g. the merger's
* post-advance auto-sync) must use this array form to avoid silently
* skipping all but the last-iterated checkout.
*/
export async function getRegisteredWorktreeBranches(rootDir: string): Promise<Array<{ branch: string; worktreePath: string }>> {
const { rawOutput } = await describeRegisteredWorktrees(rootDir);
const entries: Array<{ branch: string; worktreePath: string }> = [];
let currentWorktree: string | null = null;
for (const line of rawOutput.split("\n")) {
@@ -144,12 +161,12 @@ export async function getRegisteredWorktreeBranchMap(rootDir: string): Promise<M
? branchRef.slice("refs/heads/".length)
: branchRef;
if (branchName) {
branchMap.set(branchName, currentWorktree);
entries.push({ branch: branchName, worktreePath: currentWorktree });
}
}
}
return branchMap;
return entries;
}
export async function isRegisteredGitWorktree(rootDir: string, worktreePath: string): Promise<boolean> {

View File

@@ -1,6 +1,6 @@
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import { mkdtempSync, copyFileSync, mkdirSync, readFileSync, rmSync, existsSync } from "node:fs";
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
@@ -27,15 +27,21 @@ export interface SyncWorktreeInput {
export type SyncWorktreeResult =
| { kind: "clean-sync"; fromSha: string; toSha: string }
| { kind: "synced-with-edits-restored"; fromSha: string; toSha: string; stashedFiles: string[]; untrackedRestored: string[] }
| { kind: "synced-with-pop-conflict"; fromSha: string; toSha: string; stashedFiles: string[]; conflictedFiles: string[]; patchPath: string }
| { kind: "synced-with-edits-restored"; fromSha: string; toSha: string; stashedFiles: string[]; untrackedRestored: string[]; untrackedSkippedAsTracked: string[] }
| { kind: "synced-with-pop-conflict"; fromSha: string; toSha: string; stashedFiles: string[]; conflictedFiles: string[]; patchPath: string; untrackedSkippedAsTracked: string[] }
| { kind: "skipped-dirty"; fromSha: string; reason: "ff-only-mode-requires-clean-tree"; dirtyFiles: string[]; untrackedFiles: string[] }
| { kind: "skipped-not-on-branch"; currentBranch: string }
| { kind: "skipped-head-not-at-new-sha"; currentSha: string; expectedNewSha: string }
| { kind: "failed"; stage: "snapshot" | "reset" | "apply" | "untracked-restore"; error: string };
/**
* Run a git command with `core.quotePath=false` always set so path-listing
* commands (`ls-files`, `diff --name-only`, patch headers from `diff
* --binary`) emit raw UTF-8 paths instead of backslash-escaped octal — required
* for round-tripping non-ASCII filenames through copyFileSync / fs paths.
*/
async function runGit(args: string[], cwd: string, timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
const result = await execFileAsync("git", args, {
const result = await execFileAsync("git", ["-c", "core.quotePath=false", ...args], {
cwd,
timeout: timeoutMs,
maxBuffer: 64 * 1024 * 1024,
@@ -68,6 +74,21 @@ async function listFiles(cwd: string, args: string[]): Promise<string[]> {
}
}
/**
* Parse `diff --git a/<path> b/<path>` headers out of a patch produced with
* `git diff --binary`. Used as a fallback for populating `conflictedFiles`
* when `git apply --3way` fails without staging unmerged index entries — e.g.
* the patch references a file deleted or renamed at the new tip.
*/
function extractFilesFromPatch(patch: string): string[] {
const out = new Set<string>();
for (const line of patch.split("\n")) {
const m = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
if (m) out.add(m[2]);
}
return [...out];
}
/**
* Bring a worktree's index + files forward to its current HEAD after the
* integration-branch ref was advanced *locally* (typically by the merger via
@@ -84,7 +105,14 @@ async function listFiles(cwd: string, args: string[]): Promise<string[]> {
* against `previousSha`, copy untracked files to a temp dir, snap to
* HEAD, then reapply (`git apply --3way`) and restore untracked. Patch
* conflicts surface as `synced-with-pop-conflict` and the patch is left
* on disk for manual recovery.
* on disk for manual recovery. Untracked files whose paths collide with
* newly-tracked files at HEAD are NOT overwritten — they are reported in
* `untrackedSkippedAsTracked` and remain in the temp dir.
*
* The stash-and-ff path re-verifies `rev-parse HEAD === newSha` immediately
* before the destructive `reset --hard HEAD` so that a concurrent merger
* advance (which would move HEAD past `newSha` between snapshot and reset)
* can't trick us into applying the patch against the wrong tree.
*
* In `ff-only` mode any real edits cause the function to bail with
* `skipped-dirty`; the caller is expected to surface the Merge Advance Notice
@@ -108,13 +136,10 @@ export async function syncWorktreeToHead(input: SyncWorktreeInput): Promise<Sync
}
const headSha = (await runGit(["rev-parse", "HEAD"], worktreePath, 5_000)).stdout.trim();
if (headSha !== newSha) {
// The ref already moved past `newSha` (or hasn't reached it). Bail rather
// than risk a partial reconciliation against a moving target.
return { kind: "skipped-head-not-at-new-sha", currentSha: headSha, expectedNewSha: newSha };
}
// Snapshot real edits against `previousSha` (which is the tree the worktree
// *should* currently match if no one touched it after the ref advance).
// Snapshot real edits against `previousSha`.
let dirtyFiles: string[];
let untrackedFiles: string[];
try {
@@ -126,6 +151,12 @@ export async function syncWorktreeToHead(input: SyncWorktreeInput): Promise<Sync
const hasRealEdits = dirtyFiles.length > 0 || untrackedFiles.length > 0;
if (!hasRealEdits) {
// Re-check HEAD right before destructive reset (TOCTOU: another merger
// could have advanced past newSha while we were enumerating).
const headSha2 = (await runGit(["rev-parse", "HEAD"], worktreePath, 5_000)).stdout.trim();
if (headSha2 !== newSha) {
return { kind: "skipped-head-not-at-new-sha", currentSha: headSha2, expectedNewSha: newSha };
}
try {
await runGit(["reset", "--hard", "HEAD"], worktreePath, 30_000);
} catch (err: unknown) {
@@ -142,29 +173,43 @@ export async function syncWorktreeToHead(input: SyncWorktreeInput): Promise<Sync
return { kind: "skipped-dirty", fromSha: previousSha, reason: "ff-only-mode-requires-clean-tree", dirtyFiles, untrackedFiles };
}
// stash-and-ff: snapshot real edits + untracked, snap, restore.
// ── stash-and-ff path ───────────────────────────────────────────────────
const stageDir = mkdtempSync(join(tmpdir(), "fusion-worktree-sync-"));
const patchPath = join(stageDir, "edits.patch");
const untrackedDir = join(stageDir, "untracked");
let preserveStageDir = false;
try {
mkdirSync(untrackedDir, { recursive: true });
// 1. Diff against previousSha (binary, full-file) captures only real edits.
// 1. Capture real edits as a binary patch against previousSha.
let patch = "";
if (dirtyFiles.length > 0) {
const { stdout } = await runGit(["diff", "--binary", "--no-color", previousSha], worktreePath, 60_000);
patch = stdout;
try {
if (dirtyFiles.length > 0) {
const { stdout } = await runGit(["diff", "--binary", "--no-color", previousSha], worktreePath, 60_000);
patch = stdout;
}
} catch (err: unknown) {
return { kind: "failed", stage: "snapshot", error: commandError(err) };
}
// 2. Save untracked files.
// 2. Save untracked files to the stage dir. Persist the patch alongside
// them now (not only on conflict) so a crash between this point and
// `git apply` doesn't lose the user's edits.
if (patch.length > 0) {
try {
writeFileSync(patchPath, patch);
} catch {
// best-effort; patch still lives in memory for the apply attempt
}
}
for (const rel of untrackedFiles) {
const src = join(worktreePath, rel);
const dst = join(untrackedDir, rel);
mkdirSync(dirname(dst), { recursive: true });
try {
mkdirSync(dirname(dst), { recursive: true });
copyFileSync(src, dst);
} catch {
// best-effort; missing entries skipped
// best-effort; missing / unreadable entries skipped
}
}
@@ -180,10 +225,18 @@ export async function syncWorktreeToHead(input: SyncWorktreeInput): Promise<Sync
},
});
// 3. Snap worktree+index to HEAD (NEW).
// 3. Re-check HEAD immediately before destructive reset — a concurrent
// merger could have advanced past newSha while we were snapshotting
// (`git diff --binary` can take seconds on large patches).
const headSha3 = (await runGit(["rev-parse", "HEAD"], worktreePath, 5_000)).stdout.trim();
if (headSha3 !== newSha) {
preserveStageDir = true; // patch + untracked saved; user can recover by hand
return { kind: "skipped-head-not-at-new-sha", currentSha: headSha3, expectedNewSha: newSha };
}
try {
await runGit(["reset", "--hard", "HEAD"], worktreePath, 30_000);
} catch (err: unknown) {
preserveStageDir = true; // patch saved on disk for manual recovery
return { kind: "failed", stage: "reset", error: commandError(err) };
}
await emitSafe({
@@ -191,9 +244,10 @@ export async function syncWorktreeToHead(input: SyncWorktreeInput): Promise<Sync
metadata: { taskId, worktreePath, integrationBranch, fromSha: previousSha, toSha: newSha, succeeded: true, kind: "snap-after-snapshot" },
});
// 4. Reapply patch.
// 4. Reapply the captured patch via `git apply --3way`.
let popConflict = false;
const conflictedFiles: string[] = [];
let conflictedFiles: string[] = [];
let applyError: string | undefined;
if (patch.length > 0) {
try {
await new Promise<void>((resolve, reject) => {
@@ -209,40 +263,40 @@ export async function syncWorktreeToHead(input: SyncWorktreeInput): Promise<Sync
child.stdin.end();
});
} catch (err: unknown) {
// Persist patch for manual recovery and surface a structured conflict.
try {
await import("node:fs").then((fs) => fs.writeFileSync(patchPath, patch));
} catch {
// best-effort
}
popConflict = true;
const conflicts = await listFiles(worktreePath, ["diff", "--name-only", "--diff-filter=U"]);
for (const c of conflicts) conflictedFiles.push(c);
await emitSafe({
mutationType: "stash:pop-conflict",
metadata: {
taskId,
worktreePath,
patchPath,
conflictedFiles,
kind: "patch-apply-conflict",
error: commandError(err),
advice: `Real edits were saved to ${patchPath}. Apply manually with \`git apply --3way ${patchPath}\` after resolving conflicts.`,
},
});
applyError = commandError(err);
// Index-staged unmerged paths take priority; fall back to patch-header
// parsing when git apply failed too early to stage anything (e.g. the
// patch referenced a file deleted or renamed at the new tip).
const stagedConflicts = await listFiles(worktreePath, ["diff", "--name-only", "--diff-filter=U"]);
conflictedFiles = stagedConflicts.length > 0 ? stagedConflicts : extractFilesFromPatch(patch);
}
}
// 5. Restore untracked files.
// 5. Restore untracked files — but NEVER overwrite a path that is now
// tracked at HEAD (the new tip may have added that path as a tracked
// file; clobbering it with the user's stale untracked bytes silently
// erases the merge content). Note: `reset --hard HEAD` does not touch
// untracked files, so `existsSync(dst)` is true for paths that the
// user already had on disk — only the tracked-at-HEAD check
// distinguishes a genuine collision from a survivor.
const trackedAtHead = new Set(await listFiles(worktreePath, ["ls-tree", "-r", "--name-only", "HEAD"]));
const restored: string[] = [];
const untrackedSkippedAsTracked: string[] = [];
for (const rel of untrackedFiles) {
const src = join(untrackedDir, rel);
const dst = join(worktreePath, rel);
if (!existsSync(src)) continue;
if (trackedAtHead.has(rel)) {
// NEW introduced a tracked file at this path. Don't clobber it; user's
// bytes remain in stageDir/untracked/ for manual recovery.
untrackedSkippedAsTracked.push(rel);
continue;
}
const dst = join(worktreePath, rel);
try {
mkdirSync(dirname(dst), { recursive: true });
const data = readFileSync(src);
await import("node:fs").then((fs) => fs.writeFileSync(dst, data));
writeFileSync(dst, data);
restored.push(rel);
} catch {
// best-effort
@@ -250,22 +304,59 @@ export async function syncWorktreeToHead(input: SyncWorktreeInput): Promise<Sync
}
if (popConflict) {
// Keep stageDir so the patch survives for manual recovery.
return { kind: "synced-with-pop-conflict", fromSha: previousSha, toSha: newSha, stashedFiles: dirtyFiles, conflictedFiles, patchPath };
preserveStageDir = true;
await emitSafe({
mutationType: "stash:pop-conflict",
metadata: {
taskId,
worktreePath,
patchPath,
conflictedFiles,
untrackedSkippedAsTracked,
kind: "patch-apply-conflict",
error: applyError,
advice: `Real edits were saved to ${patchPath}. Apply manually with \`git apply --3way ${patchPath}\` after resolving conflicts.`,
},
});
return { kind: "synced-with-pop-conflict", fromSha: previousSha, toSha: newSha, stashedFiles: dirtyFiles, conflictedFiles, patchPath, untrackedSkippedAsTracked };
}
if (untrackedSkippedAsTracked.length > 0) {
// Patch applied cleanly but at least one untracked file collided with a
// newly-tracked path. Preserve the stage dir so the user can recover
// those bytes — and surface a stash:pop-conflict so the dashboard's
// existing conflict UI hooks fire.
preserveStageDir = true;
await emitSafe({
mutationType: "stash:pop-conflict",
metadata: {
taskId,
worktreePath,
patchPath,
conflictedFiles: untrackedSkippedAsTracked,
untrackedSkippedAsTracked,
kind: "untracked-collides-with-tracked",
advice: `Saved local copies of ${untrackedSkippedAsTracked.length} untracked file(s) that collide with newly-tracked paths at ${stageDir}/untracked/. Compare against the worktree before deleting.`,
},
});
return { kind: "synced-with-pop-conflict", fromSha: previousSha, toSha: newSha, stashedFiles: dirtyFiles, conflictedFiles: untrackedSkippedAsTracked, patchPath, untrackedSkippedAsTracked };
}
// Clean: emit stash:pop and clean up the stage dir.
await emitSafe({
mutationType: "stash:pop",
metadata: { taskId, worktreePath, stashedFiles: dirtyFiles, untrackedRestored: restored, kind: "patch-applied" },
});
try {
rmSync(stageDir, { recursive: true, force: true });
} catch {
// best-effort
}
return { kind: "synced-with-edits-restored", fromSha: previousSha, toSha: newSha, stashedFiles: dirtyFiles, untrackedRestored: restored };
return { kind: "synced-with-edits-restored", fromSha: previousSha, toSha: newSha, stashedFiles: dirtyFiles, untrackedRestored: restored, untrackedSkippedAsTracked };
} catch (err: unknown) {
preserveStageDir = true; // patch already on disk; keep it for the user
return { kind: "failed", stage: "apply", error: commandError(err) };
} finally {
if (!preserveStageDir) {
try {
rmSync(stageDir, { recursive: true, force: true });
} catch {
// best-effort
}
}
}
}