fix(dashboard): remove duplicate integration-advances UI; Sync working tree skips origin

Two cleanups in Git Manager → Status:

- Removed the duplicate "Sync local tip" button (gm-integration-actions)
  and the second "Recent integration advances" list (gm-recent-advances)
  that rendered above the highlighted block. Also dropped the dead
  mergeAdvanceEvents state, fetcher, and SSE subscription that only
  fed the deleted UI.

- Sync working tree is now pure-local. Added skipOriginFetch to
  PullGitBranchOptions.integration (and the matching POST /api/git/pull
  body field). When set, pullGitBranch skips tryFastForwardFromOrigin
  entirely — the sequence is just auto-stash → git reset --hard
  refs/heads/<integration> → restore stash. The Sync button passes
  skipOriginFetch: true because the "N need action" recovery is for
  catching the worktree up to a *local* merger ref-advance; touching
  origin could silently pull in unrelated remote commits.

Help disclosure rewritten to reflect the pure-local behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-23 19:24:35 -07:00
parent 6e7f1e570e
commit 5d35b64bd1
3 changed files with 45 additions and 109 deletions

View File

@@ -0,0 +1,11 @@
---
"@fusion/dashboard": patch
---
fix(dashboard): remove duplicate integration-advances UI; Sync working tree is now pure-local (no origin fetch)
**Removed duplicate UI** — Git Manager → Status had two overlapping sections rendering the same data: a `Sync local tip` button + a `Recent integration advances` list, sitting above the highlighted `Recent integration-branch advances` block (the one with the lost-work warnings). Deleted the duplicate (`gm-integration-actions` + `gm-recent-advances`) along with the dead `mergeAdvanceEvents` state, fetcher, and SSE subscription that only fed it.
**Sync working tree is now pure-local** — for the "N need action" case the merger has already advanced `refs/heads/<integration>` locally and the worktree just needs to follow. Previously the button called the integration-mode pull which ran `tryFastForwardFromOrigin` first, silently pulling in unrelated remote commits. New `skipOriginFetch` option on `PullGitBranchOptions.integration` (and the matching `POST /api/git/pull` body field) skips the origin step entirely. The Sync button passes `skipOriginFetch: true`, so the sequence is: auto-stash → `git reset --hard refs/heads/<integration>` → restore stash. Origin is not touched.
Help disclosure updated to match the new behavior.

View File

