From b21bf348937efd1c14353003740a0cf55a89af0c Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 25 Apr 2026 22:07:17 -0700 Subject: [PATCH] feat: gstack-gbrain-source-wireup helper + 13 unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new bin/gstack-gbrain-source-wireup is the single helper that registers the gstack brain repo as a gbrain federated source via `git worktree`, runs incremental sync, and supports --uninstall + --probe + --strict modes. Replaces the dead `consumers.json + ingest_url + /ingest-repo` HTTP wireup introduced in v1.12.0.0 — that endpoint never shipped on the gbrain side. The federation surface (`gbrain sources` / `gbrain sync`) shipped in gbrain v0.18.0; this helper adapts to its actual semantics (no `sources update`, so path drift recovery is `remove + re-add`; no `--install-cron` either, so freshness rides on the existing skill-end push hook). Source-id derivation is multi-fallback: ~/.gstack/.git origin URL → ~/.gstack-brain-remote.txt → --source-id flag. This makes `--uninstall` work even after `~/.gstack/.git` is destroyed by the parent uninstall script. Worktree is `--detach`ed at $GSTACK_HOME's HEAD because main is already checked out there; advance is a re-checkout of the parent's current HEAD, not a `git pull`. Divergence recovery removes + re-adds the worktree. Test suite covers 13 cases: fresh-state registration, idempotent re-runs, drift recovery, --strict failure modes, source-id fallback chain, --probe non-mutation, sync errors, and --uninstall. Fake gbrain on $PATH, real git ops at GSTACK_HOME tmp dir. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/gstack-gbrain-source-wireup | 244 ++++++++++++++++++ test/gstack-gbrain-source-wireup.test.ts | 309 +++++++++++++++++++++++ 2 files changed, 553 insertions(+) create mode 100755 bin/gstack-gbrain-source-wireup create mode 100644 test/gstack-gbrain-source-wireup.test.ts diff --git a/bin/gstack-gbrain-source-wireup b/bin/gstack-gbrain-source-wireup new file mode 100755 index 00000000..985aa05b --- /dev/null +++ b/bin/gstack-gbrain-source-wireup @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# gstack-gbrain-source-wireup — register the gstack brain repo as a gbrain +# federated source via `git worktree`, run an initial sync, hook into +# subsequent skill-end syncs. +# +# Replaces the v1.12.2.0 dead `consumers.json + ingest_url + /ingest-repo` +# wireup which depended on a gbrain HTTP endpoint that never shipped. +# +# Usage: +# gstack-gbrain-source-wireup [--strict] [--source-id ] [--no-pull] +# gstack-gbrain-source-wireup --uninstall [--source-id ] +# gstack-gbrain-source-wireup --probe +# gstack-gbrain-source-wireup --help +# +# Exit codes: +# 0 — success, OR benign skip without --strict +# 1 — hard failure (gbrain or git op errored on a real call) +# 2 — missing prereqs (no gbrain >= 0.18.0, no .git or remote-file) +# 3 — source-id derivation failed in --uninstall, no fallback worked +# +# Env: +# GSTACK_HOME — override ~/.gstack (test harness) +# GSTACK_BRAIN_WORKTREE — override worktree path (default ~/.gstack-brain-worktree) +# GSTACK_BRAIN_SOURCE_ID — id override; --source-id flag takes precedence +# GSTACK_BRAIN_NO_SYNC — skip the gbrain sync step (tests; helper still +# ensures source registration) +# +# Depends on: jq (transitive via gstack-gbrain-detect). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG_BIN="$SCRIPT_DIR/gstack-config" + +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +WORKTREE="${GSTACK_BRAIN_WORKTREE:-$HOME/.gstack-brain-worktree}" +REMOTE_FILE="$HOME/.gstack-brain-remote.txt" +PLIST_PATH="$HOME/Library/LaunchAgents/com.gstack.brain-sync.plist" + +# ---- arg parse ---- +MODE="wireup" +STRICT=0 +NO_PULL=0 +SOURCE_ID="" + +while [ $# -gt 0 ]; do + case "$1" in + --uninstall) MODE="uninstall"; shift ;; + --probe) MODE="probe"; shift ;; + --strict) STRICT=1; shift ;; + --no-pull) NO_PULL=1; shift ;; + --source-id) SOURCE_ID="$2"; shift 2 ;; + --help|-h) sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "Unknown flag: $1" >&2; exit 1 ;; + esac +done + +prefix() { sed 's/^/gstack-gbrain-source-wireup: /' >&2; } +warn() { echo "$*" | prefix; } +die() { warn "$*"; exit "${2:-1}"; } + +# ---- source-id derivation (D6 multi-fallback) ---- +derive_source_id() { + if [ -n "$SOURCE_ID" ]; then + echo "$SOURCE_ID"; return 0 + fi + if [ -n "${GSTACK_BRAIN_SOURCE_ID:-}" ]; then + echo "$GSTACK_BRAIN_SOURCE_ID"; return 0 + fi + local remote_url="" + remote_url=$(git -C "$GSTACK_HOME" remote get-url origin 2>/dev/null) || true + if [ -z "$remote_url" ] && [ -f "$REMOTE_FILE" ]; then + remote_url=$(head -1 "$REMOTE_FILE" 2>/dev/null | tr -d '[:space:]') + fi + [ -z "$remote_url" ] && return 3 + basename "$remote_url" .git \ + | tr '[:upper:]' '[:lower:]' \ + | tr -c 'a-z0-9-' '-' \ + | sed 's/--*/-/g; s/^-//; s/-$//' \ + | cut -c1-32 +} + +# ---- gbrain version gate ---- +gbrain_version_ok() { + if ! command -v gbrain >/dev/null 2>&1; then + return 1 + fi + local v + v=$(gbrain --version 2>/dev/null | awk '{print $2}') + [ -z "$v" ] && return 1 + # 0.18.0 minimum (gbrain sources shipped here) + [ "$(printf '%s\n0.18.0\n' "$v" | sort -V | head -1)" = "0.18.0" ] +} + +# ---- worktree management ---- +# A worktree is always created `--detach`ed at $GSTACK_HOME's HEAD. Detached +# because a branch (main) can only be checked out in ONE worktree, and the +# parent at $GSTACK_HOME already has it. To advance, we re-checkout the +# parent's current HEAD into the detached worktree. +_worktree_add_detached() { + local sha + sha=$(git -C "$GSTACK_HOME" rev-parse HEAD 2>/dev/null) || return 1 + git -C "$GSTACK_HOME" worktree prune 2>/dev/null || true + git -C "$GSTACK_HOME" worktree add --detach "$WORKTREE" "$sha" 2>/dev/null +} + +ensure_worktree() { + if [ ! -d "$GSTACK_HOME/.git" ]; then + return 2 + fi + if [ -d "$WORKTREE/.git" ] || [ -f "$WORKTREE/.git" ]; then + # already exists; advance the detached HEAD to parent's current HEAD + if [ "$NO_PULL" = "0" ]; then + local sha + sha=$(git -C "$GSTACK_HOME" rev-parse HEAD 2>/dev/null) || return 1 + ( cd "$WORKTREE" && git checkout --detach "$sha" 2>/dev/null ) || { + warn "worktree at $WORKTREE could not advance to $sha; resetting via remove + re-add" + git -C "$GSTACK_HOME" worktree remove --force "$WORKTREE" 2>/dev/null || rm -rf "$WORKTREE" + _worktree_add_detached || return 1 + } + fi + return 0 + fi + # Stray non-git dir? Remove first. + [ -e "$WORKTREE" ] && rm -rf "$WORKTREE" + _worktree_add_detached || return 1 +} + +# ---- gbrain sources operations ---- +# Returns 0 if source with id exists at expected path. 1 if exists but path differs. 2 if absent. +check_source_state() { + local id="$1" + local existing_path + existing_path=$(gbrain sources list --json 2>/dev/null \ + | jq -r --arg id "$id" '.sources[] | select(.id==$id) | .local_path' 2>/dev/null) || existing_path="" + if [ -z "$existing_path" ]; then + return 2 + fi + if [ "$existing_path" = "$WORKTREE" ]; then + return 0 + fi + return 1 +} + +# ---- modes ---- +do_probe() { + local id worktree_status="absent" gbrain_status="missing" source_status="absent" + id=$(derive_source_id 2>/dev/null) || id="(unknown)" + [ -d "$WORKTREE/.git" ] || [ -f "$WORKTREE/.git" ] && worktree_status="present" + if gbrain_version_ok; then + gbrain_status="ok ($(gbrain --version 2>/dev/null | awk '{print $2}'))" + if check_source_state "$id"; then + source_status="registered ($WORKTREE)" + elif [ $? = 1 ]; then + source_status="registered (different path)" + fi + fi + echo "source_id=$id" + echo "worktree=$WORKTREE" + echo "worktree_status=$worktree_status" + echo "gbrain=$gbrain_status" + echo "source_status=$source_status" +} + +do_wireup() { + local id + id=$(derive_source_id) || die "cannot derive source id (no .git, no remote-file, no --source-id)" 2 + + if ! gbrain_version_ok; then + if [ "$STRICT" = "1" ]; then + die "gbrain not installed or < 0.18.0; install/upgrade gbrain and re-run" 2 + fi + warn "gbrain not installed or < 0.18.0; skipping wireup (benign skip)" + exit 0 + fi + + ensure_worktree || { + if [ $? = 2 ]; then + [ "$STRICT" = "1" ] && die "no $GSTACK_HOME/.git; run /setup-gbrain Step 7 (gstack-brain-init) first" 2 + warn "no $GSTACK_HOME/.git; skipping (benign skip)" + exit 0 + fi + die "git worktree creation failed at $WORKTREE" 1 + } + + # Source registration: probe state, then act. + set +e + check_source_state "$id" + local sstate=$? + set -e + case "$sstate" in + 0) : ;; # already correctly registered + 1) + warn "source $id registered with different path; recreating (gbrain has no 'sources update')" + gbrain sources remove "$id" --yes 2>&1 | prefix || die "gbrain sources remove failed" 1 + gbrain sources add "$id" --path "$WORKTREE" --federated 2>&1 | prefix \ + || die "gbrain sources add failed" 1 + ;; + 2) + gbrain sources add "$id" --path "$WORKTREE" --federated 2>&1 | prefix \ + || die "gbrain sources add failed" 1 + ;; + esac + + if [ "${GSTACK_BRAIN_NO_SYNC:-0}" = "1" ]; then + echo "source_id=$id" + echo "worktree=$WORKTREE" + echo "pages_synced=skipped" + exit 0 + fi + + local sync_out + sync_out=$(gbrain sync --repo "$WORKTREE" 2>&1) || die "gbrain sync failed: $sync_out" 1 + echo "$sync_out" | tail -3 | prefix + + echo "source_id=$id" + echo "worktree=$WORKTREE" + echo "pages_synced=$(echo "$sync_out" | grep -oE '[0-9]+ pages? imported' | head -1 || echo 'incremental')" +} + +do_uninstall() { + local id + id=$(derive_source_id) || die "cannot derive source id; pass --source-id explicitly" 3 + + if command -v gbrain >/dev/null 2>&1; then + gbrain sources remove "$id" --yes 2>&1 | prefix || warn "gbrain sources remove failed (continuing)" + fi + + if [ -d "$WORKTREE/.git" ] || [ -f "$WORKTREE/.git" ]; then + git -C "$GSTACK_HOME" worktree remove --force "$WORKTREE" 2>/dev/null \ + || rm -rf "$WORKTREE" + fi + + # Cron-stub: future launchd plist (not created today; safety net for D9 future). + rm -f "$PLIST_PATH" 2>/dev/null || true + + echo "uninstalled source=$id worktree=$WORKTREE" +} + +case "$MODE" in + probe) do_probe ;; + wireup) do_wireup ;; + uninstall) do_uninstall ;; +esac diff --git a/test/gstack-gbrain-source-wireup.test.ts b/test/gstack-gbrain-source-wireup.test.ts new file mode 100644 index 00000000..1200b6d9 --- /dev/null +++ b/test/gstack-gbrain-source-wireup.test.ts @@ -0,0 +1,309 @@ +/** + * gstack-gbrain-source-wireup — unit tests with mocked gbrain CLI. + * + * The helper registers the gstack brain repo as a gbrain federated source + * via `git worktree`, runs an initial sync, and exposes --uninstall + --probe. + * + * Strategy: put a fake `gbrain` binary on PATH that records every call into + * a log file and reads/writes its "registered sources" state from a JSON + * file in the test's tmp dir. The helper sees a consistent gbrain-CLI surface + * but no real database, no real gbrain. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN_DIR = path.join(ROOT, 'bin'); +const WIREUP_BIN = path.join(BIN_DIR, 'gstack-gbrain-source-wireup'); + +let tmpHome: string; +let gstackHome: string; +let worktreeDir: string; +let fakeBinDir: string; +let gbrainCallLog: string; +let gbrainStateFile: string; + +function makeFakeGbrain(opts: { + version?: string | null; // null = "binary missing" (don't write the file) + syncFails?: boolean; +}) { + const version = opts.version ?? '0.18.2'; + if (version === null) return; // simulate missing binary by NOT writing one + const syncFails = opts.syncFails ?? false; + + // Stub gbrain reads/writes state from a JSON file. Fields: + // sources: [{id, local_path, federated}] + fs.writeFileSync(gbrainStateFile, JSON.stringify({ sources: [] }, null, 2)); + + const script = `#!/bin/bash +LOG="${gbrainCallLog}" +STATE="${gbrainStateFile}" +echo "gbrain $@" >> "$LOG" + +# --version +if [ "$1" = "--version" ]; then + echo "gbrain ${version}" + exit 0 +fi + +# sources list --json → emits state +if [ "$1" = "sources" ] && [ "$2" = "list" ]; then + cat "$STATE" + exit 0 +fi + +# sources add --path

