fix: reclaim merger-ai autostashes and stop dropping untracked work
merger-ai's local-checkout sync stashed under fusion-ai-merge-sync-<taskId>, a label none of merger.ts's reclamation machinery matches — every path keys off the fusion-merger-autostash: prefix. Those entries were never classified, never subsumed-dropped, never age-swept, and never surfaced as orphans holding work, so they accumulated indefinitely: six entries dating back a month were found on one working tree, and their age made real lost work indistinguishable from litter. merger-ai now labels through buildAutostashLabel, and the legacy prefix stays recognized so already-leaked entries are reclaimed rather than stranded in developers' stash lists. Routing them into that machinery first required fixing what it does with untracked files. A stash created with --include-untracked keeps them in a third parent (<sha>^3) that git stash show omits, so an untracked-only stash read as empty — and all three copies of the liveness check treated empty as "subsumed, safe to drop". Every leaked ai-sync stash carried untracked files, so the fix would otherwise have destroyed the work it was meant to reclaim. Liveness now resolves through one authority, classifyStashContent, which reads both sides, diffs untracked paths against <sha>^3 rather than the stash commit (whose tree never contained them), and treats unreadable state as unknown and therefore undroppable. Age-based sweeping is left alone: it drops by timestamp without consulting content, which is deliberate bounded retention and the backstop against this same accumulation, not a safety gap. Regression test uses real git — the defect lives in git's stash object model, so a mocked git can neither express nor catch it — and asserts the invariant across tracked-only, untracked-only, and mixed stashes in both live and subsumed states. The mixed shape (tracked subsumed, untracked live) is the one that silently lost work. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/reclaim-ai-merge-sync-autostashes.md
Normal file
7
.changeset/reclaim-ai-merge-sync-autostashes.md
Normal file
@@ -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-<taskId>`, 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 (`<sha>^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 `<sha>^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.
|
||||
@@ -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
|
||||
(`<sha>^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);
|
||||
});
|
||||
});
|
||||
@@ -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-<taskId>` 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) {
|
||||
|
||||
@@ -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-<taskId>`
|
||||
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-<n>`). */
|
||||
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<Set<string>> {
|
||||
const out = new Set<string>();
|
||||
/*
|
||||
FNXC:MergeAutostash 2026-07-15-13:20:
|
||||
A stash created with `--include-untracked` stores its untracked files in a THIRD
|
||||
parent commit (`<sha>^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<Set<string> | 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<string>();
|
||||
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<Set<string> | null> {
|
||||
try {
|
||||
await execAsync(`git rev-parse -q --verify ${quoteArg(`${stashSha}^3`)}`, { cwd: rootDir, encoding: "utf-8" });
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git ls-tree -r -z --name-only ${quoteArg(`${stashSha}^3`)}`,
|
||||
{ cwd: rootDir, encoding: "utf-8" },
|
||||
);
|
||||
const out = new Set<string>();
|
||||
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<Set<string>> {
|
||||
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<string>([...(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: "<sha> stash@{N} <subject including label>"
|
||||
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-<taskId>` (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
|
||||
`<sha>^3`. Diffing an untracked path against `<sha>` 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<string>, against: string): Promise<boolean | null> => {
|
||||
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<AutostashOrphanRecord[]> {
|
||||
@@ -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-<id>` 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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user