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:
11
.changeset/fix-git-manager-dedup-and-local-sync.md
Normal file
11
.changeset/fix-git-manager-dedup-and-local-sync.md
Normal 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.
|
||||||
@@ -10,7 +10,6 @@ import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
|||||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { useViewportMode } from "../hooks/useViewportMode";
|
import { useViewportMode } from "../hooks/useViewportMode";
|
||||||
import { subscribeSse } from "../sse-bus";
|
|
||||||
import type {
|
import type {
|
||||||
GitStatus,
|
GitStatus,
|
||||||
GitCommit,
|
GitCommit,
|
||||||
@@ -96,15 +95,6 @@ import {
|
|||||||
|
|
||||||
type SectionId = "status" | "changes" | "commits" | "branches" | "worktrees" | "stashes" | "remotes";
|
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 }> }[] = [
|
const SECTIONS: { id: SectionId; label: string; icon: React.ComponentType<{ size?: number }> }[] = [
|
||||||
{ id: "status", label: "Status", icon: Radio },
|
{ id: "status", label: "Status", icon: Radio },
|
||||||
@@ -230,8 +220,6 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
|||||||
// ── Status state
|
// ── Status state
|
||||||
const [status, setStatus] = useState<GitStatus | null>(null);
|
const [status, setStatus] = useState<GitStatus | null>(null);
|
||||||
|
|
||||||
// ── Integration advance events state
|
|
||||||
const [mergeAdvanceEvents, setMergeAdvanceEvents] = useState<MergeAdvanceEvent[]>([]);
|
|
||||||
const [rootDir, setRootDir] = useState<string | null>(null);
|
const [rootDir, setRootDir] = useState<string | null>(null);
|
||||||
|
|
||||||
// ── Changes state
|
// ── Changes state
|
||||||
@@ -830,39 +818,12 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
|||||||
}
|
}
|
||||||
}, [addToast, projectId]);
|
}, [addToast, projectId]);
|
||||||
|
|
||||||
// ── Integration advance events ──────────────────────────────────
|
// Fetch rootDir from config (used as worktreePath for the per-task sync
|
||||||
|
// button surfaced from RemotesPanel below).
|
||||||
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)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig(projectId).then((cfg) => setRootDir(cfg.rootDir)).catch(() => setRootDir(null));
|
fetchConfig(projectId).then((cfg) => setRootDir(cfg.rootDir)).catch(() => setRootDir(null));
|
||||||
}, [projectId]);
|
}, [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 () => {
|
const handleSyncIntegrationTip = useCallback(async () => {
|
||||||
if (!status?.integrationBranch || status.isOnIntegrationBranch === false) return;
|
if (!status?.integrationBranch || status.isOnIntegrationBranch === false) return;
|
||||||
const worktreePath = rootDir;
|
const worktreePath = rootDir;
|
||||||
@@ -879,18 +840,22 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
|||||||
worktreePath,
|
worktreePath,
|
||||||
integrationBranch: status.integrationBranch,
|
integrationBranch: status.integrationBranch,
|
||||||
taskId: undefined,
|
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 });
|
const statusData = await fetchGitStatus(projectId, { extended: true });
|
||||||
setStatus(statusData);
|
setStatus(statusData);
|
||||||
await fetchMergeAdvanceEvents();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(getErrorMessage(err) || "Sync failed", "error");
|
addToast(getErrorMessage(err) || "Sync failed", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setRemoteLoading(null);
|
setRemoteLoading(null);
|
||||||
}
|
}
|
||||||
}, [addToast, fetchMergeAdvanceEvents, projectId, rootDir, status?.integrationBranch, status?.isOnIntegrationBranch]);
|
}, [addToast, projectId, rootDir, status?.integrationBranch, status?.isOnIntegrationBranch]);
|
||||||
|
|
||||||
// ── Derived state ───────────────────────────────────────────────
|
// ── Derived state ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -970,16 +935,8 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
|||||||
<StatusPanel
|
<StatusPanel
|
||||||
status={status}
|
status={status}
|
||||||
copyToClipboard={copyToClipboard}
|
copyToClipboard={copyToClipboard}
|
||||||
onSyncWorkingTree={() => handlePull({ rebase: false })}
|
onSyncWorkingTree={handleSyncIntegrationTip}
|
||||||
syncing={remoteLoading === "pull"}
|
syncing={remoteLoading === "sync-integration"}
|
||||||
mergeAdvanceEvents={mergeAdvanceEvents}
|
|
||||||
onSyncIntegrationTip={handleSyncIntegrationTip}
|
|
||||||
syncingIntegration={remoteLoading === "sync-integration"}
|
|
||||||
syncIntegrationDisabled={
|
|
||||||
!status.integrationBranch ||
|
|
||||||
status.isOnIntegrationBranch === false ||
|
|
||||||
remoteLoading !== null
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1108,19 +1065,11 @@ function StatusPanel({
|
|||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
onSyncWorkingTree,
|
onSyncWorkingTree,
|
||||||
syncing,
|
syncing,
|
||||||
mergeAdvanceEvents,
|
|
||||||
onSyncIntegrationTip,
|
|
||||||
syncingIntegration,
|
|
||||||
syncIntegrationDisabled,
|
|
||||||
}: {
|
}: {
|
||||||
status: GitStatus;
|
status: GitStatus;
|
||||||
copyToClipboard: (text: string, label?: string) => void;
|
copyToClipboard: (text: string, label?: string) => void;
|
||||||
onSyncWorkingTree: () => void;
|
onSyncWorkingTree: () => void;
|
||||||
syncing: boolean;
|
syncing: boolean;
|
||||||
mergeAdvanceEvents: MergeAdvanceEvent[];
|
|
||||||
onSyncIntegrationTip: () => void;
|
|
||||||
syncingIntegration: boolean;
|
|
||||||
syncIntegrationDisabled: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const [advancesHelpOpen, setAdvancesHelpOpen] = useState(false);
|
const [advancesHelpOpen, setAdvancesHelpOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
@@ -1360,47 +1309,6 @@ function StatusPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{status.indexStaleVsHead === true && (
|
||||||
<div className="gm-status-warning" data-testid="index-stale-warning" role="alert">
|
<div className="gm-status-warning" data-testid="index-stale-warning" role="alert">
|
||||||
<AlertCircle size={14} />
|
<AlertCircle size={14} />
|
||||||
@@ -1461,10 +1369,13 @@ function StatusPanel({
|
|||||||
<li><code>stash-failed</code> / <code>would-conflict</code> / similar — auto-sync tried but couldn't reconcile (usually local edits collide with the new commit).</li>
|
<li><code>stash-failed</code> / <code>would-conflict</code> / similar — auto-sync tried but couldn't reconcile (usually local edits collide with the new commit).</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
<strong>Fix:</strong> click <em>Sync working tree</em> to pull
|
<strong>Fix:</strong> click <em>Sync working tree</em> to catch
|
||||||
now (uncommitted edits are auto-stashed and restored). To make
|
up now. Pure-local — it auto-stashes any uncommitted edits,
|
||||||
this automatic going forward, enable{" "}
|
hard-resets the worktree to match the local integration tip
|
||||||
<code>mergeAdvanceAutoSync</code> in Settings.
|
(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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1237,6 +1237,14 @@ export interface PullGitBranchOptions {
|
|||||||
store: TaskStore;
|
store: TaskStore;
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
runId: string;
|
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();
|
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
|
// Sync working tree + index to the local integration tip. The merger
|
||||||
// advances `refs/heads/<integrationBranch>` via `git update-ref` without
|
// 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");
|
throw badRequest("Not a git repository");
|
||||||
}
|
}
|
||||||
const requestCache = new Map<string, string[]>();
|
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") {
|
if (rebase !== undefined && typeof rebase !== "boolean") {
|
||||||
throw badRequest("rebase must be a boolean");
|
throw badRequest("rebase must be a boolean");
|
||||||
}
|
}
|
||||||
if (taskId !== undefined && typeof taskId !== "string") {
|
if (taskId !== undefined && typeof taskId !== "string") {
|
||||||
throw badRequest("taskId must be a 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 (worktreePath !== undefined) {
|
||||||
if (rebase === true) {
|
if (rebase === true) {
|
||||||
@@ -2999,6 +3012,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|||||||
store: scopedStore,
|
store: scopedStore,
|
||||||
settings,
|
settings,
|
||||||
runId,
|
runId,
|
||||||
|
skipOriginFetch: skipOriginFetch === true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
res.json(result);
|
res.json(result);
|
||||||
|
|||||||
Reference in New Issue
Block a user