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:
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user