FN-5839: prevent stale integration-advance sync prompts

Treat rewritten integration-branch advances as handled when the working tree is already aligned.

- Add a new `superseded` resolution for unreachable advance SHAs that remain after history rewrites while HEAD matches the local integration tip.
- Update Git status collection logic and route/API status typings to propagate the new resolution state.
- Update Git Manager modal messaging and dismiss behavior so handled `superseded` entries do not show a sync CTA.
- Extend dashboard route and modal tests, and refresh dashboard guide docs for the new classification behavior.

Files changed:
 docs/dashboard-guide.md                            |  5 +--
 packages/dashboard/app/api/legacy.ts               |  2 +-
 .../dashboard/app/components/GitManagerModal.tsx   |  6 ++--
 .../components/__tests__/GitManagerModal.test.tsx  |  5 ++-
 .../dashboard/src/__tests__/routes-git.test.ts     | 36 ++++++++++++++++++++--
 .../dashboard/src/routes/register-git-github.ts    | 13 ++++++--
 6 files changed, 55 insertions(+), 12 deletions(-)

Fusion-Task-Id: FN-5839

Fusion-Task-Lineage: 6b4afb73-587d-4319-8ad8-f1d5d54bc7bf
This commit is contained in:
gsxdsm
2026-06-01 11:52:02 -07:00
parent 4a20aa140e
commit 3602fb9a23
6 changed files with 55 additions and 12 deletions

View File

@@ -261,12 +261,13 @@ How to react:
- Click **Pull** to run Smart Pull (`POST /api/git/smart-pull`), including the stash-conflict flow in `StashConflictModal` when needed - Click **Pull** to run Smart Pull (`POST /api/git/smart-pull`), including the stash-conflict flow in `StashConflictModal` when needed
- Use the dismiss close button to hide the notice - Use the dismiss close button to hide the notice
- Treat dirty/untracked warnings as a hint that local changes may be auto-stashed during pull - Treat dirty/untracked warnings as a hint that local changes may be auto-stashed during pull
- In Git Manager's "Recent integration-branch advances" panel, entries are classified as `pending`, `reachable`, `subsumed`, or `orphaned`: - In Git Manager's "Recent integration-branch advances" panel, entries are classified as `pending`, `reachable`, `subsumed`, `orphaned`, or `superseded`:
- `pending`: actionable (not reflected in HEAD; Sync can help) - `pending`: actionable (not reflected in HEAD; Sync can help)
- `reachable`: commit already reachable from HEAD - `reachable`: commit already reachable from HEAD
- `subsumed`: equivalent patch content already landed under a different SHA (history rewrite/re-squash) - `subsumed`: equivalent patch content already landed under a different SHA (history rewrite/re-squash)
- `orphaned`: recorded SHA no longer exists locally after history rewrite - `orphaned`: recorded SHA no longer exists locally after history rewrite
- **Sync working tree** is shown only when there is at least one `pending` entry and HEAD is not already aligned with the integration tip; handled entries can be dismissed from the panel. - `superseded`: recorded SHA still exists but is unreachable, and HEAD is already aligned with the local integration tip after a history rewrite (handled; no sync action applies)
- **Sync working tree** is shown only when there is at least one `pending` entry and HEAD is not already aligned with the integration tip; handled entries (`reachable`/`subsumed`/`orphaned`/`superseded`) can be dismissed from the panel.
Push follow-up (when shown): Push follow-up (when shown):
- If the integration branch is ahead of `origin`, the banner can show push controls with ahead count - If the integration branch is ahead of `origin`, the banner can show push controls with ahead count

View File

@@ -2708,7 +2708,7 @@ export interface GitStatus {
advancedAt: string; advancedAt: string;
autoSyncOutcome?: string; autoSyncOutcome?: string;
needsAction: boolean; needsAction: boolean;
resolution: "reachable" | "orphaned" | "subsumed" | "pending"; resolution: "reachable" | "orphaned" | "subsumed" | "superseded" | "pending";
}>; }>;
} }

View File

