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

@@ -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,
});
}