@@ -10,7 +10,6 @@ import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useViewportMode } from "../hooks/useViewportMode";
import { subscribeSse } from "../sse-bus";
import type {
GitStatus,
GitCommit,
@@ -96,15 +95,6 @@ import {
type SectionId = "status" | "changes" | "commits" | "branches" | "worktrees" | "stashes" | "remotes";
interface MergeAdvanceEvent {
taskId: string;
integrationBranch: string;
toSha: string;
fromSha: string | null;
advanceMode: "fast-forward" | "non-fast-forward" | "update-ref" | string;
succeeded: boolean;
advancedAt: string;
}
const SECTIONS: { id: SectionId; label: string; icon: React.ComponentType<{ size?: number }> }[] = [
{ id: "status", label: "Status", icon: Radio },
@@ -230,8 +220,6 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
// ── Status state
const [status, setStatus] = useState<GitStatus | null>(null);
// ── Integration advance events state
const [mergeAdvanceEvents, setMergeAdvanceEvents] = useState<MergeAdvanceEvent[]>([]);
const [rootDir, setRootDir] = useState<string | null>(null);
// ── Changes state
@@ -830,39 +818,12 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
}
}, [addToast, projectId]);
// ── Integration advance events ──────────────────────────────────
const fetchMergeAdvanceEvents = useCallback(async () => {
try {
const query = new URLSearchParams({ limit: "5" });
if (projectId) query.set("projectId", projectId);
const response = await api<{ events: MergeAdvanceEvent[] }>(`/tasks/merge-advance-events?${query.toString()}`);
setMergeAdvanceEvents(Array.isArray(response.events) ? response.events.filter((e) => e.succeeded) : []);
} catch {
setMergeAdvanceEvents([]);
}
}, [projectId]);
// Fetch rootDir from config (used as worktreePath for sync button)
// Fetch rootDir from config (used as worktreePath for the per-task sync
// button surfaced from RemotesPanel below).
useEffect(() => {
fetchConfig(projectId).then((cfg) => setRootDir(cfg.rootDir)).catch(() => setRootDir(null));
}, [projectId]);
// Fetch events on open and subscribe to SSE for live updates
useEffect(() => {
if (!isOpen) return;
void fetchMergeAdvanceEvents();
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const unsubscribe = subscribeSse(`/api/events${query}`, {
events: {
"task:merged": () => {
void fetchMergeAdvanceEvents();
},
},
});
return () => unsubscribe();
}, [isOpen, fetchMergeAdvanceEvents, projectId]);
const handleSyncIntegrationTip = useCallback(async () => {
if (!status?.integrationBranch || status.isOnIntegrationBranch === false) return;
const worktreePath = rootDir;
@@ -879,18 +840,22 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
worktreePath,
integrationBranch: status.integrationBranch,
taskId: undefined,
// Pure-local catch-up: the merger advanced refs/heads/<integration>
// locally; the worktree just needs to hard-reset to that ref.
// No reason to fetch/merge from origin here — that would silently
// pull in unrelated remote work the operator didn't ask for.
skipOriginFetch: true,
}),
});
addToast("Synced worktree to integration tip", "success");
addToast("Synced worktree to local integration tip", "success");
const statusData = await fetchGitStatus(projectId, { extended: true });
setStatus(statusData);
await fetchMergeAdvanceEvents();
} catch (err) {
addToast(getErrorMessage(err) || "Sync failed", "error");
} finally {
setRemoteLoading(null);
}
}, [addToast, fetchMergeAdvanceEvents, projectId, rootDir, status?.integrationBranch, status?.isOnIntegrationBranch]);
}, [addToast, projectId, rootDir, status?.integrationBranch, status?.isOnIntegrationBranch]);
// ── Derived state ───────────────────────────────────────────────
@@ -970,16 +935,8 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
<StatusPanel
status={status}
copyToClipboard={copyToClipboard}
onSyncWorkingTree={() => handlePull({ rebase: false })}
syncing={remoteLoading === "pull"}
mergeAdvanceEvents={mergeAdvanceEvents}
onSyncIntegrationTip={handleSyncIntegrationTip}
syncingIntegration={remoteLoading === "sync-integration"}
syncIntegrationDisabled={
!status.integrationBranch ||
status.isOnIntegrationBranch === false ||
remoteLoading !== null
}
onSyncWorkingTree={handleSyncIntegrationTip}
syncing={remoteLoading === "sync-integration"}
/>
)}
@@ -1108,19 +1065,11 @@ function StatusPanel({
copyToClipboard,
onSyncWorkingTree,
syncing,
mergeAdvanceEvents,
onSyncIntegrationTip,
syncingIntegration,
syncIntegrationDisabled,
}: {
status: GitStatus;
copyToClipboard: (text: string, label?: string) => void;
onSyncWorkingTree: () => void;
syncing: boolean;
mergeAdvanceEvents: MergeAdvanceEvent[];
onSyncIntegrationTip: () => void;
syncingIntegration: boolean;
syncIntegrationDisabled: boolean;
}) {
const [advancesHelpOpen, setAdvancesHelpOpen] = useState(false);
return (
@@ -1360,47 +1309,6 @@ function StatusPanel({
)}
</div>
)}
{status.integrationBranch && (
<div className="gm-integration-actions" data-testid="integration-actions">
<button
className="btn btn-sm gm-sync-integration-btn"
onClick={onSyncIntegrationTip}
disabled={syncIntegrationDisabled}
title={
!status.integrationBranch
? "No integration branch configured"
: status.isOnIntegrationBranch === false
? `Not on integration branch (${status.integrationBranch})`
: "Sync working tree to local integration tip"
}
data-testid="sync-integration-tip-btn"
>
{syncingIntegration ? (
<Loader2 size={14} className="spin" />
) : (
<GitMerge size={14} />
)}
Sync local tip
</button>
</div>
)}
{mergeAdvanceEvents.length > 0 && (
<div className="gm-recent-advances" data-testid="recent-advance-events">
<div className="gm-recent-advances-header">Recent integration advances</div>
<ul className="gm-recent-advances-list">
{mergeAdvanceEvents.map((event) => (
<li key={`${event.taskId}-${event.toSha}`} className="gm-recent-advance-item">
<code className="gm-hash">{event.toSha.slice(0, 8)}</code>
{" "}
<span className="gm-recent-advance-task">{event.taskId}</span>
<span className="gm-status-sub">
{" · "}{relativeDate(event.advancedAt)}
</span>
</li>
))}
</ul>
</div>
)}
{status.indexStaleVsHead === true && (
<div className="gm-status-warning" data-testid="index-stale-warning" role="alert">
<AlertCircle size={14} />
@@ -1461,10 +1369,13 @@ function StatusPanel({
<li><code>stash-failed</code> / <code>would-conflict</code> / similar — auto-sync tried but couldn&apos;t reconcile (usually local edits collide with the new commit).</li>
</ul>
<p>
<strong>Fix:</strong> click <em>Sync working tree</em> to pull
now (uncommitted edits are auto-stashed and restored). To make
this automatic going forward, enable{" "}
<code>mergeAdvanceAutoSync</code> in Settings.
<strong>Fix:</strong> click <em>Sync working tree</em> to catch
up now. Pure-local — it auto-stashes any uncommitted edits,
hard-resets the worktree to match the local integration tip
(the sha the merger advanced <code>refs/heads/{status.integrationBranch ?? "main"}</code> to),
and restores your stash. Origin is not touched, so no unrelated
remote work gets pulled in. To make this automatic going
forward, enable <code>mergeAdvanceAutoSync</code> in Settings.
</p>
</div>
)}

View File

@@ -1237,6 +1237,14 @@ export interface PullGitBranchOptions {
store: TaskStore;
settings: Settings;
runId: string;
/**
* When true, skip the `tryFastForwardFromOrigin` step entirely. Use this
* for "the merger advanced local `refs/heads/<branch>` and my worktree is
* stale relative to it" recovery — there's no need to fetch or merge from
* origin, just hard-reset the worktree to the local ref. Avoids silently
* pulling in unrelated remote work the operator didn't ask for.
*/
skipOriginFetch?: boolean;
};
}
@@ -1299,7 +1307,9 @@ export async function pullGitBranch(cwd?: string, options?: PullGitBranchOptions
}
const pullStart = performance.now();
await tryFastForwardFromOrigin(rootDir, taskId, integration.integrationBranch, integration.integrationRemote ?? "origin");
if (!integration.skipOriginFetch) {
await tryFastForwardFromOrigin(rootDir, taskId, integration.integrationBranch, integration.integrationRemote ?? "origin");
}
// Sync working tree + index to the local integration tip. The merger
// advances `refs/heads/<integrationBranch>` via `git update-ref` without
@@ -2966,13 +2976,16 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
throw badRequest("Not a git repository");
}
const requestCache = new Map<string, string[]>();
const { rebase, worktreePath, integrationBranch, taskId } = req.body ?? {};
const { rebase, worktreePath, integrationBranch, taskId, skipOriginFetch } = req.body ?? {};
if (rebase !== undefined && typeof rebase !== "boolean") {
throw badRequest("rebase must be a boolean");
}
if (taskId !== undefined && typeof taskId !== "string") {
throw badRequest("taskId must be a string");
}
if (skipOriginFetch !== undefined && typeof skipOriginFetch !== "boolean") {
throw badRequest("skipOriginFetch must be a boolean");
}
if (worktreePath !== undefined) {
if (rebase === true) {
@@ -2999,6 +3012,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
store: scopedStore,
settings,
runId,
skipOriginFetch: skipOriginFetch === true,
},
});
res.json(result);