Files
fusion/packages/dashboard/app/hooks/useMergeAdvanceNotice.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

277 lines
9.0 KiB
TypeScript

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;
refName: string;
toSha: string;
fromSha: string | null;
advanceMode: "fast-forward" | "non-fast-forward" | "update-ref" | string;
succeeded: boolean;
advancedAt: string;
userCheckout: {
worktreePath: string;
dirty: boolean;
untrackedCount: number;
} | null;
autoSync?: AutoSyncOutcome[];
}
interface MergeAdvanceEventsResponse {
events: MergeAdvanceEvent[];
}
type SmartPullResponse =
| { kind: "pull-clean"; toSha: string }
| { kind: "pull-restored"; toSha: string }
| { kind: "stash-conflict"; toSha: string; stashSha: string; stashLabel: string; conflictedFiles: string[]; autostashOutcome: "conflict-needs-manual" | "failed" };
type PushDisabledReason = "no-remote" | "no-upstream" | "not-ahead" | "merge-locked" | "not-a-git-repo";
export type PushOriginStatus = {
integrationBranch: string;
branchSource: "settings" | "origin-head" | "fallback";
hasOriginRemote: boolean;
hasUpstream: boolean;
localSha: string | null;
remoteSha: string | null;
aheadCount: number;
behindCount: number;
mergeActive: boolean;
canPush: boolean;
disabledReason?: PushDisabledReason;
};
type PushResponse = {
ok: boolean;
outcome: "ok" | "rejected-non-ff" | "rejected-other" | "no-upstream" | "no-remote" | "merge-locked" | "not-ahead" | "sha-mismatch" | "failed";
integrationBranch: string;
aheadCount: number;
localSha: string | null;
remoteSha: string | null;
forceWithLease: boolean;
stderrPreview?: string;
message?: string;
};
export type PushState = "idle" | "pending" | "ok" | {
error: string;
outcome: PushDisabledReason | "rejected-non-ff" | "rejected-other" | "sha-mismatch" | "failed";
stderr?: string;
};
function dismissedStorageKey(projectId?: string): string {
return `kb:merge-advance-notice-dismissed:${projectId ?? "default"}`;
}
function forceWithLeaseKey(projectId?: string): string {
return `kb:${projectId ?? "default"}:merge-advance-force-with-lease`;
}
function readDismissedShas(projectId?: string): string[] {
try {
const raw = window.localStorage.getItem(dismissedStorageKey(projectId));
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((value): value is string => typeof value === "string");
} catch {
return [];
}
}
function persistDismissedShas(projectId: string | undefined, values: string[]): void {
try {
window.localStorage.setItem(dismissedStorageKey(projectId), JSON.stringify(values.slice(-50)));
} catch {
// ignore storage failures
}
}
function readForceWithLease(projectId?: string): boolean {
try {
return window.sessionStorage.getItem(forceWithLeaseKey(projectId)) === "1";
} catch {
return false;
}
}
function persistForceWithLease(projectId: string | undefined, enabled: boolean): void {
try {
window.sessionStorage.setItem(forceWithLeaseKey(projectId), enabled ? "1" : "0");
} catch {
// ignore storage failures
}
}
export function useMergeAdvanceNotice({ projectId, apiBase = "/api" }: { projectId?: string; apiBase?: string }) {
const [events, setEvents] = useState<MergeAdvanceEvent[]>([]);
const [dismissedShas, setDismissedShas] = useState<string[]>(() => readDismissedShas(projectId));
const [pullState, setPullState] = useState<"idle" | "pending" | "stashing" | { error: string }>("idle");
const [conflictState, setConflictState] = useState<{ stashSha: string; stashLabel: string; conflictedFiles: string[]; autostashOutcome: "conflict-needs-manual" | "failed" } | null>(null);
const [pushStatus, setPushStatus] = useState<PushOriginStatus | null>(null);
const [pushState, setPushState] = useState<PushState>("idle");
const [forceWithLease, setForceWithLeaseState] = useState<boolean>(() => readForceWithLease(projectId));
const pushOkTimerRef = useRef<number | null>(null);
useEffect(() => {
setDismissedShas(readDismissedShas(projectId));
setForceWithLeaseState(readForceWithLease(projectId));
}, [projectId]);
useEffect(() => () => {
if (pushOkTimerRef.current !== null) {
window.clearTimeout(pushOkTimerRef.current);
}
}, []);
const fetchEvents = useCallback(async () => {
try {
const query = new URLSearchParams({ limit: "5" });
if (projectId) query.set("projectId", projectId);
const response = await api<MergeAdvanceEventsResponse>(`/tasks/merge-advance-events?${query.toString()}`);
setEvents(Array.isArray(response.events) ? response.events : []);
} catch {
setEvents([]);
}
}, [projectId]);
const fetchPushStatus = useCallback(async () => {
if (!projectId) {
setPushStatus(null);
return;
}
try {
const status = await api<PushOriginStatus>(`/projects/${encodeURIComponent(projectId)}/merge-advance/push-status`);
setPushStatus(status);
} catch {
setPushStatus(null);
}
}, [projectId]);
useEffect(() => {
void fetchEvents();
void fetchPushStatus();
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const unsubscribe = subscribeSse(`${apiBase}/events${query}`, {
events: {
"task:merged": () => {
void fetchEvents();
void fetchPushStatus();
},
},
});
return () => unsubscribe();
}, [apiBase, fetchEvents, fetchPushStatus, projectId]);
const notice = useMemo(() => events.find((event) => (
event.succeeded === true
&& event.userCheckout !== null
&& event.userCheckout.worktreePath.trim().length > 0
)), [events]);
const dismiss = useCallback(() => {
if (!notice) return;
const next = [...dismissedShas.filter((sha) => sha !== notice.toSha), notice.toSha].slice(-50);
setDismissedShas(next);
persistDismissedShas(projectId, next);
}, [dismissedShas, notice, projectId]);
const pull = useCallback(async () => {
if (!notice?.userCheckout) return;
setPullState(notice.userCheckout.dirty || notice.userCheckout.untrackedCount > 0 ? "stashing" : "pending");
try {
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const response = await api<SmartPullResponse>(`/git/pull${query}`, {
method: "POST",
body: JSON.stringify({ worktreePath: notice.userCheckout.worktreePath, integrationBranch: notice.integrationBranch, taskId: notice.taskId }),
});
if (response.kind === "stash-conflict") {
setConflictState({
stashSha: response.stashSha,
stashLabel: response.stashLabel,
conflictedFiles: response.conflictedFiles,
autostashOutcome: response.autostashOutcome,
});
} else {
dismiss();
}
setPullState("idle");
await fetchPushStatus();
} catch (error: unknown) {
if (error instanceof ApiRequestError) {
setPullState({ error: error.message || "Pull failed" });
} else if (error instanceof Error && error.message) {
setPullState({ error: error.message });
} else {
setPullState({ error: "Pull failed" });
}
}
}, [dismiss, fetchPushStatus, notice, projectId]);
const setForceWithLease = useCallback((enabled: boolean) => {
setForceWithLeaseState(enabled);
persistForceWithLease(projectId, enabled);
}, [projectId]);
const clearPushError = useCallback(() => {
setPushState("idle");
}, []);
const push = useCallback(async () => {
if (!projectId) return;
setPushState("pending");
try {
const response = await api<PushResponse>(`/projects/${encodeURIComponent(projectId)}/merge-advance/push-origin`, {
method: "POST",
body: JSON.stringify({ forceWithLease, expectedLocalSha: pushStatus?.localSha }),
});
if (response.ok) {
setPushState("ok");
await fetchPushStatus();
if (pushOkTimerRef.current !== null) window.clearTimeout(pushOkTimerRef.current);
pushOkTimerRef.current = window.setTimeout(() => setPushState("idle"), 2000);
return;
}
setPushState({
error: response.message ?? response.outcome,
outcome: response.outcome === "ok" ? "failed" : response.outcome,
stderr: response.stderrPreview,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Push failed";
setPushState({ error: message, outcome: "failed" });
}
}, [fetchPushStatus, forceWithLease, projectId, pushStatus?.localSha]);
return {
notice,
dismiss,
pull,
pullState,
conflictState,
setConflictState,
pushStatus,
pushState,
push,
clearPushError,
forceWithLease,
setForceWithLease,
};
}