diff --git a/.changeset/reclaim-ai-merge-sync-autostashes.md b/.changeset/reclaim-ai-merge-sync-autostashes.md new file mode 100644 index 0000000000..94b60a1d10 --- /dev/null +++ b/.changeset/reclaim-ai-merge-sync-autostashes.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Merge autostashes no longer pile up in `git stash list`, and untracked work in them is never dropped. +category: fix +dev: `merger-ai`'s local-checkout sync labelled stashes `fusion-ai-merge-sync-`, which no reclamation path in `merger.ts` matched (all key off `fusion-merger-autostash:`) — they were never classified, subsumed-dropped, age-swept, or surfaced as orphans. It now labels via the new exported `buildAutostashLabel(taskId, "ai-local-sync", ts)`; the legacy prefix stays recognized so already-leaked entries are reclaimed rather than stranded. Separately, `--include-untracked` stashes keep untracked files in a third parent (`^3`) that `git stash show` omits, so an untracked-only stash read as empty and empty meant "subsumed → drop". Liveness now resolves through one authority, `classifyStashContent`, which enumerates both sides, diffs untracked paths against `^3`, and treats unreadable state as `unknown` (never dropped); it replaces three divergent copies of the check. Age-based sweeping is unchanged deliberate bounded retention. diff --git a/packages/engine/src/__tests__/merger-autostash-untracked-reclaim.real-git.test.ts b/packages/engine/src/__tests__/merger-autostash-untracked-reclaim.real-git.test.ts new file mode 100644 index 0000000000..92448032d9 --- /dev/null +++ b/packages/engine/src/__tests__/merger-autostash-untracked-reclaim.real-git.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import type { TaskStore } from "@fusion/core"; +import { __test__, buildAutostashLabel } from "../merger.js"; + +const { sweepAutostashOrphans, parseAutostashTaskId, listAutostashOrphans } = __test__; + +/* +FNXC:MergeAutostash 2026-07-15-13:20: +Real git, not mocks: the defect under test is a property of git's own stash +object model — `--include-untracked` puts untracked files in a third parent +(`^3`) that `git stash show` omits — so a mocked git can neither express nor +catch it. Reading only the tracked side made an untracked-only stash look empty, +and empty was treated as "subsumed → safe to drop", silently destroying work. + +Asserts the invariant across ALL stash shapes rather than the single reported +case (FN-5893): tracked-only, untracked-only, and mixed, each in both live and +subsumed states. The mixed/live-untracked shape is the one that regressed — +tracked side subsumed, untracked side live — because a tracked-only reader drops +it and takes the untracked work with it. +*/ + +function git(cwd: string, cmd: string): string { + return execSync(cmd, { cwd, stdio: "pipe" }).toString("utf-8").trim(); +} + +function initRepo(dir: string): void { + git(dir, "git init -b main"); + git(dir, 'git config user.email "test@example.com"'); + git(dir, 'git config user.name "Test"'); + writeFileSync(join(dir, "file.txt"), "base\n"); + git(dir, "git add file.txt"); + git(dir, 'git commit -m "init"'); +} + +function stashList(dir: string): string { + return git(dir, 'git stash list --format="%H %gd %s"'); +} + +function testTempParent(): string { + return process.env.FUSION_TEST_WORKER_ROOT ?? tmpdir(); +} + +function assertIsolatedWorkspace(dir: string): void { + const repoRoot = process.env.FUSION_TEST_REAL_ROOT; + if (!repoRoot) return; + expect(resolve(dir).startsWith(resolve(repoRoot))).toBe(false); +} + +/** Store whose tasks are all live, so orphan retention is decided purely by + * stash content rather than by the closed-task drop path. */ +function makeStore(): TaskStore { + return { + getTask: async (taskId: string) => ({ id: taskId, column: "in-progress" }) as never, + logEntry: async () => undefined, + } as unknown as TaskStore; +} + +/** Stash the working tree under a canonical label, including untracked files. */ +function pushAutostash(dir: string, taskId: string, phase = "ai-local-sync"): string { + const label = buildAutostashLabel(taskId, phase, Date.now()); + git(dir, `git stash push --include-untracked -m ${JSON.stringify(label)}`); + return git(dir, 'git stash list --format="%H" -n 1'); +} + +describe("autostash reclamation — untracked content (real git)", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(testTempParent(), "fusion-test-autostash-untracked-")); + assertIsolatedWorkspace(dir); + initRepo(dir); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("retains an untracked-only stash whose file is absent from HEAD", async () => { + writeFileSync(join(dir, "new-plan.md"), "unrecovered work\n"); + const sha = pushAutostash(dir, "FN-6001"); + + await sweepAutostashOrphans(dir, "FN-9999", makeStore()); + + expect(stashList(dir)).toContain(sha); + }); + + it("reports an untracked-only stash's paths rather than an empty list", async () => { + writeFileSync(join(dir, "new-plan.md"), "unrecovered work\n"); + pushAutostash(dir, "FN-6002"); + + const [record] = await listAutostashOrphans(dir); + + expect(record?.changedPaths).toContain("new-plan.md"); + expect(record?.classification).toBe("live"); + }); + + it("retains a mixed stash whose tracked side is subsumed but untracked side is not", async () => { + // Tracked edit lands on HEAD (subsumed); untracked file never does (live). + writeFileSync(join(dir, "file.txt"), "landed\n"); + writeFileSync(join(dir, "orphan-test.ts"), "still only in the stash\n"); + const sha = pushAutostash(dir, "FN-6003"); + writeFileSync(join(dir, "file.txt"), "landed\n"); + git(dir, "git add file.txt"); + git(dir, 'git commit -m "land the tracked edit"'); + + await sweepAutostashOrphans(dir, "FN-9999", makeStore()); + + expect(stashList(dir)).toContain(sha); + }); + + it("drops a stash once BOTH its tracked and untracked content are on HEAD", async () => { + writeFileSync(join(dir, "file.txt"), "landed\n"); + writeFileSync(join(dir, "added.ts"), "landed too\n"); + const sha = pushAutostash(dir, "FN-6004"); + writeFileSync(join(dir, "file.txt"), "landed\n"); + writeFileSync(join(dir, "added.ts"), "landed too\n"); + git(dir, "git add file.txt added.ts"); + git(dir, 'git commit -m "land both"'); + + await sweepAutostashOrphans(dir, "FN-9999", makeStore()); + + expect(stashList(dir)).not.toContain(sha); + }); + + it("retains a tracked-only stash that still differs from HEAD", async () => { + writeFileSync(join(dir, "file.txt"), "uncommitted edit\n"); + const sha = pushAutostash(dir, "FN-6005"); + + await sweepAutostashOrphans(dir, "FN-9999", makeStore()); + + expect(stashList(dir)).toContain(sha); + }); +}); + +describe("autostash reclamation — merger-ai label vocabulary", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(testTempParent(), "fusion-test-autostash-ai-label-")); + assertIsolatedWorkspace(dir); + initRepo(dir); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("parses the task id from a legacy fusion-ai-merge-sync label", () => { + expect(parseAutostashTaskId("fusion-ai-merge-sync-FN-7790")).toBe("FN-7790"); + expect(parseAutostashTaskId("fusion-ai-merge-sync-")).toBeNull(); + expect(parseAutostashTaskId("fusion-ai-merge-sync-nope")).toBeNull(); + }); + + it("builds an ai-local-sync label the canonical parsers accept", () => { + const label = buildAutostashLabel("FN-7790", "ai-local-sync", 1_700_000_000_000); + expect(label).toBe("fusion-merger-autostash:FN-7790:ai-local-sync:1700000000000"); + expect(parseAutostashTaskId(label)).toBe("FN-7790"); + }); + + /* + The leak itself: a legacy-labelled stash was invisible to every reclamation + path, so it accumulated forever even once its content was fully on HEAD. + */ + it("reclaims a legacy-labelled stash once its content is on HEAD", async () => { + writeFileSync(join(dir, "file.txt"), "landed\n"); + git(dir, 'git stash push --include-untracked -m "fusion-ai-merge-sync-FN-7790"'); + const sha = git(dir, 'git stash list --format="%H" -n 1'); + writeFileSync(join(dir, "file.txt"), "landed\n"); + git(dir, "git add file.txt"); + git(dir, 'git commit -m "land it"'); + + await sweepAutostashOrphans(dir, "FN-9999", makeStore()); + + expect(stashList(dir)).not.toContain(sha); + }); + + it("retains a legacy-labelled stash that still holds unrecovered work", async () => { + writeFileSync(join(dir, "file.txt"), "never landed\n"); + git(dir, 'git stash push --include-untracked -m "fusion-ai-merge-sync-FN-7791"'); + const sha = git(dir, 'git stash list --format="%H" -n 1'); + + await sweepAutostashOrphans(dir, "FN-9999", makeStore()); + + expect(stashList(dir)).toContain(sha); + }); +}); diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index 239b170e59..ad9a9ec5c6 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -70,6 +70,7 @@ import { accumulateSessionTokenUsage } from "./session-token-usage.js"; import { createRunAuditor, generateSyntheticRunId, type RunAuditor } from "./run-audit.js"; import { createLogger } from "./logger.js"; import { + buildAutostashLabel, captureSingleCommitLandedMetadata, isNonFastForwardPushError, parsePushRemoteTarget, @@ -596,8 +597,22 @@ export async function landSquash(input: { + `Commit, stash, or clean local changes before retrying.`, ); } + /* + FNXC:MergeAutostash 2026-07-15-13:20: + Label through the canonical `fusion-merger-autostash:` vocabulary so this stash + reaches merger.ts's reclamation machinery: subsumed-drop once its content is on + HEAD, age sweep, and the orphan notifications that tell an operator work is + recoverable. The former `fusion-ai-merge-sync-` label matched none of + it, so the retention below ("keep as a backup") had no counterpart that ever + reclaimed the backup and entries accumulated for months. + Retention is still deliberate — only a stash whose content is provably already + on HEAD is ever dropped. + */ const stashed = dirty - ? await gitOk(["stash", "push", "--include-untracked", "-m", `fusion-ai-merge-sync-${taskId}`], projectRootDir) + ? await gitOk( + ["stash", "push", "--include-untracked", "-m", buildAutostashLabel(taskId, "ai-local-sync", Date.now())], + projectRootDir, + ) : false; if (dirty && !stashed) { diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index e2455bedaf..3bb62d5dc2 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -2218,29 +2218,103 @@ export interface AutostashHandle { } const AUTOSTASH_LABEL_PREFIX = "fusion-merger-autostash:"; + +/* +FNXC:MergeAutostash 2026-07-15-13:20: +`merger-ai`'s local-checkout sync stashed under its own `fusion-ai-merge-sync-` +label, which none of the reclamation machinery here matches — every path keys off +AUTOSTASH_LABEL_PREFIX. The entries were therefore never classified, never +subsumed-dropped, never age-swept, and never surfaced as orphans holding work: +they accumulated indefinitely (six entries dating back a month were found on a +single working tree, and their sheer age made real lost work indistinguishable +from litter). + +merger-ai now labels through `buildAutostashLabel` so one vocabulary reaches all +of it. This legacy prefix stays recognized so entries already sitting in +developers' stash lists are reclaimed rather than stranded forever; it carries no +timestamp, so the age sweep skips it and only the subsumed check can drop it. +*/ +const LEGACY_AI_SYNC_LABEL_PREFIX = "fusion-ai-merge-sync-"; + +/** Canonical autostash label. `phase` distinguishes the creating call site + * (`pre-merge`, `ai-local-sync`, `finalize-reset`, `race-rescue-`). */ +export function buildAutostashLabel(taskId: string, phase: string, at: number): string { + return `${AUTOSTASH_LABEL_PREFIX}${taskId}:${phase}:${at}`; +} const AUTOSTASH_TIMESTAMP_RE = /^fusion-merger-autostash:[A-Za-z]+-\d+:(?:(?:[a-z0-9-]+:)?(?:\d+:)?)?(\d+)$/; /** Return the set of paths a stash commit recorded as changed against its * parent (HEAD-at-stash-time). Used to compare a new dirty snapshot against * the primary autostash and avoid producing duplicate race-rescue stashes * for the same paths the primary already captured. */ -async function listStashChangedPaths(rootDir: string, stashSha: string): Promise> { - const out = new Set(); +/* +FNXC:MergeAutostash 2026-07-15-13:20: +A stash created with `--include-untracked` stores its untracked files in a THIRD +parent commit (`^3`) whose paths `git stash show` does not list — it reports +the tracked side only. Reading just that side makes an untracked-only stash look +EMPTY, and every "does this stash still hold work?" caller reads empty as +"subsumed → safe to drop". That silently destroys untracked work: new tests, plan +docs, and changesets are exactly what the ai-local-sync stashes carry. +Enumerate both sides, and keep them distinguishable — the two sides must be +diffed against different commits (see `classifyStashContent`). + +`null` means "could not read", which is NOT the same as "holds nothing". Callers +must treat null as unknown and refuse to drop; collapsing the two is the bug +above. +*/ +async function listStashTrackedPaths(rootDir: string, stashSha: string): Promise | null> { try { const { stdout } = await execAsync( `git stash show -z --name-only ${quoteArg(stashSha)}`, { cwd: rootDir, encoding: "utf-8" }, ); - for (const entry of stdout.split("\0")) { + const out = new Set(); + for (const entry of String(stdout).split("\0")) { const p = entry.trim(); if (p) out.add(p); } + return out; } catch { - // Best-effort: an empty set means we'll be slightly more aggressive - // about rescuing (everything dirty gets rescued), which is the safe - // direction — false positives are noise, false negatives are data loss. + return null; } - return out; +} + +/** Paths held in a stash's untracked third parent. An empty set (not null) is + * returned when the stash simply has no `^3` — i.e. it was created without + * `--include-untracked`, which is a legitimate "no untracked files" answer. */ +async function listStashUntrackedPaths(rootDir: string, stashSha: string): Promise | null> { + try { + await execAsync(`git rev-parse -q --verify ${quoteArg(`${stashSha}^3`)}`, { cwd: rootDir, encoding: "utf-8" }); + } catch { + return new Set(); + } + try { + const { stdout } = await execAsync( + `git ls-tree -r -z --name-only ${quoteArg(`${stashSha}^3`)}`, + { cwd: rootDir, encoding: "utf-8" }, + ); + const out = new Set(); + for (const entry of String(stdout).split("\0")) { + const p = entry.trim(); + if (p) out.add(p); + } + return out; + } catch { + return null; + } +} + +/** Every path a stash holds, tracked and untracked. Used for display and for + * rescue-scope decisions; `classifyStashContent` is the drop-safety authority. */ +async function listStashChangedPaths(rootDir: string, stashSha: string): Promise> { + const [tracked, untracked] = await Promise.all([ + listStashTrackedPaths(rootDir, stashSha), + listStashUntrackedPaths(rootDir, stashSha), + ]); + // Best-effort union: an empty set means we'll be slightly more aggressive + // about rescuing (everything dirty gets rescued), which is the safe + // direction — false positives are noise, false negatives are data loss. + return new Set([...(tracked ?? []), ...(untracked ?? [])]); } /** True iff two stash commits point to the exact same tree object. Cheap @@ -2431,7 +2505,10 @@ async function listOrphanedAutostashes( const orphans: Array<{ sha: string; ref: string; label: string }> = []; for (const line of lines) { // Format: " stash@{N} " - const idx = line.indexOf(AUTOSTASH_LABEL_PREFIX); + // Match the canonical label, or the legacy merger-ai one so already-leaked + // entries are reclaimed too (see LEGACY_AI_SYNC_LABEL_PREFIX). + let idx = line.indexOf(AUTOSTASH_LABEL_PREFIX); + if (idx === -1) idx = line.indexOf(LEGACY_AI_SYNC_LABEL_PREFIX); if (idx === -1) continue; const parts = line.split(/\s+/); const sha = parts[0] ?? ""; @@ -2446,8 +2523,11 @@ async function listOrphanedAutostashes( } function parseAutostashTaskId(label: string): string | null { - const match = /^fusion-merger-autostash:([A-Za-z]+-\d+):/.exec(label.trim()); - return match?.[1] ?? null; + const trimmed = label.trim(); + const match = /^fusion-merger-autostash:([A-Za-z]+-\d+):/.exec(trimmed); + if (match?.[1]) return match[1]; + // Legacy merger-ai label: `fusion-ai-merge-sync-` (no trailing fields). + return /^fusion-ai-merge-sync-([A-Za-z]+-\d+)$/.exec(trimmed)?.[1] ?? null; } @@ -2465,22 +2545,63 @@ function parseAutostashSourcePhase(label: string): string | null { if (phaseMatch?.[1]) return phaseMatch[1]; if (/^fusion-merger-autostash:[A-Za-z]+-\d+:race-rescue-\d+:\d+$/.test(trimmed)) return "race-rescue"; if (/^fusion-merger-autostash:[A-Za-z]+-\d+:\d+$/.test(trimmed)) return "pre-merge"; + if (/^fusion-ai-merge-sync-[A-Za-z]+-\d+$/.test(trimmed)) return "ai-local-sync"; return null; } +/* +FNXC:MergeAutostash 2026-07-15-13:20: +The single drop-safety authority: may this stash be discarded without losing work? + + - `subsumed` — every path it holds is already byte-identical to HEAD. Safe to drop. + - `live` — at least one path still differs from HEAD. Real work; never drop. + - `unknown` — we could not prove either. Never drop (false positives are noise, + false negatives are data loss). + +The tracked and untracked sides must be diffed against DIFFERENT commits: the +stash commit's tree holds only tracked content, while untracked files live in +`^3`. Diffing an untracked path against `` compares HEAD to a tree that +never contained it, which reports no difference and misreads live work as +subsumed — the data-loss path this replaces. + +Previously this logic existed in three near-identical copies (orphan +classification, the per-merge sweep, and the liveness probe), all sharing that +bug. One authority, one behavior. +*/ +async function classifyStashContent(rootDir: string, sha: string): Promise<"subsumed" | "live" | "unknown"> { + const [tracked, untracked] = await Promise.all([ + listStashTrackedPaths(rootDir, sha), + listStashUntrackedPaths(rootDir, sha), + ]); + // Unreadable side → cannot prove the stash is redundant. + if (tracked === null || untracked === null) return "unknown"; + if (tracked.size === 0 && untracked.size === 0) return "subsumed"; + + const differsFromHead = async (paths: Set, against: string): Promise => { + if (paths.size === 0) return false; + try { + const pathsArg = [...paths].map(quoteArg).join(" "); + const { stdout } = await execAsync( + `git diff --name-only HEAD ${quoteArg(against)} -- ${pathsArg}`, + { cwd: rootDir, encoding: "utf-8" }, + ); + return String(stdout).trim() !== ""; + } catch { + return null; + } + }; + + const trackedLive = await differsFromHead(tracked, sha); + if (trackedLive === null) return "unknown"; + if (trackedLive) return "live"; + + const untrackedLive = await differsFromHead(untracked, `${sha}^3`); + if (untrackedLive === null) return "unknown"; + return untrackedLive ? "live" : "subsumed"; +} + async function classifyAutostashOrphan(rootDir: string, sha: string): Promise<"subsumed" | "live" | "unknown"> { - try { - const stashFiles = await listStashChangedPaths(rootDir, sha); - if (stashFiles.size === 0) return "subsumed"; - const pathsArg = [...stashFiles].map(quoteArg).join(" "); - const { stdout: pathDiffOut } = await execAsync( - `git diff --name-only HEAD ${quoteArg(sha)} -- ${pathsArg}`, - { cwd: rootDir, encoding: "utf-8" }, - ); - return pathDiffOut.trim() === "" ? "subsumed" : "live"; - } catch { - return "unknown"; - } + return classifyStashContent(rootDir, sha); } export async function listAutostashOrphans(rootDir: string): Promise { @@ -2626,20 +2747,19 @@ async function sweepAutostashOrphans( for (const orphan of orphans) { try { - const stashFiles = await listStashChangedPaths(rootDir, orphan.sha); - if (stashFiles.size === 0) { - // Empty stash — nothing to lose by dropping. + /* + FNXC:MergeAutostash 2026-07-15-13:20: + Delegates to the shared drop-safety authority so tracked-only and + untracked-only stashes are judged identically. `unknown` is treated as + live: an unprovable stash is warned about, never dropped. + */ + const classification = await classifyStashContent(rootDir, orphan.sha); + if (classification === "subsumed") { subsumed.push(orphan); continue; } - const pathsArg = [...stashFiles].map(quoteArg).join(" "); - const { stdout: pathDiffOut } = await execAsync( - `git diff --name-only HEAD ${quoteArg(orphan.sha)} -- ${pathsArg}`, - { cwd: rootDir, encoding: "utf-8" }, - ); - const isPathSubsumed = pathDiffOut.trim() === ""; - if (isPathSubsumed) { - subsumed.push(orphan); + if (classification === "unknown") { + live.push(orphan); continue; } @@ -2741,6 +2861,16 @@ export async function sweepStaleAutostashes( const entries = await listOrphanedAutostashes(rootDir); let dropped = 0; + /* + FNXC:MergeAutostash 2026-07-15-13:20: + Age-based dropping is deliberate bounded retention, not a safety gap: it is + the backstop that stops autostashes accumulating forever when a restore + failed and nobody recovered the work. It intentionally drops by timestamp + alone, without consulting stash content — do not add a liveness check here. + Entries carrying a timestamp (every canonical label) age out; the legacy + `fusion-ai-merge-sync-` labels carry none, so they are reachable only via + the subsumed check in `sweepAutostashOrphans`. + */ for (const entry of entries) { const match = AUTOSTASH_TIMESTAMP_RE.exec(entry.label.trim()); if (!match) continue; @@ -2989,16 +3119,11 @@ export async function dropAutostashBySha( return { dropped: false, reason: "exhausted retry attempts" }; } +/** True when the stash still holds work not present on HEAD. An unprovable + * (`unknown`) stash counts as live so callers never discard it. */ async function isAutostashLive(rootDir: string, sha: string): Promise { try { - const stashFiles = await listStashChangedPaths(rootDir, sha); - if (stashFiles.size === 0) return false; - const pathsArg = [...stashFiles].map(quoteArg).join(" "); - const { stdout: pathDiffOut } = await execAsync( - `git diff --name-only HEAD ${quoteArg(sha)} -- ${pathsArg}`, - { cwd: rootDir, encoding: "utf-8" }, - ); - return pathDiffOut.trim().length > 0; + return (await classifyStashContent(rootDir, sha)) !== "subsumed"; } catch { return true; }