diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 6af701b2f..4990d5b9b 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -29,7 +29,7 @@ * than building a gstack-side daemon. */ -import { existsSync, statSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, renameSync, realpathSync } from "fs"; +import { existsSync, statSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, renameSync, realpathSync, readdirSync, appendFileSync } from "fs"; import { join, dirname } from "path"; import { execSync, spawnSync } from "child_process"; import { homedir, hostname } from "os"; @@ -37,7 +37,18 @@ import { createHash } from "crypto"; import "../lib/conductor-env-shim"; import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers"; -import { constrainSourceId, ensureSourceRegistered, sourcePageCount, parseSourcesList, cycleCompleted, type CycleStatus } from "../lib/gbrain-sources"; +import { + constrainSourceId, + ensureSourceRegistered, + sourcePageCount, + parseSourcesList, + cycleCompleted, + decidePrune, + trackUnavailableSources, + type CodeSourceRecord, + type CycleStatus, + type UnavailableSource, +} from "../lib/gbrain-sources"; import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbrain-guards"; import { writeReceipt } from "../lib/egress-receipt"; import { dbUnreachableReason, localEngineStatus, localEngineStatusDetail, type LocalEngineStatus } from "../lib/gbrain-local-status"; @@ -66,6 +77,8 @@ export interface CliArgs { allowReclone: boolean; /** #2922: `--sources ` for the memory stage; wins over GSTACK_MEMORY_INGEST_SOURCES. */ memorySources?: string; + /** A6: remove gstack code sources whose worktree is provably gone (with --dry-run: report only). */ + pruneGone?: boolean; } interface CodeStageDetail { @@ -474,6 +487,11 @@ Options: Runs lock-free AFTER the sync stages. ~minutes. Default timeout 45min, override GSTACK_SYNC_DREAM_TIMEOUT_MS. --no-dream Opt out of the dream cycle that --full would auto-run. + --prune-gone-worktrees Remove gstack code sources whose worktree is provably + gone: missing on 2 consecutive syncs, created on this + host (its id recomputes from host + path), and no + longer listed by its repository. Prints the plan first; + add --dry-run to only print it. Never automatic. --allow-reclone Permit the code walk for URL-managed sources (remote_url set) even though gbrain may auto-reclone the working tree (#1734). --sources Memory types to ingest (comma-separated, or \`all\`): @@ -508,6 +526,7 @@ function parseArgs(): CliArgs { let noDream = false; let allowReclone = false; let memorySources: string | undefined; + let pruneGone = false; for (let i = 0; i < args.length; i++) { const a = args[i]; @@ -536,6 +555,7 @@ function parseArgs(): CliArgs { // --no-dream can override) — do NOT set dream from --full here. case "--dream": dream = true; break; case "--no-dream": noDream = true; break; + case "--prune-gone-worktrees": pruneGone = true; break; case "--help": case "-h": printUsage(); @@ -547,7 +567,7 @@ function parseArgs(): CliArgs { } } - return { mode, quiet, noCode, noMemory, noBrainSync, codeOnly, dream, noDream, allowReclone, memorySources }; + return { mode, quiet, noCode, noMemory, noBrainSync, codeOnly, dream, noDream, allowReclone, memorySources, pruneGone }; } // ── Helpers ──────────────────────────────────────────────────────────────── @@ -589,10 +609,9 @@ function originUrl(): string | null { * optional interior hyphens. `constrainSourceId` handles the 32-char cap * with a hashed-tail fallback when the combined slug exceeds budget. */ -function deriveCodeSourceId(repoPath: string): string { +export function deriveCodeSourceId(repoPath: string, remote: string = canonicalizeRemote(originUrl())): string { const host = process.env.GSTACK_HOSTNAME || hostname(); const hostPathHash = createHash("sha1").update(`${host}::${repoPath}`).digest("hex").slice(0, 8); - const remote = canonicalizeRemote(originUrl()); if (remote) { const segs = remote.split("/").filter(Boolean); const slugSource = segs.slice(-2).join("-"); @@ -1890,6 +1909,10 @@ interface SyncState { last_sync?: string; last_full_sync?: string; last_stages?: StageResult[]; + /** A6: gstack code sources whose path was missing, with consecutive-miss counts. */ + unavailable_sources?: Record; + /** A6: what this machine knew about each code source it synced (ownership proof). */ + code_sources?: Record; } function loadSyncState(): SyncState { @@ -1948,8 +1971,124 @@ export function formatStage(s: StageResult): string { // ── Main ─────────────────────────────────────────────────────────────────── +// ── Gone worktree sources (A6, #2688) ────────────────────────────────────── + +function gitCommonDir(root: string): string | null { + const r = spawnSync("git", ["-C", root, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf-8", timeout: 5000 }); + return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : null; +} + +function currentCodeSourceRecord(): CodeSourceRecord | null { + const root = repoRoot(); + return root ? { path: root, remote: canonicalizeRemote(originUrl()), git_common_dir: gitCommonDir(root) } : null; +} + +/** Remember which repository a synced code source belongs to (the prune ownership proof). */ +function recordCodeSource(state: SyncState, stages: StageResult[]): void { + const code = stages.find((s) => s.name === "code" && s.ran && s.ok); + const id = code?.detail?.source_id; + const record = currentCodeSourceRecord(); + if (!id || !record || code?.detail?.source_path !== record.path) return; + (state.code_sources ??= {})[id] = record; +} + +/** + * Count consecutive syncs that find a gstack code source's path missing, and + * say so once per source. gstack never removes such a source on its own. + */ +function trackGoneSources(state: SyncState, quiet: boolean): void { + const raw = execGbrainJson(["sources", "list", "--json"], { timeout: 10_000 }); + if (raw === null) return; + const { next, newlyUnavailable } = trackUnavailableSources(parseSourcesList(raw), state.unavailable_sources ?? {}, existsSync, new Date().toISOString()); + state.unavailable_sources = next; + for (const id of newlyUnavailable) { + console.error( + `[gbrain-sync] gbrain source ${id}: path unavailable (${next[id].path}); gstack skips it. ` + + `If that worktree was deleted, review removal with: gstack-gbrain-sync --prune-gone-worktrees --dry-run`, + ); + } + if (!quiet && newlyUnavailable.length === 0 && Object.keys(next).length > 0) { + console.error(`[gbrain-sync] ${Object.keys(next).length} gbrain source(s) still have an unavailable path (see --prune-gone-worktrees --dry-run)`); + } +} + +function readableDir(p: string): boolean { + try { + readdirSync(p); + return true; + } catch { + return false; + } +} + +function worktreeListed(gitDir: string, path: string): boolean | null { + const r = spawnSync("git", [`--git-dir=${gitDir}`, "worktree", "list", "--porcelain"], { encoding: "utf-8", timeout: 10_000 }); + if (r.status !== 0) return null; + return r.stdout.split("\n").some((line) => line === `worktree ${path}`); +} + +/** + * `--prune-gone-worktrees`: remove gstack code sources whose worktree is + * provably gone (lib/gbrain-sources.ts decidePrune). Prints the plan first; + * with --dry-run that is all it does. Each removal re-checks the path right + * before removing and is logged to ~/.gstack/.gbrain-prune.log. + */ +function pruneGoneWorktrees(dryRun: boolean): number { + const raw = execGbrainJson(["sources", "list", "--json"], { timeout: 10_000 }); + if (raw === null) { + console.error("[prune] gbrain sources list failed; nothing removed. Fix: run /setup-gbrain, then retry."); + return 1; + } + if (!dryRun && !acquireLock()) { + console.error(`[prune] another /sync-gbrain is running (lock at ${LOCK_PATH}); nothing removed.`); + return 2; + } + try { + const state = loadSyncState(); + const ctx = { + unavailable: state.unavailable_sources ?? {}, + records: state.code_sources ?? {}, + current: currentCodeSourceRecord(), + deriveId: deriveCodeSourceId, + exists: existsSync, + readableDir, + worktreeListed, + }; + const rows = parseSourcesList(raw).filter((r) => r.id?.startsWith("gstack-code-") && r.local_path && !existsSync(r.local_path)); + if (rows.length === 0) { + console.log("[prune] no gstack code source has a missing path; nothing to do."); + return 0; + } + const plan = rows.map((r) => ({ id: r.id!, path: r.local_path!, ...decidePrune(r.id!, r.local_path!, ctx) })); + for (const p of plan) console.log(`[prune]${dryRun ? " (dry run)" : ""} ${p.remove ? "would remove" : "keep"} ${p.id} (${p.path}): ${p.reason}`); + if (dryRun) return 0; + let failed = 0; + for (const p of plan.filter((x) => x.remove)) { + if (existsSync(p.path)) { + console.log(`[prune] keep ${p.id}: ${p.path} exists again`); + continue; + } + const r = safeSourcesRemove(p.id); + const line = `${new Date().toISOString()} ${r.removed ? "removed" : "not removed"} ${p.id} ${p.path}: ${r.removed ? p.reason : r.reason}`; + console.log(`[prune] ${line}`); + try { + appendFileSync(join(GSTACK_HOME, ".gbrain-prune.log"), line + "\n"); + } catch {} + if (r.removed) { + delete state.unavailable_sources?.[p.id]; + delete state.code_sources?.[p.id]; + } else failed++; + } + saveSyncState(state); + return failed > 0 ? 1 : 0; + } finally { + if (!dryRun) releaseLock(); + } +} + async function main(): Promise { const args = parseArgs(); + if (args.pruneGone) process.exit(pruneGoneWorktrees(args.mode === "dry-run")); // Read once at sync start: a config change made while the sync runs never // adds transcripts to this run. const consent = args.noMemory ? null : normalizeTranscriptConsent(); @@ -1998,6 +2137,8 @@ async function main(): Promise { state.last_sync = new Date().toISOString(); if (args.mode === "full") state.last_full_sync = state.last_sync; state.last_stages = stages; + recordCodeSource(state, stages); + trackGoneSources(state, args.quiet); saveSyncState(state); } @@ -2055,6 +2196,10 @@ async function main(): Promise { const okCount = allStages.filter((s) => s.ok).length; const errCount = allStages.filter((s) => !s.ok && s.ran).length; console.log(`\n ${okCount} ok, ${errCount} error, ${allStages.length - okCount - errCount} skipped`); + const gone = Object.keys(loadSyncState().unavailable_sources ?? {}).length; + if (gone > 0) { + console.log(` ${gone} gbrain source(s) have an unavailable path; review with: gstack-gbrain-sync --prune-gone-worktrees --dry-run`); + } } process.exit(exitCode); diff --git a/docs/gbrain-sync-errors.md b/docs/gbrain-sync-errors.md index 79e573a6d..01ee3703d 100644 --- a/docs/gbrain-sync-errors.md +++ b/docs/gbrain-sync-errors.md @@ -427,6 +427,32 @@ If it stays at zero, re-register the source with `gstack-gbrain-source-wireup`. --- +## `[gbrain-sync] gbrain source : path unavailable (); gstack skips it.` + +**Problem.** A gbrain code source points at a directory that no longer +exists on this machine, usually a deleted Conductor worktree. gbrain's +autopilot keeps failing on it. + +**Cause.** Each worktree gets its own `gstack-code--` source. +Deleting the worktree leaves the source behind. gstack does not remove it on +its own: its indexed pages may be the only copy of work that was never +committed, and a source can belong to another machine sharing the brain. + +**Fix.** Review, then remove the ones gstack can prove are gone: +```bash +gstack-gbrain-sync --prune-gone-worktrees --dry-run +gstack-gbrain-sync --prune-gone-worktrees +``` +A source is removed only when its path was missing on two consecutive syncs, +its parent directory is readable, its id recomputes from this host and path +(so this machine created it), and its repository still exists but no longer +lists the worktree. Each removal is logged to `~/.gstack/.gbrain-prune.log`. +Run it from the main checkout of the same repository so gstack can find the +repository. Anything it keeps prints the reason; remove such a source by hand +with `gbrain sources remove ` only if you are sure. + +--- + ## Nothing is syncing but I expect it to **Not an error, but a common gotcha.** Check in order: diff --git a/lib/gbrain-sources.ts b/lib/gbrain-sources.ts index c7730348e..87fb2f001 100644 --- a/lib/gbrain-sources.ts +++ b/lib/gbrain-sources.ts @@ -12,6 +12,7 @@ import { execFileSync, spawnSync } from "child_process"; import { createHash } from "crypto"; import { realpathSync } from "fs"; +import { dirname } from "path"; import { withErrorContext } from "./gstack-memory-helpers"; import { execGbrainJson, gbrainChildCwd, gbrainInvocation } from "./gbrain-exec"; import { @@ -393,3 +394,87 @@ export function constrainSourceId(prefix: string, raw: string): string { const tail = kept.join("-"); return tail ? `${prefix}-${tail}-${hash}` : `${prefix}-${hash}`; } + +// ── Gone worktree sources (A6, #2688) ────────────────────────────────────── + +/** A gstack code source whose registered path is missing on this machine. */ +export interface UnavailableSource { + path: string; + /** Consecutive syncs that found the path missing. */ + misses: number; + since: string; +} + +/** What gstack knew about a code source the last time it synced it here. */ +export interface CodeSourceRecord { + path: string; + /** Canonical origin remote ("" for a repo without one). */ + remote: string; + /** Absolute git common dir of the repository the worktree belongs to. */ + git_common_dir: string | null; +} + +/** + * Track gstack code sources whose path is missing. Absence on this machine + * is not proof a source is dead (machines sharing a federated brain register + * their own paths), so this only counts consecutive misses for the explicit + * prune command and reports each newly unavailable source once. + */ +export function trackUnavailableSources( + rows: GbrainSourceRow[], + prev: Record, + exists: (p: string) => boolean, + now: string, +): { next: Record; newlyUnavailable: string[] } { + const next: Record = {}; + const newlyUnavailable: string[] = []; + for (const r of rows) { + if (!r.id?.startsWith("gstack-code-") || !r.local_path || exists(r.local_path)) continue; + const before = prev[r.id]; + next[r.id] = { path: r.local_path, misses: (before?.misses ?? 0) + 1, since: before?.since ?? now }; + if (!before) newlyUnavailable.push(r.id); + } + return { next, newlyUnavailable }; +} + +export interface PruneContext { + unavailable: Record; + /** Records from earlier syncs on this machine, by source id. */ + records: Record; + /** The current checkout's repository, a candidate owner for its deleted worktrees. */ + current: CodeSourceRecord | null; + /** deriveCodeSourceId() for an explicit path and remote, on this host. */ + deriveId: (path: string, remote: string) => string; + exists: (p: string) => boolean; + readableDir: (p: string) => boolean; + /** Whether `git worktree list` in the common dir still lists the path; null when git failed. */ + worktreeListed: (gitCommonDir: string, path: string) => boolean | null; +} + +/** + * Decide whether `gstack-gbrain-sync --prune-gone-worktrees` may remove a + * source. Its indexed pages may be the only copy of uncommitted work, so + * every condition must hold: the path is gone for two consecutive syncs, its + * parent is readable (not an unmounted volume), recomputing + * deriveCodeSourceId() for the path on this host yields the id (this machine + * created it), and the repository still exists but no longer lists the + * worktree. + */ +export function decidePrune(id: string, path: string, ctx: PruneContext): { remove: boolean; reason: string } { + if (ctx.exists(path)) return { remove: false, reason: "path exists" }; + const parent = dirname(path); + if (!ctx.readableDir(parent)) return { remove: false, reason: `parent directory ${parent} is not readable (unmounted volume?)` }; + const misses = ctx.unavailable[id]?.misses ?? 0; + if (misses < 2) return { remove: false, reason: `path missing on ${misses} sync(s) so far; needs 2 consecutive syncs` }; + const record = [ctx.records[id], ctx.current].find((r) => r && ctx.deriveId(path, r.remote) === id); + if (!record) { + return { remove: false, reason: "not proven to be this machine's source (its id does not recompute from this host and path)" }; + } + if (!record.git_common_dir || !ctx.exists(record.git_common_dir)) { + return { remove: false, reason: `its repository (${record.git_common_dir ?? "unknown git dir"}) is not available to confirm the worktree was removed` }; + } + const listed = ctx.worktreeListed(record.git_common_dir, path); + if (listed === null) return { remove: false, reason: "git worktree list failed" }; + if (listed) return { remove: false, reason: "git still lists this worktree (run `git worktree prune` if it was deleted)" }; + return { remove: true, reason: `missing on ${misses} syncs; created on this host; ${record.git_common_dir} no longer lists it` }; +} diff --git a/test/gbrain-prune-gone-worktrees.test.ts b/test/gbrain-prune-gone-worktrees.test.ts new file mode 100644 index 000000000..893e897a6 --- /dev/null +++ b/test/gbrain-prune-gone-worktrees.test.ts @@ -0,0 +1,144 @@ +/** + * A6 (#2688): a deleted worktree's gbrain code source is reported once as + * "path unavailable" and is only removed by the explicit + * `gstack-gbrain-sync --prune-gone-worktrees`, and only with positive + * evidence that this machine created it and the worktree is gone. + */ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, realpathSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { spawnSync } from "child_process"; +import { decidePrune, trackUnavailableSources, type PruneContext } from "../lib/gbrain-sources"; + +const SYNC = join(import.meta.dir, "..", "bin", "gstack-gbrain-sync.ts"); + +describe("prune decision (unit)", () => { + const base = (over: Partial = {}): PruneContext => ({ + unavailable: { "gstack-code-x": { path: "/w/gone", misses: 2, since: "t" } }, + records: { "gstack-code-x": { path: "/w/gone", remote: "github.com/acme/app", git_common_dir: "/repo/.git" } }, + current: null, + deriveId: (path, remote) => (path === "/w/gone" && remote === "github.com/acme/app" ? "gstack-code-x" : "gstack-code-other"), + exists: (p) => p === "/repo/.git", + readableDir: () => true, + worktreeListed: () => false, + ...over, + }); + + it("removes only with every proof", () => { + expect(decidePrune("gstack-code-x", "/w/gone", base()).remove).toBe(true); + }); + + it("keeps a source created on another host (id does not recompute here)", () => { + const v = decidePrune("gstack-code-x", "/w/gone", base({ deriveId: () => "gstack-code-elsewhere" })); + expect(v).toEqual({ remove: false, reason: expect.stringContaining("not proven to be this machine's source") }); + }); + + it("keeps a source missing on fewer than two consecutive syncs", () => { + const v = decidePrune("gstack-code-x", "/w/gone", base({ unavailable: { "gstack-code-x": { path: "/w/gone", misses: 1, since: "t" } } })); + expect(v.reason).toContain("needs 2 consecutive syncs"); + }); + + it("keeps a source whose parent directory is unreadable (unmounted volume)", () => { + expect(decidePrune("gstack-code-x", "/w/gone", base({ readableDir: () => false })).reason).toContain("not readable"); + }); + + it("keeps a source whose repository still lists the worktree, or cannot be checked", () => { + expect(decidePrune("gstack-code-x", "/w/gone", base({ worktreeListed: () => true })).remove).toBe(false); + expect(decidePrune("gstack-code-x", "/w/gone", base({ worktreeListed: () => null })).remove).toBe(false); + expect(decidePrune("gstack-code-x", "/w/gone", base({ exists: () => false })).reason).toContain("not available to confirm"); + }); + + it("counts consecutive misses and reports each source once", () => { + const rows = [{ id: "gstack-code-x", local_path: "/w/gone" }, { id: "gstack-code-y", local_path: "/w/here" }, { id: "other", local_path: "/w/gone2" }]; + const exists = (p: string) => p === "/w/here"; + const first = trackUnavailableSources(rows, {}, exists, "t1"); + expect(first.newlyUnavailable).toEqual(["gstack-code-x"]); + const second = trackUnavailableSources(rows, first.next, exists, "t2"); + expect(second.newlyUnavailable).toEqual([]); + expect(second.next["gstack-code-x"]).toEqual({ path: "/w/gone", misses: 2, since: "t1" }); + expect(trackUnavailableSources(rows, second.next, () => true, "t3").next).toEqual({}); + }); +}); + +describe("gstack-gbrain-sync with a deleted worktree (CLI)", () => { + let home = ""; + let env: Record = {}; + let repo = ""; + let gone = ""; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "gstack-prune-"))); + const bin = join(home, "bin"); + mkdirSync(bin, { recursive: true }); + repo = join(home, "app"); + gone = join(home, "worktrees", "feature"); + env = { + HOME: home, GSTACK_HOME: join(home, ".gstack"), PATH: `${bin}:/usr/bin:/bin`, GSTACK_HOSTNAME: "this-host", + GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: join(home, ".gitconfig"), + GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.com", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.com", + }; + const git = (...a: string[]) => expect(spawnSync("git", a, { env, cwd: home, timeout: 10_000 }).status).toBe(0); + git("init", "-q", repo); + git("-C", repo, "remote", "add", "origin", "https://github.com/acme/app.git"); + git("-C", repo, "commit", "-q", "--allow-empty", "-m", "init"); + mkdirSync(join(home, "worktrees")); + git("-C", repo, "worktree", "add", "-q", gone); + git("-C", repo, "worktree", "remove", "--force", gone); + }); + afterEach(() => rmSync(home, { recursive: true, force: true })); + + function fakeGbrain(sources: Array<{ id: string; local_path: string }>) { + writeFileSync(join(home, "bin", "gbrain"), `#!/bin/sh +echo "$*" >> "${home}/gbrain-calls.log" +case "$1 $2" in + "sources list") echo '${JSON.stringify({ sources: sources.map((s) => ({ ...s, page_count: 3 })) })}' ;; + "sources remove") exit 0 ;; + *) exit 1 ;; +esac +`); + chmodSync(join(home, "bin", "gbrain"), 0o755); + } + function sync(args: string[]) { + const r = spawnSync(process.execPath, [SYNC, ...args], { env, cwd: repo, encoding: "utf-8", timeout: 60_000 }); + return { status: r.status, out: (r.stdout || "") + (r.stderr || "") }; + } + async function idFor(path: string, host: string): Promise { + const r = spawnSync(process.execPath, ["-e", `import { deriveCodeSourceId } from ${JSON.stringify(SYNC)}; console.log(deriveCodeSourceId(${JSON.stringify(path)}, "github.com/acme/app"))`], { + env: { ...env, GSTACK_HOSTNAME: host }, encoding: "utf-8", timeout: 20_000, + }); + return r.stdout.trim(); + } + + it("reports the path once, never removes on sync, and prunes only this host's proven source", async () => { + const mine = await idFor(gone, "this-host"); + const theirs = await idFor(gone, "other-host"); + expect(mine).not.toBe(theirs); + fakeGbrain([{ id: mine, local_path: gone }, { id: theirs, local_path: gone }]); + const noStages = ["--no-code", "--no-memory", "--no-brain-sync", "--quiet"]; + + const first = sync(noStages); + expect(first.out).toContain(`gbrain source ${mine}: path unavailable (${gone})`); + expect(first.out).toContain("--prune-gone-worktrees --dry-run"); + const early = sync(["--prune-gone-worktrees", "--dry-run"]); + expect(early.out).toContain(`keep ${mine} (${gone}): path missing on 1 sync(s) so far`); + + const second = sync(noStages); + expect(second.out).not.toContain("path unavailable ("); + const log = () => (existsSync(join(home, "gbrain-calls.log")) ? readFileSync(join(home, "gbrain-calls.log"), "utf8") : ""); + expect(log()).not.toContain("sources remove"); + + const dry = sync(["--prune-gone-worktrees", "--dry-run"]); + expect(dry.status).toBe(0); + expect(dry.out).toContain(`would remove ${mine}`); + expect(dry.out).toContain(`keep ${theirs}`); + expect(log()).not.toContain("sources remove"); + + const real = sync(["--prune-gone-worktrees"]); + expect(real.status).toBe(0); + expect(real.out).toContain(`removed ${mine} ${gone}`); + expect(log()).toContain(`sources remove ${mine}`); + expect(log()).not.toContain(`sources remove ${theirs}`); + expect(readFileSync(join(home, ".gstack", ".gbrain-prune.log"), "utf8")).toContain(`removed ${mine}`); + }); +});