feat: gstack-gbrain-source-wireup helper + 13 unit tests

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) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-25 22:07:17 -07:00
parent 6209163900
commit b21bf34893
2 changed files with 553 additions and 0 deletions
+244
View File
@@ -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 <id>] [--no-pull]
# gstack-gbrain-source-wireup --uninstall [--source-id <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 <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
+309
View File
@@ -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 <id> --path <p> --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 <id> --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 <p> → 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<string, string> } = {}
) {
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);
});
});