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:
@@ -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
|
||||
- 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
|
||||
- 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)
|
||||
- `reachable`: commit already reachable from HEAD
|
||||
- `subsumed`: equivalent patch content already landed under a different SHA (history rewrite/re-squash)
|
||||
- `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):
|
||||
- If the integration branch is ahead of `origin`, the banner can show push controls with ahead count
|
||||
|
||||
@@ -2708,7 +2708,7 @@ export interface GitStatus {
|
||||
advancedAt: string;
|
||||
autoSyncOutcome?: string;
|
||||
needsAction: boolean;
|
||||
resolution: "reachable" | "orphaned" | "subsumed" | "pending";
|
||||
resolution: "reachable" | "orphaned" | "subsumed" | "superseded" | "pending";
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1371,12 +1371,12 @@ function StatusPanel({
|
||||
</p>
|
||||
<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>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't follow.</li>
|
||||
<li><code>pending</code> + <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>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -1398,7 +1398,7 @@ function StatusPanel({
|
||||
<span className="gm-status-sub">
|
||||
{" "}· {new Date(advance.advancedAt).toLocaleTimeString()} · {advance.resolution}
|
||||
</span>
|
||||
{(advance.resolution === "orphaned" || advance.resolution === "subsumed") && (
|
||||
{(advance.resolution === "orphaned" || advance.resolution === "subsumed" || advance.resolution === "superseded") && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs"
|
||||
|
||||
@@ -3041,6 +3041,7 @@ describe("GitManagerModal", () => {
|
||||
|
||||
describe("recent merge advances panel", () => {
|
||||
it("hides sync CTA when advances are handled and head is aligned", async () => {
|
||||
const supersededSha = "e".repeat(40);
|
||||
(fetchGitStatus as any).mockResolvedValue({
|
||||
branch: "main",
|
||||
commit: "abc1234",
|
||||
@@ -3053,12 +3054,14 @@ describe("GitManagerModal", () => {
|
||||
recentMergeAdvances: [
|
||||
{ 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-3", fromSha: null, toSha: supersededSha, advancedAt: new Date().toISOString(), needsAction: false, resolution: "superseded" },
|
||||
],
|
||||
});
|
||||
render(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />);
|
||||
await waitFor(() => expect(screen.getByTestId("recent-merge-advances")).toBeInTheDocument());
|
||||
expect(screen.getByText(/\(0 need action\)/i)).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 () => {
|
||||
@@ -3080,7 +3083,7 @@ describe("GitManagerModal", () => {
|
||||
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);
|
||||
(fetchGitStatus as any).mockResolvedValue({
|
||||
branch: "main",
|
||||
|
||||
@@ -1733,8 +1733,8 @@ describe("Workspace File Routes", () => {
|
||||
return git(repoDir, ["rev-parse", "HEAD"]);
|
||||
}
|
||||
|
||||
async function runWithAdvance(repoDir: string, toSha: string) {
|
||||
const headSha = git(repoDir, ["rev-parse", "HEAD"]);
|
||||
async function runWithAdvance(repoDir: string, toSha: string, options?: { headSha?: string; localIntegrationTipSha?: string }) {
|
||||
const headSha = options?.headSha ?? git(repoDir, ["rev-parse", "HEAD"]);
|
||||
const fakeStore = {
|
||||
getRunAuditEvents: ({ mutationType }: { mutationType?: string }) => {
|
||||
if (mutationType === "merge:integration-ref-advance") {
|
||||
@@ -1743,7 +1743,7 @@ describe("Workspace File Routes", () => {
|
||||
return [];
|
||||
},
|
||||
} as unknown as TaskStore;
|
||||
return collectRecentMergeAdvances(fakeStore, repoDir, headSha);
|
||||
return collectRecentMergeAdvances(fakeStore, repoDir, headSha, options?.localIntegrationTipSha);
|
||||
}
|
||||
|
||||
it("marks orphaned SHAs as handled", async () => {
|
||||
@@ -1806,5 +1806,35 @@ describe("Workspace File Routes", () => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -443,7 +443,7 @@ export interface ExtendedGitStatus {
|
||||
advancedAt: string;
|
||||
autoSyncOutcome?: string;
|
||||
needsAction: boolean;
|
||||
resolution: "reachable" | "orphaned" | "subsumed" | "pending";
|
||||
resolution: "reachable" | "orphaned" | "subsumed" | "superseded" | "pending";
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -604,6 +604,7 @@ export async function collectRecentMergeAdvances(
|
||||
},
|
||||
worktreePath: string,
|
||||
headSha: string | undefined,
|
||||
localIntegrationTipSha: string | undefined,
|
||||
): Promise<ExtendedGitStatus["recentMergeAdvances"]> {
|
||||
if (typeof scopedStore.getRunAuditEvents !== "function") return [];
|
||||
const advances = scopedStore.getRunAuditEvents({
|
||||
@@ -648,7 +649,7 @@ export async function collectRecentMergeAdvances(
|
||||
if (!tid) continue;
|
||||
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;
|
||||
if (headSha && headSha === md.toSha) {
|
||||
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"
|
||||
@@ -786,6 +794,7 @@ export async function computeExtendedGitStatus(rootDir: string, scopedStore: Tas
|
||||
},
|
||||
rootDir,
|
||||
headSha,
|
||||
localIntegrationTip ?? undefined,
|
||||
),
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user