A6: report deleted-worktree gbrain sources; prune only with proof, on request

A deleted Conductor worktree leaves its gstack-code-<hash> source behind
and gbrain's autopilot fails on it forever. Local absence is not proof a
source is dead (machines sharing a federated brain register their own
paths, and its pages may be the only copy of uncommitted work), so gstack
never removes one automatically.

- Each sync counts consecutive misses for gstack code sources whose path
  is missing, prints "path unavailable" once per source with the review
  command, and records which repository each synced source belongs to.
- New gstack-gbrain-sync --prune-gone-worktrees (with --dry-run, whose
  plan it always prints first) removes a source only when its path was
  missing on two consecutive syncs, its parent is readable, recomputing
  deriveCodeSourceId() for that path on this host yields its id, and the
  repository's git common dir still exists and no longer lists the
  worktree. It re-checks the path right before removing (through the
  #1734-guarded safeSourcesRemove) and logs each removal.
This commit is contained in:
garrytan committed 2026-10-03 21:23:46 +00:00
1 parent 16aed1ced5
commit ce651c2089
4 files changed
+405 -5

No files matched your search

+150 -5
View File
@@ -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 <list|all>` 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 <list> 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<string, UnavailableSource>;
/** A6: what this machine knew about each code source it synced (ownership proof). */
code_sources?: Record<string, CodeSourceRecord>;
}
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<void> {
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<void> {
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<void> {
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);
+26
View File
@@ -427,6 +427,32 @@ If it stays at zero, re-register the source with `gstack-gbrain-source-wireup`.
---
## `[gbrain-sync] gbrain source <id>: path unavailable (<path>); 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-<repo>-<hash>` 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 <id>` only if you are sure.
---
## Nothing is syncing but I expect it to
**Not an error, but a common gotcha.** Check in order:
+85
View File
@@ -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<string, UnavailableSource>,
exists: (p: string) => boolean,
now: string,
): { next: Record<string, UnavailableSource>; newlyUnavailable: string[] } {
const next: Record<string, UnavailableSource> = {};
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<string, UnavailableSource>;
/** Records from earlier syncs on this machine, by source id. */
records: Record<string, CodeSourceRecord>;
/** 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` };
}
+144
View File
@@ -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> = {}): 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<string, string> = {};
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<string> {
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}`);
});
});