FN-5838: suppress stale integration-advance action prompts

Refine merge-advance status handling so Git Manager only requests action when the checkout is truly behind.

- classify recent integration advances as reachable, orphaned, subsumed, or pending before marking them actionable
- detect subsumed advances by comparing commit patch fingerprints from recent HEAD history
- expose advance resolution metadata in API status payloads and route exports
- update Git Manager UI to count only pending actionable advances, gate Sync Working Tree visibility, and allow dismissing handled orphaned/subsumed rows
- expand dashboard tests and docs to cover stale false-positive scenarios and new resolution behavior

Files changed:
 docs/dashboard-guide.md                            |  6 ++
 packages/dashboard/app/api/legacy.ts               |  1 +
 .../dashboard/app/components/GitManagerModal.tsx   | 45 ++++++----
 .../components/__tests__/GitManagerModal.test.tsx  | 63 ++++++++++++++
 .../dashboard/src/__tests__/routes-git.test.ts     | 97 +++++++++++++++++++++-
 .../dashboard/src/routes/register-git-github.ts    | 94 ++++++++++++++-------
 6 files changed, 259 insertions(+), 47 deletions(-)

Fusion-Task-Id: FN-5838

Fusion-Task-Lineage: 65d1317a-7618-4fff-9875-c845474888ed
This commit is contained in:
gsxdsm
2026-06-01 11:05:22 -07:00
parent 05c7e448a1
commit 4a20aa140e
6 changed files with 259 additions and 47 deletions

View File

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

View File

