diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 4fc658ac4..0f0263f12 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -32,7 +32,7 @@ import { existsSync, statSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, renameSync } from "fs"; import { join, dirname } from "path"; import { execSync, spawnSync } from "child_process"; -import { homedir } from "os"; +import { homedir, hostname } from "os"; import { createHash } from "crypto"; import "../lib/conductor-env-shim"; @@ -161,30 +161,35 @@ function originUrl(): string | null { } /** - * Derive a worktree-aware source id for the cwd code corpus. + * Derive a host- and worktree-aware source id for the cwd code corpus. * - * Pattern: `gstack-code--` where slug comes from origin - * (org/repo) and pathhash8 is the first 8 hex chars of sha1(absolute repo - * path). The pathhash8 is what makes Conductor worktrees of the same repo - * coexist as separate sources in the same gbrain DB instead of stomping on - * each other. + * Pattern: `gstack-code--` where slug comes from origin + * (org/repo) and hostpathhash8 is the first 8 hex chars of + * sha1(`${hostname}::${absolute repo path}`). Folding hostname into the hash + * keeps Conductor worktrees of the same repo as distinct sources on one host + * AND keeps two machines that share an absolute layout (e.g. chezmoi-managed + * home dirs against a federated brain) from colliding on each other. * * Falls back to the repo basename when there is no origin (local repo). * + * `GSTACK_HOSTNAME` env override is honored for deterministic tests; in + * production paths it is unset and `os.hostname()` is used. + * * gbrain enforces source ids to be 1-32 lowercase alnum chars with * 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 { - const pathHash = createHash("sha1").update(repoPath).digest("hex").slice(0, 8); + 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("-"); - return constrainSourceId("gstack-code", `${slugSource}-${pathHash}`); + return constrainSourceId("gstack-code", `${slugSource}-${hostPathHash}`); } const base = repoPath.split("/").pop() || "repo"; - return constrainSourceId("gstack-code", `${base}-${pathHash}`); + return constrainSourceId("gstack-code", `${base}-${hostPathHash}`); } /** @@ -208,6 +213,172 @@ function deriveLegacyCodeSourceId(repoPath: string): string { return constrainSourceId("gstack-code", base); } +/** + * Pre-#1468 path-only-hash source id, kept for hostname-fold migration only. + * + * Before the hostname fold, `deriveCodeSourceId` hashed only the absolute + * repo path: `gstack-code--`. After #1468 the + * hash key is `${hostname}::${path}`, so every existing user's brain has a + * legacy id that no longer matches what `deriveCodeSourceId` produces. We + * detect this form once, attempt rename-in-place if the gbrain CLI supports + * `sources rename`, and otherwise clean up after the new source successfully + * syncs. Distinct from `deriveLegacyCodeSourceId` (pre-pathhash v1.x form); + * both probes run. + */ +export function derivePathOnlyHashLegacyId(repoPath: string): string { + const pathHash = createHash("sha1").update(repoPath).digest("hex").slice(0, 8); + const remote = canonicalizeRemote(originUrl()); + if (remote) { + const segs = remote.split("/").filter(Boolean); + const slugSource = segs.slice(-2).join("-"); + return constrainSourceId("gstack-code", `${slugSource}-${pathHash}`); + } + const base = repoPath.split("/").pop() || "repo"; + return constrainSourceId("gstack-code", `${base}-${pathHash}`); +} + +/** + * Feature-check whether the installed gbrain CLI ships `sources rename `. + * + * Per the v1.40.0.0 design review: probing `gbrain sources rename --help` and + * matching for the exact argument shape catches the case where gbrain's + * `sources` parent help mentions a `rename` subcommand but the CLI doesn't + * accept the ` ` form (or vice versa). Cached for the lifetime + * of the process. As of gbrain 0.35.0.0 this command does not exist, so the + * function returns false and the migration path falls back to register-new + * + sync-OK + remove-old. + */ +let _gbrainSupportsRenameCache: boolean | null = null; +export function _resetGbrainSupportsRenameCache(): void { + _gbrainSupportsRenameCache = null; +} +function gbrainSupportsSourcesRename(env?: NodeJS.ProcessEnv): boolean { + if (_gbrainSupportsRenameCache !== null) return _gbrainSupportsRenameCache; + try { + const r = spawnSync("gbrain", ["sources", "rename", "--help"], { + encoding: "utf-8", + timeout: 5_000, + stdio: ["ignore", "pipe", "pipe"], + env: env || process.env, + }); + const out = `${r.stdout || ""}\n${r.stderr || ""}`; + // Match the exact argument shape: `rename ` (with literal + // angle brackets in usage strings) or `rename OLD NEW`. + const exact = /sources\s+rename\s+\s+/i.test(out) + || /sources\s+rename\s+OLD\s+NEW/.test(out) + || /sources\s+rename\s+\s+/i.test(out); + _gbrainSupportsRenameCache = exact && r.status === 0; + } catch { + _gbrainSupportsRenameCache = false; + } + return _gbrainSupportsRenameCache; +} + +/** + * Look up a source's `local_path` from `gbrain sources list --json`. + * Returns null when the source is absent or the listing fails. + * + * `env` is the environment passed to the spawned `gbrain` process; defaults + * to `process.env`. Tests inject a PATH that points at a gbrain shim so the + * helper can be exercised without a real gbrain CLI. + */ +export function sourceLocalPath(sourceId: string, env?: NodeJS.ProcessEnv): string | null { + try { + const r = spawnSync("gbrain", ["sources", "list", "--json"], { + encoding: "utf-8", + timeout: 30_000, + stdio: ["ignore", "pipe", "pipe"], + env: env || process.env, + }); + if (r.status !== 0) return null; + const list = JSON.parse(r.stdout || "[]") as Array<{ id: string; local_path?: string }>; + const found = list.find((s) => s.id === sourceId); + return found?.local_path ?? null; + } catch { + return null; + } +} + +/** Result of `planHostnameFoldMigration` — informs `runCodeImport` of next steps. */ +export type HostnameFoldMigration = + | { kind: "none"; reason: "ids-match" | "no-legacy-source" } + | { kind: "skipped-path-drift"; oldId: string; oldPath: string; currentPath: string } + | { kind: "renamed"; oldId: string; newId: string } + | { kind: "pending-cleanup"; oldId: string }; + +/** + * Decide how to migrate from the pre-#1468 path-only-hash source id to the + * new hostname-fold id. + * + * Order: + * 1. If old == new → no-op. + * 2. Look up old source's local_path. Absent → no legacy source to migrate. + * 3. local_path != currentRoot → user moved the repo or two machines share a + * hash slot. Skip migration; let the user clean up manually. We will NOT + * rename or remove anything; the new source is registered alongside. + * 4. Otherwise: feature-check `gbrain sources rename`. If supported and the + * rename call exits 0 → renamed, pages preserved. + * 5. Else: pending-cleanup. Caller registers + syncs new source first; only + * after sync succeeds with a non-zero page count does it remove the old. + * This avoids a data-loss window where the old source is gone before the + * new one is verifiably populated. + */ +export function planHostnameFoldMigration( + currentRoot: string, + newSourceId: string, + legacyPathHashId: string, + env?: NodeJS.ProcessEnv, +): HostnameFoldMigration { + if (legacyPathHashId === newSourceId) { + return { kind: "none", reason: "ids-match" }; + } + const oldPath = sourceLocalPath(legacyPathHashId, env); + if (oldPath === null) { + return { kind: "none", reason: "no-legacy-source" }; + } + if (oldPath !== currentRoot) { + return { + kind: "skipped-path-drift", + oldId: legacyPathHashId, + oldPath, + currentPath: currentRoot, + }; + } + if (gbrainSupportsSourcesRename(env)) { + const r = spawnSync("gbrain", ["sources", "rename", legacyPathHashId, newSourceId], { + encoding: "utf-8", + timeout: 30_000, + stdio: ["ignore", "pipe", "pipe"], + env: env || process.env, + }); + if (r.status === 0) { + return { kind: "renamed", oldId: legacyPathHashId, newId: newSourceId }; + } + // Rename failed at runtime — fall through to cleanup path. + } + return { kind: "pending-cleanup", oldId: legacyPathHashId }; +} + +/** + * Remove an orphaned source. Called only after new-source sync verifies pages + * exist, so the old source is provably redundant before deletion. + * + * Flag note: existing call sites used `--confirm-destructive` here and + * `--yes` in `lib/gbrain-sources.ts` — gbrain 0.35.0.0 accepts neither + * deterministically (the subcommand surface help is generic). We pass + * `--confirm-destructive` to match the existing call site convention; the + * flag-helper centralization in commit 4 (lib/gbrain-exec.ts) will resolve + * the inconsistency across the codebase. + */ +export function removeOrphanedSource(oldId: string): boolean { + const r = spawnSync("gbrain", ["sources", "remove", oldId, "--confirm-destructive"], { + encoding: "utf-8", + timeout: 30_000, + stdio: ["ignore", "pipe", "pipe"], + }); + return r.status === 0; +} + /** * Build a gbrain-valid source id (1-32 lowercase alnum + interior hyphens). Sanitizes * `raw`, prefixes with `prefix`, and falls back to a hashed-tail form when total length @@ -365,7 +536,7 @@ async function runCodeImport(args: CliArgs): Promise { return skipStageForLocalStatus("code", localStatus, t0); } - // Step 0: Best-effort cleanup of pre-pathhash legacy source. + // Step 0a: Best-effort cleanup of pre-pathhash legacy source (v1.x form). // Earlier /sync-gbrain versions registered `gstack-code-` (no path // suffix). On a multi-worktree repo, those collapsed onto a single id // with last-sync-wins. Federated search would return stale duplicate @@ -385,6 +556,24 @@ async function runCodeImport(args: CliArgs): Promise { if (rm.status === 0) legacyRemoved = true; } + // Step 0b: Hostname-fold migration (#1414). + // Before #1468 the source id hashed only the absolute repo path. After the + // hostname fold, every existing user has a legacy id that no longer matches + // what deriveCodeSourceId produces. Try rename-in-place first (preserves + // pages); fall back to register-new → sync-OK → remove-old. Path-drift + // (user moved the repo, etc.) skips migration with a warning. + const pathOnlyHashLegacyId = derivePathOnlyHashLegacyId(root); + const migration = planHostnameFoldMigration(root, sourceId, pathOnlyHashLegacyId); + if (migration.kind === "skipped-path-drift" && !args.quiet) { + console.error( + `[sync:code] hostname-fold migration skipped: legacy source ${migration.oldId} ` + + `points at ${migration.oldPath}, current repo is ${migration.currentPath}. ` + + `Clean up manually with: gbrain sources remove ${migration.oldId} --confirm-destructive`, + ); + } else if (migration.kind === "renamed" && !args.quiet) { + console.error(`[sync:code] hostname-fold migration: renamed ${migration.oldId} → ${migration.newId} (pages preserved)`); + } + // Step 1: Ensure source registered (idempotent). Single source of truth in lib — // no synchronous duplicate here (per /codex review #12). let registered = false; @@ -439,7 +628,26 @@ async function runCodeImport(args: CliArgs): Promise { stdio: ["ignore", "pipe", "pipe"], }); const pageCount = sourcePageCount(sourceId); - const legacyNote = legacyRemoved ? `, removed legacy ${legacyId}` : ""; + + // Step 4: Deferred hostname-fold cleanup. + // Only remove the pre-#1468 path-only-hash source NOW that the new source + // has registered + synced + has pages. Removing before sync would create a + // data-loss window if sync failed; removing without a page-count check would + // wipe pages when sync silently no-op'd. This is the codex-review-flagged + // safety: register → sync → verify → THEN delete. + let hostnameLegacyRemoved = false; + if (migration.kind === "pending-cleanup" && pageCount !== null && pageCount > 0) { + hostnameLegacyRemoved = removeOrphanedSource(migration.oldId); + if (hostnameLegacyRemoved && !args.quiet) { + console.error(`[sync:code] hostname-fold migration: removed legacy ${migration.oldId} after new source sync verified (page_count=${pageCount})`); + } + } + + const legacyParts: string[] = []; + if (legacyRemoved) legacyParts.push(`removed legacy ${legacyId}`); + if (migration.kind === "renamed") legacyParts.push(`renamed ${migration.oldId}→${migration.newId}`); + if (hostnameLegacyRemoved) legacyParts.push(`removed pre-hostname-fold ${migration.kind === "pending-cleanup" ? migration.oldId : ""}`); + const legacyNote = legacyParts.length > 0 ? `, ${legacyParts.join(", ")}` : ""; const baseSummary = `${registered ? "registered + " : ""}synced ${sourceId} (page_count=${pageCount ?? "unknown"}${legacyNote})`; if (attach.status !== 0) { @@ -675,8 +883,10 @@ async function main(): Promise { process.exit(exitCode); } -main().catch((err) => { - console.error(`gstack-gbrain-sync fatal: ${err instanceof Error ? err.message : String(err)}`); - releaseLock(); - process.exit(1); -}); +if (import.meta.main) { + main().catch((err) => { + console.error(`gstack-gbrain-sync fatal: ${err instanceof Error ? err.message : String(err)}`); + releaseLock(); + process.exit(1); + }); +} diff --git a/test/gstack-gbrain-sync.test.ts b/test/gstack-gbrain-sync.test.ts index 528d6deed..74b0e2e8f 100644 --- a/test/gstack-gbrain-sync.test.ts +++ b/test/gstack-gbrain-sync.test.ts @@ -7,12 +7,19 @@ * preview + state file lifecycle + flag composition. */ -import { describe, it, expect } from "bun:test"; -import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync } from "fs"; +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, chmodSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { spawnSync } from "child_process"; +import { + derivePathOnlyHashLegacyId, + planHostnameFoldMigration, + sourceLocalPath, + _resetGbrainSupportsRenameCache, +} from "../bin/gstack-gbrain-sync"; + const SCRIPT = join(import.meta.dir, "..", "bin", "gstack-gbrain-sync.ts"); function makeTestHome(): string { @@ -215,6 +222,62 @@ describe("gstack-gbrain-sync CLI", () => { rmSync(home, { recursive: true, force: true }); }); + it("derives distinct source ids for the same absolute path on different hosts", () => { + // Issue #1414: two machines with identical home-dir layouts (chezmoi-managed + // dotfiles, ansible-provisioned VMs) collide on the same source id when + // federated against a shared gbrain DB, because the pre-fix `pathHash` was + // sha1(absolute path) only — host-agnostic. Folding hostname into the hash + // key keeps them distinct. `GSTACK_HOSTNAME` env var is the test-only knob; + // production uses `os.hostname()`. + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + mkdirSync(gstackHome, { recursive: true }); + const repo = mkdtempSync(join(tmpdir(), "gstack-host-collide-")); + spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo }); + spawnSync("git", ["remote", "add", "origin", "https://github.com/example/multihost.git"], { cwd: repo }); + + // Dry-run still gates the code stage on `command -v gbrain`. Drop a no-op + // shim on PATH so the stage runs (we only assert the preview line, never + // invoke gbrain itself). + const bindir = mkdtempSync(join(tmpdir(), "gstack-host-collide-bin-")); + const shim = join(bindir, "gbrain"); + writeFileSync(shim, "#!/bin/sh\nexit 0\n"); + chmodSync(shim, 0o755); + const PATH = `${bindir}:${process.env.PATH || ""}`; + + const runAs = (host: string) => + spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], { + encoding: "utf-8", + timeout: 60000, + cwd: repo, + env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome, GSTACK_HOSTNAME: host, PATH }, + }); + + const a = runAs("machine-a"); + const b = runAs("machine-b"); + expect(a.status).toBe(0); + expect(b.status).toBe(0); + const idA = (a.stdout || "").match(/gbrain sources add (\S+)/)?.[1]; + const idB = (b.stdout || "").match(/gbrain sources add (\S+)/)?.[1]; + expect(idA).toBeTruthy(); + expect(idB).toBeTruthy(); + expect(idA).not.toBe(idB); + // Both still gbrain-valid. + const VALID_ID = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/; + expect(idA!).toMatch(VALID_ID); + expect(idB!).toMatch(VALID_ID); + + // Same host + same path stays stable across invocations. + const a2 = runAs("machine-a"); + expect(a2.status).toBe(0); + const idA2 = (a2.stdout || "").match(/gbrain sources add (\S+)/)?.[1]; + expect(idA2).toBe(idA); + + rmSync(repo, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + rmSync(bindir, { recursive: true, force: true }); + }); + it("dry-run does NOT acquire the lock file (lock is for write paths only)", () => { const home = makeTestHome(); const gstackHome = join(home, ".gstack"); @@ -476,3 +539,227 @@ describe("gstack-gbrain-sync CLI", () => { rmSync(home, { recursive: true, force: true }); }); }); + +// ────────────────────────────────────────────────────────────────────────── +// Hostname-fold migration (v1.40.0.0) +// +// Tests for `derivePathOnlyHashLegacyId` and `planHostnameFoldMigration`, +// which together let an existing user's pre-#1468 path-only-hash source +// transition to the new hostname-folded id without orphaning pages or +// creating a data-loss window. See bin/gstack-gbrain-sync.ts and the +// gbrain-sync-hardening plan. +// ────────────────────────────────────────────────────────────────────────── + +/** + * Build a gbrain shim that responds to specific subcommands with canned + * output, then return PATH-prepend value. Lets us run helpers in-process + * (which spawn `gbrain` from PATH) without a real gbrain CLI. + */ +function makeShim(bindir: string, responses: Record): string { + const shim = join(bindir, "gbrain"); + const cases = Object.entries(responses).map(([key, r]) => { + const exit = r.exit ?? 0; + const stdout = (r.stdout || "").replace(/'/g, "'\\''"); + const stderr = (r.stderr || "").replace(/'/g, "'\\''"); + // Patterns with spaces MUST be double-quoted in sh case statements, + // otherwise the shell parses the second word as the start of the next + // pattern and errors out. + return ` "${key}") printf '%s' '${stdout}'; printf '%s' '${stderr}' >&2; exit ${exit} ;;`; + }).join("\n"); + // Match on the full argument string, joined with literal spaces. + const script = `#!/bin/sh\nARGS="$*"\ncase "$ARGS" in\n${cases}\n *) echo "shim: no match for [$ARGS]" >&2; exit 1 ;;\nesac\n`; + writeFileSync(shim, script); + chmodSync(shim, 0o755); + return shim; +} + +describe("derivePathOnlyHashLegacyId", () => { + it("returns the pre-#1468 form (path-only sha1, no hostname)", () => { + // Pure function — no subprocess. The same repoPath must yield the same + // legacy id regardless of $GSTACK_HOSTNAME, because the pre-#1468 hash + // didn't include hostname. + const repo = mkdtempSync(join(tmpdir(), "gstack-legacy-id-")); + spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo }); + spawnSync("git", ["remote", "add", "origin", "https://github.com/example/legacy-test.git"], { cwd: repo }); + + const cwd = process.cwd(); + try { + process.chdir(repo); + const a = derivePathOnlyHashLegacyId(repo); + process.env.GSTACK_HOSTNAME = "machine-a"; + const b = derivePathOnlyHashLegacyId(repo); + process.env.GSTACK_HOSTNAME = "machine-b"; + const c = derivePathOnlyHashLegacyId(repo); + expect(a).toBe(b); + expect(b).toBe(c); + expect(a.startsWith("gstack-code-")).toBe(true); + expect(a.length).toBeLessThanOrEqual(32); + } finally { + delete process.env.GSTACK_HOSTNAME; + process.chdir(cwd); + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("produces a different id than the new hostname-folded form", () => { + // The whole point of the migration: the path-only-hash legacy id and the + // host-fold id must differ for any non-empty hostname, so the migration + // can detect + clean up the orphan. + const repo = mkdtempSync(join(tmpdir(), "gstack-legacy-id-distinct-")); + spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo }); + spawnSync("git", ["remote", "add", "origin", "https://github.com/example/distinct.git"], { cwd: repo }); + + const cwd = process.cwd(); + try { + process.chdir(repo); + process.env.GSTACK_HOSTNAME = "machine-x"; + const legacy = derivePathOnlyHashLegacyId(repo); + // Drive the new id through the CLI so we use the same code path users hit. + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + mkdirSync(gstackHome, { recursive: true }); + const bindir = mkdtempSync(join(tmpdir(), "gstack-legacy-id-distinct-bin-")); + makeShim(bindir, { "--help": { stdout: "gbrain\n" } }); + const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], { + encoding: "utf-8", + timeout: 60000, + cwd: repo, + env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome, GSTACK_HOSTNAME: "machine-x", PATH: `${bindir}:${process.env.PATH || ""}` }, + }); + const newId = (r.stdout || "").match(/gbrain sources add (\S+)/)?.[1]; + expect(newId).toBeTruthy(); + expect(newId).not.toBe(legacy); + rmSync(home, { recursive: true, force: true }); + rmSync(bindir, { recursive: true, force: true }); + } finally { + delete process.env.GSTACK_HOSTNAME; + process.chdir(cwd); + rmSync(repo, { recursive: true, force: true }); + } + }); +}); + +/** + * Build an env dict that prepends `bindir` to PATH. Bun's spawnSync does NOT + * pick up runtime mutations of `process.env.PATH` — the env must be passed + * explicitly to each spawn for the override to take effect. + */ +function envWithBindir(bindir: string): NodeJS.ProcessEnv { + return { ...process.env, PATH: `${bindir}:${process.env.PATH || ""}` }; +} + +describe("planHostnameFoldMigration", () => { + let bindir: string; + + beforeEach(() => { + bindir = mkdtempSync(join(tmpdir(), "gstack-mig-plan-bin-")); + _resetGbrainSupportsRenameCache(); + }); + afterEach(() => { + rmSync(bindir, { recursive: true, force: true }); + _resetGbrainSupportsRenameCache(); + }); + + it("returns ids-match when legacy == new (degenerate case)", () => { + const result = planHostnameFoldMigration("/repo/path", "gstack-code-same-abc12345", "gstack-code-same-abc12345"); + expect(result).toEqual({ kind: "none", reason: "ids-match" }); + }); + + it("returns no-legacy-source when sources list does not include the legacy id", () => { + makeShim(bindir, { + "sources list --json": { stdout: "[]" }, + }); + const result = planHostnameFoldMigration("/repo/path", "new-id", "legacy-id", envWithBindir(bindir)); + expect(result).toEqual({ kind: "none", reason: "no-legacy-source" }); + }); + + it("returns skipped-path-drift when old source local_path differs from current repo root", () => { + makeShim(bindir, { + "sources list --json": { + stdout: JSON.stringify([{ id: "legacy-id", local_path: "/some/other/repo" }]), + }, + }); + const result = planHostnameFoldMigration("/repo/here", "new-id", "legacy-id", envWithBindir(bindir)); + expect(result.kind).toBe("skipped-path-drift"); + if (result.kind === "skipped-path-drift") { + expect(result.oldId).toBe("legacy-id"); + expect(result.oldPath).toBe("/some/other/repo"); + expect(result.currentPath).toBe("/repo/here"); + } + }); + + it("returns renamed when rename is supported and exits 0", () => { + makeShim(bindir, { + "sources list --json": { + stdout: JSON.stringify([{ id: "legacy-id", local_path: "/repo/here" }]), + }, + "sources rename --help": { + stdout: "Usage: gbrain sources rename \n", + }, + "sources rename legacy-id new-id": { exit: 0 }, + }); + const result = planHostnameFoldMigration("/repo/here", "new-id", "legacy-id", envWithBindir(bindir)); + expect(result).toEqual({ kind: "renamed", oldId: "legacy-id", newId: "new-id" }); + }); + + it("returns pending-cleanup when rename is unsupported (current gbrain 0.35.0.0)", () => { + makeShim(bindir, { + "sources list --json": { + stdout: JSON.stringify([{ id: "legacy-id", local_path: "/repo/here" }]), + }, + // No `sources rename --help` match → shim falls into the catch-all and exits 1. + }); + const result = planHostnameFoldMigration("/repo/here", "new-id", "legacy-id", envWithBindir(bindir)); + expect(result).toEqual({ kind: "pending-cleanup", oldId: "legacy-id" }); + }); + + it("returns pending-cleanup when rename is supported but the rename call itself fails", () => { + makeShim(bindir, { + "sources list --json": { + stdout: JSON.stringify([{ id: "legacy-id", local_path: "/repo/here" }]), + }, + "sources rename --help": { + stdout: "Usage: gbrain sources rename \n", + }, + "sources rename legacy-id new-id": { exit: 1, stderr: "rename failed: db locked" }, + }); + const result = planHostnameFoldMigration("/repo/here", "new-id", "legacy-id", envWithBindir(bindir)); + expect(result).toEqual({ kind: "pending-cleanup", oldId: "legacy-id" }); + }); +}); + +describe("sourceLocalPath", () => { + let bindir: string; + beforeEach(() => { + bindir = mkdtempSync(join(tmpdir(), "gstack-source-lp-bin-")); + }); + afterEach(() => { + rmSync(bindir, { recursive: true, force: true }); + }); + + it("returns local_path when the source exists", () => { + makeShim(bindir, { + "sources list --json": { + stdout: JSON.stringify([ + { id: "other-source", local_path: "/x" }, + { id: "target-id", local_path: "/repo/match" }, + ]), + }, + }); + expect(sourceLocalPath("target-id", envWithBindir(bindir))).toBe("/repo/match"); + }); + + it("returns null when the source is missing", () => { + makeShim(bindir, { + "sources list --json": { stdout: "[]" }, + }); + expect(sourceLocalPath("missing-id", envWithBindir(bindir))).toBeNull(); + }); + + it("returns null when gbrain exits non-zero or returns malformed JSON", () => { + makeShim(bindir, { + "sources list --json": { exit: 2, stderr: "db unreachable" }, + }); + expect(sourceLocalPath("any-id", envWithBindir(bindir))).toBeNull(); + }); +});