Files
fusion/packages/engine/src/worktree-ref-sync.ts
gsxdsm dc944949b1 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>
2026-05-23 15:13:17 -07:00

363 lines
15 KiB
TypeScript

import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
const execFileAsync = promisify(execFile);
export type SyncMode = "ff-only" | "stash-and-ff";
export interface WorktreeSyncAuditEvent {
mutationType: "pull:fast-forward" | "stash:push" | "stash:pop" | "stash:pop-conflict";
metadata: Record<string, unknown>;
}
export type WorktreeSyncAuditEmitter = (event: WorktreeSyncAuditEvent) => void | Promise<void>;
export interface SyncWorktreeInput {
worktreePath: string;
integrationBranch: string;
previousSha: string;
newSha: string;
mode: SyncMode;
taskId?: string;
emit?: WorktreeSyncAuditEmitter;
}
export type SyncWorktreeResult =
| { kind: "clean-sync"; fromSha: string; toSha: 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", ["-c", "core.quotePath=false", ...args], {
cwd,
timeout: timeoutMs,
maxBuffer: 64 * 1024 * 1024,
encoding: "utf-8",
});
if (typeof result === "string") return { stdout: result, stderr: "" };
if (result && typeof result === "object") {
return {
stdout: String((result as { stdout?: unknown }).stdout ?? ""),
stderr: String((result as { stderr?: unknown }).stderr ?? ""),
};
}
return { stdout: "", stderr: "" };
}
function commandError(err: unknown): string {
if (err instanceof Error) {
const anyErr = err as Error & { stdout?: string; stderr?: string };
return [anyErr.stderr, anyErr.stdout, anyErr.message].filter(Boolean).join("\n").trim() || anyErr.message;
}
return String(err);
}
async function listFiles(cwd: string, args: string[]): Promise<string[]> {
try {
const { stdout } = await runGit(args, cwd, 10_000);
return stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
} catch {
return [];
}
}
/**
* 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
* `git update-ref`). This is NOT a `git pull` — origin may still be at the old
* tip, so a pull would be a no-op and would leave the worktree pinned to the
* stale state.
*
* Strategy:
* 1. Compare worktree contents against `previousSha` to isolate the user's
* *real* edits from the stale-index "phantom diff" against the new HEAD.
* 2. If no real edits and no untracked files exist, `git reset --hard HEAD`
* cleanly snaps both the index and the working tree forward to `newSha`.
* 3. With real edits in `stash-and-ff` mode, capture them as a binary patch
* 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. 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
* banner so the user can handle the worktree by hand.
*/
export async function syncWorktreeToHead(input: SyncWorktreeInput): Promise<SyncWorktreeResult> {
const { worktreePath, integrationBranch, previousSha, newSha, mode, taskId, emit } = input;
const emitSafe = async (event: WorktreeSyncAuditEvent): Promise<void> => {
if (!emit) return;
try {
await emit(event);
} catch {
// never let audit emission abort the sync
}
};
// Guards.
const branch = (await runGit(["rev-parse", "--abbrev-ref", "HEAD"], worktreePath, 5_000)).stdout.trim();
if (branch !== integrationBranch) {
return { kind: "skipped-not-on-branch", currentBranch: branch };
}
const headSha = (await runGit(["rev-parse", "HEAD"], worktreePath, 5_000)).stdout.trim();
if (headSha !== newSha) {
return { kind: "skipped-head-not-at-new-sha", currentSha: headSha, expectedNewSha: newSha };
}
// Snapshot real edits against `previousSha`.
let dirtyFiles: string[];
let untrackedFiles: string[];
try {
dirtyFiles = await listFiles(worktreePath, ["diff", "--name-only", previousSha]);
untrackedFiles = await listFiles(worktreePath, ["ls-files", "--others", "--exclude-standard"]);
} catch (err: unknown) {
return { kind: "failed", stage: "snapshot", error: commandError(err) };
}
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) {
return { kind: "failed", stage: "reset", error: commandError(err) };
}
await emitSafe({
mutationType: "pull:fast-forward",
metadata: { taskId, worktreePath, integrationBranch, fromSha: previousSha, toSha: newSha, succeeded: true, kind: "clean-sync" },
});
return { kind: "clean-sync", fromSha: previousSha, toSha: newSha };
}
if (mode === "ff-only") {
return { kind: "skipped-dirty", fromSha: previousSha, reason: "ff-only-mode-requires-clean-tree", dirtyFiles, untrackedFiles };
}
// ── 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. Capture real edits as a binary patch against previousSha.
let patch = "";
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 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);
try {
mkdirSync(dirname(dst), { recursive: true });
copyFileSync(src, dst);
} catch {
// best-effort; missing / unreadable entries skipped
}
}
await emitSafe({
mutationType: "stash:push",
metadata: {
taskId,
worktreePath,
stashedFiles: dirtyFiles,
untrackedCount: untrackedFiles.length,
patchPath,
kind: "patch-snapshot",
},
});
// 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({
mutationType: "pull:fast-forward",
metadata: { taskId, worktreePath, integrationBranch, fromSha: previousSha, toSha: newSha, succeeded: true, kind: "snap-after-snapshot" },
});
// 4. Reapply the captured patch via `git apply --3way`.
let popConflict = false;
let conflictedFiles: string[] = [];
let applyError: string | undefined;
if (patch.length > 0) {
try {
await new Promise<void>((resolve, reject) => {
const child = spawn("git", ["apply", "--3way", "--whitespace=nowarn"], { cwd: worktreePath });
let stderr = "";
child.stderr.on("data", (chunk: Buffer | string) => { stderr += chunk.toString(); });
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) resolve();
else reject(new Error(`git apply exited with code ${code}: ${stderr}`));
});
child.stdin.write(patch);
child.stdin.end();
});
} catch (err: unknown) {
popConflict = true;
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 — 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);
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);
writeFileSync(dst, data);
restored.push(rel);
} catch {
// best-effort
}
}
if (popConflict) {
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 };
}
await emitSafe({
mutationType: "stash:pop",
metadata: { taskId, worktreePath, stashedFiles: dirtyFiles, untrackedRestored: restored, kind: "patch-applied" },
});
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
}
}
}
}