@@ -1371,12 +1371,12 @@ function StatusPanel({
</p> </p>
<ul className="gm-status-advances-help-list"> <ul className="gm-status-advances-help-list">
<li><code>clean-sync</code> / <code>synced-with-edits-restored</code> — working tree is in sync; nothing to do.</li> <li><code>clean-sync</code> / <code>synced-with-edits-restored</code> — working tree is in sync; nothing to do.</li>
<li><code>reachable</code> / <code>subsumed</code> / <code>orphaned</code> — already handled (including history rewrites where equivalent content already landed or original SHAs disappeared).</li> <li><code>reachable</code> / <code>subsumed</code> / <code>orphaned</code> / <code>superseded</code> — already handled (including history rewrites where equivalent content already landed, original SHAs disappeared, or HEAD is already aligned to the rewritten integration tip).</li>
<li><code>pending</code> + <code>off / not run</code> — auto-sync is disabled in Settings; the branch ref moved but your worktree didn&apos;t follow.</li> <li><code>pending</code> + <code>off / not run</code> — auto-sync is disabled in Settings; the branch ref moved but your worktree didn&apos;t follow.</li>
<li><code>pending</code> + <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> <li><code>pending</code> + <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> </ul>
<p> <p>
<strong>Fix:</strong> Fusion only shows <em>Sync working tree</em> when at least one advance is genuinely <code>pending</code> and HEAD is not aligned with the integration tip. If entries are already handled (subsumed/orphaned/reachable), no sync action is offered. <strong>Fix:</strong> Fusion only shows <em>Sync working tree</em> when at least one advance is genuinely <code>pending</code> and HEAD is not aligned with the integration tip. If entries are already handled (subsumed/orphaned/reachable/superseded), no sync action is offered.
</p> </p>
</div> </div>
)} )}
@@ -1398,7 +1398,7 @@ function StatusPanel({
<span className="gm-status-sub"> <span className="gm-status-sub">
{" "}· {new Date(advance.advancedAt).toLocaleTimeString()} · {advance.resolution} {" "}· {new Date(advance.advancedAt).toLocaleTimeString()} · {advance.resolution}
</span> </span>
{(advance.resolution === "orphaned" || advance.resolution === "subsumed") && ( {(advance.resolution === "orphaned" || advance.resolution === "subsumed" || advance.resolution === "superseded") && (
<button <button
type="button" type="button"
className="btn btn-xs" className="btn btn-xs"

View File

@@ -3041,6 +3041,7 @@ describe("GitManagerModal", () => {
describe("recent merge advances panel", () => { describe("recent merge advances panel", () => {
it("hides sync CTA when advances are handled and head is aligned", async () => { it("hides sync CTA when advances are handled and head is aligned", async () => {
const supersededSha = "e".repeat(40);
(fetchGitStatus as any).mockResolvedValue({ (fetchGitStatus as any).mockResolvedValue({
branch: "main", branch: "main",
commit: "abc1234", commit: "abc1234",
@@ -3053,12 +3054,14 @@ describe("GitManagerModal", () => {
recentMergeAdvances: [ recentMergeAdvances: [
{ taskId: "FN-1", fromSha: null, toSha: "a".repeat(40), advancedAt: new Date().toISOString(), needsAction: false, resolution: "orphaned" }, { taskId: "FN-1", fromSha: null, toSha: "a".repeat(40), advancedAt: new Date().toISOString(), needsAction: false, resolution: "orphaned" },
{ taskId: "FN-2", fromSha: null, toSha: "b".repeat(40), advancedAt: new Date().toISOString(), needsAction: false, resolution: "subsumed" }, { taskId: "FN-2", fromSha: null, toSha: "b".repeat(40), advancedAt: new Date().toISOString(), needsAction: false, resolution: "subsumed" },
{ taskId: "FN-3", fromSha: null, toSha: supersededSha, advancedAt: new Date().toISOString(), needsAction: false, resolution: "superseded" },
], ],
}); });
render(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />); render(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />);
await waitFor(() => expect(screen.getByTestId("recent-merge-advances")).toBeInTheDocument()); await waitFor(() => expect(screen.getByTestId("recent-merge-advances")).toBeInTheDocument());
expect(screen.getByText(/\(0 need action\)/i)).toBeInTheDocument(); expect(screen.getByText(/\(0 need action\)/i)).toBeInTheDocument();
expect(screen.queryByTestId("sync-working-tree-btn")).not.toBeInTheDocument(); expect(screen.queryByTestId("sync-working-tree-btn")).not.toBeInTheDocument();
expect(screen.getByTestId(`dismiss-advance-${supersededSha}`)).toBeInTheDocument();
}); });
it("shows sync CTA when pending advance exists", async () => { it("shows sync CTA when pending advance exists", async () => {
@@ -3080,7 +3083,7 @@ describe("GitManagerModal", () => {
await userEvent.click(screen.getByTestId("sync-working-tree-btn")); await userEvent.click(screen.getByTestId("sync-working-tree-btn"));
}); });
it("dismisses orphaned/subsumed entries", async () => { it("dismisses orphaned/subsumed/superseded entries", async () => {
const toSha = "d".repeat(40); const toSha = "d".repeat(40);
(fetchGitStatus as any).mockResolvedValue({ (fetchGitStatus as any).mockResolvedValue({
branch: "main", branch: "main",

View File

@@ -1733,8 +1733,8 @@ describe("Workspace File Routes", () => {
return git(repoDir, ["rev-parse", "HEAD"]); return git(repoDir, ["rev-parse", "HEAD"]);
} }
async function runWithAdvance(repoDir: string, toSha: string) { async function runWithAdvance(repoDir: string, toSha: string, options?: { headSha?: string; localIntegrationTipSha?: string }) {
const headSha = git(repoDir, ["rev-parse", "HEAD"]); const headSha = options?.headSha ?? git(repoDir, ["rev-parse", "HEAD"]);
const fakeStore = { const fakeStore = {
getRunAuditEvents: ({ mutationType }: { mutationType?: string }) => { getRunAuditEvents: ({ mutationType }: { mutationType?: string }) => {
if (mutationType === "merge:integration-ref-advance") { if (mutationType === "merge:integration-ref-advance") {
@@ -1743,7 +1743,7 @@ describe("Workspace File Routes", () => {
return []; return [];
}, },
} as unknown as TaskStore; } as unknown as TaskStore;
return collectRecentMergeAdvances(fakeStore, repoDir, headSha); return collectRecentMergeAdvances(fakeStore, repoDir, headSha, options?.localIntegrationTipSha);
} }
it("marks orphaned SHAs as handled", async () => { it("marks orphaned SHAs as handled", async () => {
@@ -1806,5 +1806,35 @@ describe("Workspace File Routes", () => {
rmSync(repoDir, { recursive: true, force: true }); rmSync(repoDir, { recursive: true, force: true });
} }
}); });
it("marks unreachable existing SHA as superseded when head equals integration tip", async () => {
const repoDir = initRepo();
try {
const baseSha = commitFile(repoDir, "a.txt", "base\n", "base");
const toSha = commitFile(repoDir, "a.txt", "base\nadvance\n", "advance");
execFileSync("git", ["-C", repoDir, "reset", "--hard", baseSha], { stdio: "pipe" });
const rewrittenHead = commitFile(repoDir, "a.txt", "base\nrewritten\n", "rewritten");
const result = await runWithAdvance(repoDir, toSha, { localIntegrationTipSha: rewrittenHead });
expect(result?.[0]?.resolution).toBe("superseded");
expect(result?.[0]?.needsAction).toBe(false);
} finally {
rmSync(repoDir, { recursive: true, force: true });
}
});
it("keeps unreachable existing SHA pending when head is not aligned", async () => {
const repoDir = initRepo();
try {
const baseSha = commitFile(repoDir, "a.txt", "base\n", "base");
const toSha = commitFile(repoDir, "a.txt", "base\nadvance\n", "advance");
execFileSync("git", ["-C", repoDir, "reset", "--hard", baseSha], { stdio: "pipe" });
const rewrittenHead = commitFile(repoDir, "a.txt", "base\nrewritten\n", "rewritten");
const result = await runWithAdvance(repoDir, toSha, { localIntegrationTipSha: `${rewrittenHead.slice(0, 39)}0` });
expect(result?.[0]?.resolution).toBe("pending");
expect(result?.[0]?.needsAction).toBe(true);
} finally {
rmSync(repoDir, { recursive: true, force: true });
}
});
}); });
}); });

View File

@@ -443,7 +443,7 @@ export interface ExtendedGitStatus {
advancedAt: string; advancedAt: string;
autoSyncOutcome?: string; autoSyncOutcome?: string;
needsAction: boolean; needsAction: boolean;
resolution: "reachable" | "orphaned" | "subsumed" | "pending"; resolution: "reachable" | "orphaned" | "subsumed" | "superseded" | "pending";
}>; }>;
} }
@@ -604,6 +604,7 @@ export async function collectRecentMergeAdvances(
}, },
worktreePath: string, worktreePath: string,
headSha: string | undefined, headSha: string | undefined,
localIntegrationTipSha: string | undefined,
): Promise<ExtendedGitStatus["recentMergeAdvances"]> { ): Promise<ExtendedGitStatus["recentMergeAdvances"]> {
if (typeof scopedStore.getRunAuditEvents !== "function") return []; if (typeof scopedStore.getRunAuditEvents !== "function") return [];
const advances = scopedStore.getRunAuditEvents({ const advances = scopedStore.getRunAuditEvents({
@@ -648,7 +649,7 @@ export async function collectRecentMergeAdvances(
if (!tid) continue; if (!tid) continue;
const autoSyncOutcome = autoSyncByAdvance.get(pairKey(tid, md.toSha)) ?? autoSyncByTaskFallback.get(tid); const autoSyncOutcome = autoSyncByAdvance.get(pairKey(tid, md.toSha)) ?? autoSyncByTaskFallback.get(tid);
let resolution: "reachable" | "orphaned" | "subsumed" | "pending" = "pending"; let resolution: "reachable" | "orphaned" | "subsumed" | "superseded" | "pending" = "pending";
let toShaExists = true; let toShaExists = true;
if (headSha && headSha === md.toSha) { if (headSha && headSha === md.toSha) {
resolution = "reachable"; resolution = "reachable";
@@ -690,6 +691,13 @@ export async function collectRecentMergeAdvances(
} }
} }
} }
// When HEAD is already aligned with the local integration tip, resetting
// to that tip cannot make an unreachable advance SHA become reachable.
// Treat this as handled (superseded by rewrite), not actionable pending.
if (toShaExists && resolution === "pending" && localIntegrationTipSha && headSha === localIntegrationTipSha) {
resolution = "superseded";
}
} }
const needsAction = resolution === "pending" const needsAction = resolution === "pending"
@@ -786,6 +794,7 @@ export async function computeExtendedGitStatus(rootDir: string, scopedStore: Tas
}, },
rootDir, rootDir,
headSha, headSha,
localIntegrationTip ?? undefined,
), ),
]); ]);