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
|
- 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
|
||||||
|
|||||||
@@ -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";
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'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'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>
|
<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>
|
</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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user