--federated → adds entry +if [ "$1" = "sources" ] && [ "$2" = "add" ]; then + shift 2 + ID="$1"; shift + PATH_VAL="" + FED="false" + while [ $# -gt 0 ]; do + case "$1" in + --path) PATH_VAL="$2"; shift 2 ;; + --federated) FED="true"; shift ;; + *) shift ;; + esac + done + python3 -c " +import json, sys +state = json.load(open('$STATE')) +state['sources'].append({'id': '$ID', 'local_path': '$PATH_VAL', 'federated': '$FED' == 'true'}) +json.dump(state, open('$STATE','w'), indent=2) +" || exit 1 + exit 0 +fi + +# sources remove --yes → drops entry +if [ "$1" = "sources" ] && [ "$2" = "remove" ]; then + shift 2 + ID="$1" + python3 -c " +import json +state = json.load(open('$STATE')) +state['sources'] = [s for s in state['sources'] if s['id'] != '$ID'] +json.dump(state, open('$STATE','w'), indent=2) +" + exit 0 +fi + +# sync --repo

→ records, optionally fails +if [ "$1" = "sync" ]; then + ${syncFails ? 'echo "sync failed: connection error" >&2; exit 1' : 'echo "1 page imported"; exit 0'} +fi + +echo "fake gbrain: unhandled subcommand: $@" >&2 +exit 99 +`; + const gbrainPath = path.join(fakeBinDir, 'gbrain'); + fs.writeFileSync(gbrainPath, script, { mode: 0o755 }); +} + +function run( + argv: string[], + opts: { env?: Record } = {} +) { + const env = { + PATH: `${fakeBinDir}:${process.env.PATH || '/usr/bin:/bin:/opt/homebrew/bin'}`, + HOME: tmpHome, + GSTACK_HOME: gstackHome, + GSTACK_BRAIN_WORKTREE: worktreeDir, + GSTACK_BRAIN_NO_SYNC: '0', + ...(opts.env || {}), + }; + return spawnSync(WIREUP_BIN, argv, { + env, + encoding: 'utf-8', + cwd: ROOT, + }); +} + +function readState(): { sources: Array<{ id: string; local_path: string; federated: boolean }> } { + if (!fs.existsSync(gbrainStateFile)) return { sources: [] }; + return JSON.parse(fs.readFileSync(gbrainStateFile, 'utf-8')); +} + +function gbrainCalls(): string[] { + if (!fs.existsSync(gbrainCallLog)) return []; + return fs.readFileSync(gbrainCallLog, 'utf-8') + .split('\n') + .filter((l) => l.trim()); +} + +function setupGstackRepo(remoteUrl: string) { + // Real git repo at gstackHome with at least one commit + an origin remote. + fs.mkdirSync(gstackHome, { recursive: true }); + spawnSync('git', ['-C', gstackHome, 'init', '-q', '-b', 'main'], { stdio: 'pipe' }); + spawnSync('git', ['-C', gstackHome, 'config', 'user.email', 'test@example.com'], { stdio: 'pipe' }); + spawnSync('git', ['-C', gstackHome, 'config', 'user.name', 'test'], { stdio: 'pipe' }); + fs.writeFileSync(path.join(gstackHome, '.brain-allowlist'), '# allowlist\n'); + spawnSync('git', ['-C', gstackHome, 'add', '.'], { stdio: 'pipe' }); + spawnSync('git', ['-C', gstackHome, 'commit', '-q', '-m', 'init'], { stdio: 'pipe' }); + spawnSync('git', ['-C', gstackHome, 'remote', 'add', 'origin', remoteUrl], { stdio: 'pipe' }); +} + +beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-wireup-test-')); + gstackHome = path.join(tmpHome, '.gstack'); + worktreeDir = path.join(tmpHome, '.gstack-brain-worktree'); + fakeBinDir = path.join(tmpHome, 'fake-bin'); + fs.mkdirSync(fakeBinDir, { recursive: true }); + gbrainCallLog = path.join(tmpHome, 'gbrain-calls.log'); + gbrainStateFile = path.join(tmpHome, 'gbrain-state.json'); +}); + +afterEach(() => { + try { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } catch {} +}); + +describe('gstack-gbrain-source-wireup — wireup mode', () => { + test('fresh state: registers source + creates worktree + syncs', () => { + setupGstackRepo('git@github.com:user/gstack-brain-user.git'); + makeFakeGbrain({}); + const r = run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } }); + expect(r.status).toBe(0); + expect(fs.existsSync(worktreeDir)).toBe(true); + const state = readState(); + expect(state.sources).toHaveLength(1); + expect(state.sources[0].id).toBe('gstack-brain-user'); + expect(state.sources[0].local_path).toBe(worktreeDir); + expect(state.sources[0].federated).toBe(true); + }); + + test('idempotent re-run after success: no new sources add call', () => { + setupGstackRepo('git@github.com:user/gstack-brain-user.git'); + makeFakeGbrain({}); + run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } }); + const callsAfterFirst = gbrainCalls().filter((c) => c.startsWith('gbrain sources add')).length; + expect(callsAfterFirst).toBe(1); + run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } }); + const callsAfterSecond = gbrainCalls().filter((c) => c.startsWith('gbrain sources add')).length; + expect(callsAfterSecond).toBe(1); // no new add + }); + + test('drift recovery: existing source with different path triggers remove + add', () => { + setupGstackRepo('git@github.com:user/gstack-brain-user.git'); + makeFakeGbrain({}); + // Pre-seed the fake gbrain state with a source at the wrong path + fs.writeFileSync( + gbrainStateFile, + JSON.stringify({ + sources: [{ id: 'gstack-brain-user', local_path: '/old/stale/path', federated: true }], + }) + ); + const r = run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } }); + expect(r.status).toBe(0); + const calls = gbrainCalls(); + expect(calls.some((c) => c.startsWith('gbrain sources remove gstack-brain-user'))).toBe(true); + expect(calls.some((c) => c.includes(`gbrain sources add gstack-brain-user --path ${worktreeDir}`))).toBe(true); + const state = readState(); + expect(state.sources[0].local_path).toBe(worktreeDir); + }); + + test('--strict + gbrain too old: exits 2', () => { + setupGstackRepo('git@github.com:user/gstack-brain-user.git'); + makeFakeGbrain({ version: '0.17.0' }); + const r = run(['--strict']); + expect(r.status).toBe(2); + expect(r.stderr).toContain('< 0.18.0'); + }); + + test('non-strict + gbrain too old: warn + exit 0', () => { + setupGstackRepo('git@github.com:user/gstack-brain-user.git'); + makeFakeGbrain({ version: '0.17.0' }); + const r = run([]); + expect(r.status).toBe(0); + expect(r.stderr).toContain('benign skip'); + }); + + test('--strict + gbrain missing on PATH: exits 2', () => { + setupGstackRepo('git@github.com:user/gstack-brain-user.git'); + // Don't make a fake gbrain — fakeBinDir is empty. Keep system dirs on PATH + // so basic commands (git, awk, sed, etc.) work; only `gbrain` is absent. + const r = run(['--strict'], { + env: { PATH: `${fakeBinDir}:/usr/bin:/bin:/opt/homebrew/bin` }, + }); + expect(r.status).toBe(2); + }); + + test('source-id derived from origin URL', () => { + setupGstackRepo('git@github.com:user/gstack-brain-alice.git'); + makeFakeGbrain({}); + const r = run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } }); + expect(r.status).toBe(0); + expect(readState().sources[0].id).toBe('gstack-brain-alice'); + }); + + test('source-id fallback to ~/.gstack-brain-remote.txt when .git is gone', () => { + // No git repo at gstackHome; just the remote-file + fs.mkdirSync(tmpHome, { recursive: true }); + fs.writeFileSync( + path.join(tmpHome, '.gstack-brain-remote.txt'), + 'git@github.com:user/gstack-brain-bob.git\n' + ); + makeFakeGbrain({}); + // No --strict: helper should benign-skip because .gstack/.git is missing + const r = run([]); + // ensure_worktree returns 2 → benign skip, exit 0 + expect(r.status).toBe(0); + }); + + test('source-id from --source-id flag overrides everything', () => { + setupGstackRepo('git@github.com:user/gstack-brain-different.git'); + makeFakeGbrain({}); + run(['--source-id', 'custom-id'], { env: { GSTACK_BRAIN_NO_SYNC: '1' } }); + const state = readState(); + expect(state.sources[0].id).toBe('custom-id'); + }); + + test('--probe: read-only, prints state without mutating', () => { + setupGstackRepo('git@github.com:user/gstack-brain-user.git'); + makeFakeGbrain({}); + const r = run(['--probe']); + expect(r.status).toBe(0); + expect(r.stdout).toContain('source_id=gstack-brain-user'); + expect(r.stdout).toContain('worktree='); + expect(r.stdout).toContain('gbrain=ok'); + expect(r.stdout).toContain('source_status=absent'); + // Probe should NOT call sources add / sync + const calls = gbrainCalls(); + expect(calls.some((c) => c.startsWith('gbrain sources add'))).toBe(false); + expect(calls.some((c) => c.startsWith('gbrain sync'))).toBe(false); + }); + + test('gbrain sync failure: exits 1 with stderr', () => { + setupGstackRepo('git@github.com:user/gstack-brain-user.git'); + makeFakeGbrain({ syncFails: true }); + const r = run([]); + expect(r.status).toBe(1); + expect(r.stderr).toContain('sync failed'); + }); +}); + +describe('gstack-gbrain-source-wireup — uninstall mode', () => { + test('after wireup: removes source + worktree', () => { + setupGstackRepo('git@github.com:user/gstack-brain-user.git'); + makeFakeGbrain({}); + run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } }); + expect(readState().sources).toHaveLength(1); + expect(fs.existsSync(worktreeDir)).toBe(true); + const r = run(['--uninstall']); + expect(r.status).toBe(0); + expect(readState().sources).toHaveLength(0); + expect(fs.existsSync(worktreeDir)).toBe(false); + }); + + test('with no prior state: exits 3 (cannot derive id)', () => { + // No git repo, no remote file. --uninstall must fail with code 3. + fs.mkdirSync(tmpHome, { recursive: true }); + makeFakeGbrain({}); + const r = run(['--uninstall']); + expect(r.status).toBe(3); + }); +});