@@ -1072,6 +1072,12 @@ function StatusPanel({
syncing: boolean;
}) {
const [advancesHelpOpen, setAdvancesHelpOpen] = useState(false);
const [dismissedAdvanceShas, setDismissedAdvanceShas] = useState<Set<string>>(new Set());
const visibleAdvances = (status.recentMergeAdvances ?? []).filter((advance) => !dismissedAdvanceShas.has(advance.toSha));
const actionableAdvances = visibleAdvances.filter((advance) => advance.resolution === "pending");
const hasActionableAdvances = actionableAdvances.length > 0;
const isHeadAlignedWithIntegration = status.aheadOfIntegration === 0 && status.behindIntegration === 0;
const showSyncWorkingTree = hasActionableAdvances && !isHeadAlignedWithIntegration;
return (
<div className="gm-panel" data-testid="status-panel">
<div className="gm-panel-header">
@@ -1322,13 +1328,13 @@ function StatusPanel({
</div>
</div>
)}
{(status.recentMergeAdvances ?? []).length > 0 && (
{visibleAdvances.length > 0 && (
<div className="gm-status-advances" data-testid="recent-merge-advances">
<div className="gm-status-advances-header">
<span>
Recent integration-branch advances
<span className="gm-status-sub">
{" "}({(status.recentMergeAdvances ?? []).filter((a) => a.needsAction).length} need action)
{" "}({actionableAdvances.length} need action)
</span>
<button
type="button"
@@ -1342,7 +1348,7 @@ function StatusPanel({
<Info size={13} />
</button>
</span>
{(status.recentMergeAdvances ?? []).some((a) => a.needsAction) && (
{showSyncWorkingTree && (
<button
type="button"
className="btn btn-sm"
@@ -1365,22 +1371,17 @@ 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>off / not run</code> — auto-sync is disabled in Settings; the branch ref moved but your worktree didn&apos;t follow.</li>
<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>
<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>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>
</ul>
<p>
<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.
<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.
</p>
</div>
)}
<ul>
{(status.recentMergeAdvances ?? []).map((advance) => (
{visibleAdvances.map((advance) => (
<li key={`${advance.taskId}-${advance.toSha}`} className={advance.needsAction ? "gm-advance-needs-action" : "gm-advance-handled"}>
<code className="gm-hash">{advance.toSha.slice(0, 8)}</code>
{" "}
@@ -1395,8 +1396,24 @@ function StatusPanel({
</span>
)}
<span className="gm-status-sub">
{" "}· {new Date(advance.advancedAt).toLocaleTimeString()}
{" "}· {new Date(advance.advancedAt).toLocaleTimeString()} · {advance.resolution}
</span>
{(advance.resolution === "orphaned" || advance.resolution === "subsumed") && (
<button
type="button"
className="btn btn-xs"
onClick={() => {
setDismissedAdvanceShas((prev) => {
const next = new Set(prev);
next.add(advance.toSha);
return next;
});
}}
data-testid={`dismiss-advance-${advance.toSha}`}
>
Dismiss
</button>
)}
</li>
))}
</ul>

View File

@@ -3039,6 +3039,69 @@ describe("GitManagerModal", () => {
});
describe("recent merge advances panel", () => {
it("hides sync CTA when advances are handled and head is aligned", async () => {
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 0,
behind: 0,
integrationBranch: "main",
aheadOfIntegration: 0,
behindIntegration: 0,
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" },
],
});
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();
});
it("shows sync CTA when pending advance exists", async () => {
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 0,
behind: 0,
integrationBranch: "main",
aheadOfIntegration: 1,
behindIntegration: 0,
recentMergeAdvances: [
{ taskId: "FN-3", fromSha: null, toSha: "c".repeat(40), advancedAt: new Date().toISOString(), needsAction: true, resolution: "pending" },
],
});
render(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />);
await waitFor(() => expect(screen.getByTestId("sync-working-tree-btn")).toBeInTheDocument());
await userEvent.click(screen.getByTestId("sync-working-tree-btn"));
});
it("dismisses orphaned/subsumed entries", async () => {
const toSha = "d".repeat(40);
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 0,
behind: 0,
integrationBranch: "main",
aheadOfIntegration: 0,
behindIntegration: 0,
recentMergeAdvances: [
{ taskId: "FN-4", fromSha: null, toSha, advancedAt: new Date().toISOString(), needsAction: false, resolution: "orphaned" },
],
});
render(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />);
await waitFor(() => expect(screen.getByText("FN-4")).toBeInTheDocument());
await userEvent.click(screen.getByTestId(`dismiss-advance-${toSha}`));
expect(screen.queryByText("FN-4")).not.toBeInTheDocument();
});
});
describe("CSS regression coverage", () => {
it("includes remotes layout selectors and mobile rules", () => {
const css = loadAllAppCss();

View File

@@ -22,7 +22,7 @@ import type { TaskStore, TaskAttachment, Routine, RoutineCreateInput, RoutineUpd
import type { TaskDetail } from "@fusion/core";
import type { AuthStorageLike, ModelRegistryLike } from "../routes.js";
import { __resetBatchImportRateLimiter, __setCreateFnAgentForRefine } from "../routes.js";
import { pullGitBranch } from "../routes/register-git-github.js";
import { collectRecentMergeAdvances, pullGitBranch } from "../routes/register-git-github.js";
import * as agentGenerationModule from "../agent-generation.js";
import { __resetPlanningState, __setCreateFnAgent, planningStreamManager } from "../planning.js";
import * as planningModule from "../planning.js";
@@ -1712,4 +1712,99 @@ describe("Workspace File Routes", () => {
}
});
});
describe("collectRecentMergeAdvances", () => {
function git(cwd: string, args: string[]): string {
return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf-8", stdio: "pipe" }).trim();
}
function initRepo() {
const repoDir = mkdtempSync(join(tmpdir(), "kb-advances-"));
execFileSync("git", ["init", "--initial-branch=main", repoDir], { stdio: "pipe" });
execFileSync("git", ["-C", repoDir, "config", "user.email", "kb-tests@example.com"], { stdio: "pipe" });
execFileSync("git", ["-C", repoDir, "config", "user.name", "KB Tests"], { stdio: "pipe" });
return repoDir;
}
function commitFile(repoDir: string, file: string, content: string, message: string): string {
writeFileSync(join(repoDir, file), content, "utf-8");
execFileSync("git", ["-C", repoDir, "add", file], { stdio: "pipe" });
execFileSync("git", ["-C", repoDir, "commit", "-m", message], { stdio: "pipe" });
return git(repoDir, ["rev-parse", "HEAD"]);
}
async function runWithAdvance(repoDir: string, toSha: string) {
const headSha = git(repoDir, ["rev-parse", "HEAD"]);
const fakeStore = {
getRunAuditEvents: ({ mutationType }: { mutationType?: string }) => {
if (mutationType === "merge:integration-ref-advance") {
return [{ taskId: "FN-123", timestamp: new Date().toISOString(), metadata: { toSha, fromSha: null, succeeded: true } }];
}
return [];
},
} as unknown as TaskStore;
return collectRecentMergeAdvances(fakeStore, repoDir, headSha);
}
it("marks orphaned SHAs as handled", async () => {
const repoDir = initRepo();
try {
const orphanSha = commitFile(repoDir, "a.txt", "one\n", "one");
execFileSync("git", ["-C", repoDir, "checkout", "--orphan", "rewritten"], { stdio: "pipe" });
execFileSync("git", ["-C", repoDir, "rm", "-rf", "."], { stdio: "pipe" });
commitFile(repoDir, "a.txt", "two\n", "two");
execFileSync("git", ["-C", repoDir, "branch", "-M", "main"], { stdio: "pipe" });
execFileSync("git", ["-C", repoDir, "reflog", "expire", "--expire=now", "--all"], { stdio: "pipe" });
execFileSync("git", ["-C", repoDir, "gc", "--prune=now"], { stdio: "pipe" });
const result = await runWithAdvance(repoDir, orphanSha);
expect(result?.[0]?.resolution).toBe("orphaned");
expect(result?.[0]?.needsAction).toBe(false);
} finally {
rmSync(repoDir, { recursive: true, force: true });
}
});
it("marks subsumed equivalent content as handled", async () => {
const repoDir = initRepo();
try {
const baseSha = commitFile(repoDir, "a.txt", "base\n", "base");
const toSha = commitFile(repoDir, "a.txt", "base\nnew\n", "advance");
execFileSync("git", ["-C", repoDir, "reset", "--hard", baseSha], { stdio: "pipe" });
commitFile(repoDir, "a.txt", "base\nnew\n", "equivalent");
const result = await runWithAdvance(repoDir, toSha);
expect(result?.[0]?.resolution).toBe("subsumed");
expect(result?.[0]?.needsAction).toBe(false);
} finally {
rmSync(repoDir, { recursive: true, force: true });
}
});
it("keeps unresolved advances as pending", async () => {
const repoDir = initRepo();
try {
const baseSha = commitFile(repoDir, "a.txt", "base\n", "base");
const toSha = commitFile(repoDir, "a.txt", "base\nnew\n", "advance");
execFileSync("git", ["-C", repoDir, "reset", "--hard", baseSha], { stdio: "pipe" });
commitFile(repoDir, "a.txt", "base\nother\n", "different");
const result = await runWithAdvance(repoDir, toSha);
expect(result?.[0]?.resolution).toBe("pending");
expect(result?.[0]?.needsAction).toBe(true);
} finally {
rmSync(repoDir, { recursive: true, force: true });
}
});
it("marks reachable ancestor as handled", async () => {
const repoDir = initRepo();
try {
const toSha = commitFile(repoDir, "a.txt", "base\n", "base");
commitFile(repoDir, "b.txt", "next\n", "next");
const result = await runWithAdvance(repoDir, toSha);
expect(result?.[0]?.resolution).toBe("reachable");
expect(result?.[0]?.needsAction).toBe(false);
} finally {
rmSync(repoDir, { recursive: true, force: true });
}
});
});
});

View File

@@ -443,6 +443,7 @@ export interface ExtendedGitStatus {
advancedAt: string;
autoSyncOutcome?: string;
needsAction: boolean;
resolution: "reachable" | "orphaned" | "subsumed" | "pending";
}>;
}
@@ -578,7 +579,21 @@ function canonicalForCompare(p: string): string {
}
}
async function collectRecentMergeAdvances(
async function getPatchFingerprint(cwd: string, sha: string): Promise<string | null> {
try {
const out = await runGitCommand(["show", sha, "--pretty=format:", "--patch", "--no-color"], cwd, 5_000);
const normalized = out
.split("\n")
.filter((line) => !line.startsWith("index ") && !line.startsWith("@@ "))
.join("\n")
.trim();
return normalized || null;
} catch {
return null;
}
}
export async function collectRecentMergeAdvances(
scopedStore: TaskStore & {
getRunAuditEvents?: (filters: {
taskId?: string;
@@ -596,13 +611,6 @@ async function collectRecentMergeAdvances(
mutationType: "merge:integration-ref-advance",
limit: 10,
});
// Auto-sync events come in two flavors:
// - per-advance: emit with `worktreePath` + `newSha`; pair by (taskId, newSha)
// - early-failure (`outcome: "enumeration-failed"`): emitted by the merger
// when worktree enumeration fails BEFORE any advance was processed, so
// they carry NO `worktreePath` and NO `newSha`. We still want operators
// to see these — pair them by taskId-only as a fallback so the matching
// advance shows the actual reason instead of "no auto-sync record."
const wantPath = canonicalForCompare(worktreePath);
const autoSyncByAdvance = new Map<string, string>();
const autoSyncByTaskFallback = new Map<string, string>();
@@ -620,21 +628,17 @@ async function collectRecentMergeAdvances(
const hasPath = typeof md.worktreePath === "string";
const hasNewSha = typeof md.newSha === "string";
if (hasPath && hasNewSha) {
// Per-advance event for a specific worktree: only attribute to this
// user's checkout when the canonicalized paths match.
if (canonicalForCompare(md.worktreePath as string) !== wantPath) continue;
const key = pairKey(tid, md.newSha as string);
// Events are timestamp DESC; first occurrence is the freshest.
if (!autoSyncByAdvance.has(key)) autoSyncByAdvance.set(key, md.outcome);
} else if (!hasPath && !hasNewSha) {
// Early-failure event (e.g. "enumeration-failed"): no per-worktree
// attribution possible — apply to every advance for this task.
if (!autoSyncByTaskFallback.has(tid)) autoSyncByTaskFallback.set(tid, md.outcome);
}
// Events with one of the two but not the other are malformed; skip.
}
const successOutcomes = new Set(["clean-sync", "synced-with-edits-restored"]);
const out: NonNullable<ExtendedGitStatus["recentMergeAdvances"]> = [];
const headPatchIds = new Set<string>();
let headPatchIdsLoaded = false;
for (const ev of advances) {
const md = ev.metadata as { fromSha?: unknown; toSha?: unknown; succeeded?: unknown } | undefined;
if (!md || typeof md !== "object") continue;
@@ -642,30 +646,55 @@ async function collectRecentMergeAdvances(
if (md.succeeded === false) continue;
const tid = typeof ev.taskId === "string" ? ev.taskId : "";
if (!tid) continue;
const autoSyncOutcome =
autoSyncByAdvance.get(pairKey(tid, md.toSha))
?? autoSyncByTaskFallback.get(tid);
// The worktree may already contain `toSha` — either because auto-sync
// succeeded, the operator manually ran "Sync working tree" / pulled, or
// they checked out a later commit by hand. In all those cases there's
// nothing left to do, regardless of what the original auto-sync audit
// event recorded. Treat reachability from HEAD as authoritative.
let alreadyInHead = false;
if (headSha) {
if (headSha === md.toSha) {
alreadyInHead = true;
} else {
const autoSyncOutcome = autoSyncByAdvance.get(pairKey(tid, md.toSha)) ?? autoSyncByTaskFallback.get(tid);
let resolution: "reachable" | "orphaned" | "subsumed" | "pending" = "pending";
let toShaExists = true;
if (headSha && headSha === md.toSha) {
resolution = "reachable";
} else if (headSha) {
try {
await runGitCommand(["cat-file", "-e", `${md.toSha}^{commit}`], worktreePath, 5_000);
} catch {
toShaExists = false;
resolution = "orphaned";
}
if (toShaExists && resolution === "pending") {
try {
await runGitCommand(["merge-base", "--is-ancestor", md.toSha, headSha], worktreePath, 5_000);
alreadyInHead = true;
resolution = "reachable";
} catch {
alreadyInHead = false;
// continue
}
}
if (toShaExists && resolution === "pending") {
const targetPatchId = await getPatchFingerprint(worktreePath, md.toSha);
if (targetPatchId) {
if (!headPatchIdsLoaded) {
headPatchIdsLoaded = true;
try {
const commitsOut = (await runGitCommand(["log", "-n", "50", "--pretty=%H", headSha], worktreePath, 5_000)).trim();
const commits = commitsOut ? commitsOut.split("\n").filter(Boolean) : [];
for (const commitSha of commits) {
const patchId = await getPatchFingerprint(worktreePath, commitSha);
if (patchId) headPatchIds.add(patchId);
}
} catch {
// degrade conservatively
}
}
if (headPatchIds.has(targetPatchId)) {
resolution = "subsumed";
}
}
}
}
const needsAction = alreadyInHead
? false
: (autoSyncOutcome === undefined || !successOutcomes.has(autoSyncOutcome));
const needsAction = resolution === "pending"
&& (autoSyncOutcome === undefined || !successOutcomes.has(autoSyncOutcome));
out.push({
taskId: tid,
fromSha: typeof md.fromSha === "string" ? md.fromSha : null,
@@ -673,6 +702,7 @@ async function collectRecentMergeAdvances(
advancedAt: ev.timestamp,
autoSyncOutcome,
needsAction,
resolution,
});
if (out.length >= 5) break;
}