diff --git a/commands/__tests__/cd.test.ts b/commands/__tests__/cd.test.ts new file mode 100644 index 00000000..67496d96 --- /dev/null +++ b/commands/__tests__/cd.test.ts @@ -0,0 +1,134 @@ +/** + * The `--repo --worktree ` combo picks a repo via its own inline + * picker (not lib/pickers.ts's pickFromAllRepos), so a `missing: true` row + * needs its own guard: refuse via missingRepoRefusal before ever falling + * into branch resolution against a dead path. + */ + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { closeStateDb, setKvValue } from "../../lib/state/index.ts"; +import { worktreePicker } from "../cd.ts"; + +// Satisfies ensureShellFunction()'s early-return check so worktreePicker +// never reaches the interactive "install rt cd?" prompt. +const UP_TO_DATE_RC = 'rt() {\n whence -p rt\n "$rt_bin" nav\n}\n'; + +describe("rt cd --repo --worktree with a missing repo", () => { + const origHome = process.env.HOME; + const origShell = process.env.SHELL; + const origCwd = process.cwd(); + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-cd-missing-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-cd-missing-repos-"))); + process.env.HOME = home; + process.env.SHELL = "/bin/zsh"; + writeFileSync(join(home, ".zshrc"), UP_TO_DATE_RC); + closeStateDb(); + // cwd must NOT be a git repo: getRepoIdentity()'s real getRepoRoot() runs + // "git rev-parse --show-toplevel" against process.cwd() with no override, + // and if it found one it would auto-register it into this isolated + // index (updateRepoIndex's side effect), giving repoChoices a second, + // live entry and forcing the interactive picker instead of the + // single-entry auto-select this test exercises. + process.chdir(scratch); + }); + + afterEach(() => { + process.chdir(origCwd); + process.env.HOME = origHome; + process.env.SHELL = origShell; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + test("refuses with missingRepoRefusal instead of falling into branch resolution", async () => { + setKvValue("repo-index", "moved", join(scratch, "gone-away")); + + const originalStdoutWrite = process.stdout.write; + const chdirSpy = spyOn(process, "chdir").mockImplementation(() => {}); + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit sentinel"); + }); + + try { + await expect(worktreePicker(["--repo", "--worktree", "anybranch"])).rejects.toThrow( + "process.exit sentinel", + ); + expect(chdirSpy).not.toHaveBeenCalled(); + expect(exitSpy.mock.calls.at(-1)?.[0]).toBe(1); + expect(errSpy.mock.calls.flat().join(" ")).toContain("rt repos locate"); + } finally { + process.stdout.write = originalStdoutWrite; + chdirSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); +}); + +/** + * The plain `rt cd` picker (no --repo/--worktree flags) reaches + * pickFromAllRepos through commands/cd.ts's own `getKnownRepos()` call — a + * bare call there excludes missing rows, so a lost repo would silently vanish + * from the picker instead of hitting the missingRepoRefusal guard + * pickFromAllRepos already carries. + */ +describe("rt cd default picker with a missing repo", () => { + const origHome = process.env.HOME; + const origShell = process.env.SHELL; + const origCwd = process.cwd(); + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-cd-default-missing-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-cd-default-missing-repos-"))); + process.env.HOME = home; + process.env.SHELL = "/bin/zsh"; + writeFileSync(join(home, ".zshrc"), UP_TO_DATE_RC); + closeStateDb(); + // Not a git repo — see the comment in the describe block above for why + // this matters (getRepoIdentity() must not auto-register process.cwd()). + process.chdir(scratch); + }); + + afterEach(() => { + process.chdir(origCwd); + process.env.HOME = origHome; + process.env.SHELL = origShell; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + test("the only known repo being missing refuses instead of silently dropping it from the picker", async () => { + setKvValue("repo-index", "moved", join(scratch, "gone-away")); + + const originalStdoutWrite = process.stdout.write; + const chdirSpy = spyOn(process, "chdir").mockImplementation(() => {}); + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit sentinel"); + }); + + try { + await expect(worktreePicker([])).rejects.toThrow("process.exit sentinel"); + expect(chdirSpy).not.toHaveBeenCalled(); + expect(exitSpy.mock.calls.at(-1)?.[0]).toBe(1); + expect(errSpy.mock.calls.flat().join(" ")).toContain("rt repos locate"); + } finally { + process.stdout.write = originalStdoutWrite; + chdirSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); +}); diff --git a/commands/__tests__/repos-locate.test.ts b/commands/__tests__/repos-locate.test.ts new file mode 100644 index 00000000..5c963d4a --- /dev/null +++ b/commands/__tests__/repos-locate.test.ts @@ -0,0 +1,165 @@ +/** + * The CLI takes its local branch here. `daemonPresent()` reads + * `DAEMON_PID_PATH`/`DAEMON_SOCK_PATH`, which are module-load constants bound + * to the throwaway HOME the bunfig preload (test-setup.ts) sets before any + * module loads — not to the per-test HOME below. That tree holds neither a pid + * file nor a socket, so the apply happens in-process, which is exactly the + * "no daemon, nothing to race" branch. + */ + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { execSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { closeStateDb, setKvValue } from "../../lib/state/index.ts"; +import { loadRepoIndex } from "../../lib/repo-index.ts"; +import { saveRegistry, loadRegistry } from "../../lib/worktree/registry.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../../lib/settings/identity.ts"; +import { reposLocate, type RegisterDeps } from "../repos.ts"; + +function testDeps(): RegisterDeps & { lines: string[] } { + const lines: string[] = []; + return { print: (s) => lines.push(s), lines }; +} + +async function runExpectingProcessExit(fn: () => Promise): Promise { + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit sentinel"); + }); + try { + await fn(); + return undefined; + } catch { + return exitSpy.mock.calls.at(-1)?.[0] as number | undefined; + } finally { + exitSpy.mockRestore(); + } +} + +describe("reposLocate", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-cli-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-cli-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + async function movedRepo(name: string): Promise<{ identity: string; from: string; to: string }> { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync(`git remote add origin https://gitlab.com/g/${name}.git`, { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + saveRegistry(identity, [{ name: "main", path: from, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }]); + const to = join(scratch, `${name}-moved`); + renameSync(from, to); + return { identity, from, to }; + } + + test("locates a moved repo and says where it went", async () => { + const { identity, from, to } = await movedRepo("alpha"); + const deps = testDeps(); + + await reposLocate([to], {}, deps); + + expect(loadRepoIndex()[identity]).toBe(to); + expect(loadRegistry(identity)[0]?.path).toBe(to); + expect(deps.lines.join("\n")).toContain(from); + expect(deps.lines.join("\n")).toContain(to); + }); + + test("--dry-run reports the plan and writes nothing", async () => { + const { identity, from, to } = await movedRepo("beta"); + const deps = testDeps(); + + await reposLocate([to, "--dry-run"], {}, deps); + + expect(loadRepoIndex()[identity]).toBe(from); + expect(deps.lines.join("\n")).toContain("would move"); + }); + + test("--json emits a contract envelope", async () => { + const { identity, to } = await movedRepo("gamma"); + const deps = testDeps(); + + await reposLocate([to, "--json"], {}, deps); + + const parsed = JSON.parse(deps.lines[0]!); + expect(parsed.contract).toBe(1); + expect(parsed.located.identity).toBe(identity); + expect(parsed.located.to).toBe(to); + }); + + test("--repo resolves to an identity and is honoured", async () => { + const { identity, to } = await movedRepo("delta"); + const deps = testDeps(); + + await reposLocate([to, "--repo", identity], {}, deps); + + expect(loadRepoIndex()[identity]).toBe(to); + }); + + test("a refusal exits 2 with the typed message", async () => { + const plain = join(scratch, "plain"); + mkdirSync(plain); + const deps = testDeps(); + + const code = await runExpectingProcessExit(() => reposLocate([plain], {}, deps)); + + expect(code).toBe(2); + expect(deps.lines.join("\n")).toContain("not a git repository"); + }); + + test("an unknown flag is a usage error", async () => { + const deps = testDeps(); + const code = await runExpectingProcessExit(() => reposLocate(["--nope"], {}, deps)); + expect(code).toBe(2); + expect(deps.lines.join("\n")).toContain("usage: rt repos locate"); + }); + + test("--repo without a value is a usage error", async () => { + const deps = testDeps(); + const code = await runExpectingProcessExit(() => reposLocate(["--repo"], {}, deps)); + expect(code).toBe(2); + expect(deps.lines.join("\n")).toContain("--repo needs a value"); + }); + + test("a second positional is a usage error, not a silently ignored path", async () => { + const deps = testDeps(); + const code = await runExpectingProcessExit(() => reposLocate([scratch, join(scratch, "other")], {}, deps)); + expect(code).toBe(2); + expect(deps.lines.join("\n")).toContain("locate takes one path"); + }); + + test("no path and no lost rows exits 1 saying so", async () => { + const deps = testDeps(); + const code = await runExpectingProcessExit(() => reposLocate([], {}, deps)); + expect(code).toBe(1); + expect(deps.lines.join("\n")).toContain("no indexed repo is missing"); + }); + + test("no path, a lost row and no candidate lists the lost row and exits 1", async () => { + setKvValue("repo-index", "remote:gitlab.com%2Fg%2Fghost", join(scratch, "ghost")); + const deps = testDeps(); + + const code = await runExpectingProcessExit(() => reposLocate([], {}, deps)); + + expect(code).toBe(1); + expect(deps.lines.join("\n")).toContain("remote:gitlab.com%2Fg%2Fghost"); + }); +}); diff --git a/commands/__tests__/repos.test.ts b/commands/__tests__/repos.test.ts index 7e3ff1c8..a280e3cf 100644 --- a/commands/__tests__/repos.test.ts +++ b/commands/__tests__/repos.test.ts @@ -5,8 +5,9 @@ import { homedir, tmpdir } from "node:os"; import { basename, join } from "node:path"; import { getKnownRepos, loadRepoIndex, loadRepoIndexEntries, updateRepoIndex } from "../../lib/repo-index.ts"; import { loadRepoTracking } from "../../lib/repo-tracking.ts"; +import { saveRegistry } from "../../lib/worktree/registry.ts"; import { deriveRepoIdentity, serializeIdentity } from "../../lib/settings/identity.ts"; -import { closeStateDb } from "../../lib/state/index.ts"; +import { closeStateDb, setKvValue } from "../../lib/state/index.ts"; import { reposPrune, reposRegister, type RegisterDeps } from "../repos.ts"; function testDeps(): RegisterDeps & { lines: string[] } { @@ -119,6 +120,49 @@ describe("reposRegister", () => { expect(body).toEqual({ contract: 1, registered: [{ name, path: repoPath, tracking: null }] }); }); + test("a repo whose move cannot be applied exits 2 instead of reporting it registered", async () => { + const repoPath = makeTempRepo(); + execSync("git remote add origin git@gitlab.com:group/moved.git", { cwd: repoPath, stdio: "pipe" }); + const identity = serializeIdentity(await deriveRepoIdentity(repoPath)); + const gone = join(home, "gone-away"); + setKvValue("repo-index", identity, gone); + // A registry record whose re-rooted spelling is occupied by something git + // does not list as a worktree — the apply refuses at verification, so + // nothing is written and the row keeps naming the gone path. + saveRegistry(identity, [ + { name: "main", path: gone, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }, + { name: "t1", path: join(gone, ".worktrees", "t1"), kind: "ephemeral", state: "on-deck", branch: "feat", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + mkdirSync(join(repoPath, ".worktrees", "t1"), { recursive: true }); + const deps = testDeps(); + + const code = await runExpectingProcessExit(() => reposRegister([repoPath], {}, deps)); + + expect(code).toBe(2); + expect(deps.lines.some((l) => l.includes("registered"))).toBe(false); + expect(loadRepoIndex()[identity]).toBe(gone); + }); + + test("--json reports a failed move as an error envelope, never a registered one", async () => { + const repoPath = makeTempRepo(); + execSync("git remote add origin git@gitlab.com:group/moved-json.git", { cwd: repoPath, stdio: "pipe" }); + const identity = serializeIdentity(await deriveRepoIdentity(repoPath)); + const gone = join(home, "gone-away-json"); + setKvValue("repo-index", identity, gone); + saveRegistry(identity, [ + { name: "t1", path: join(gone, ".worktrees", "t1"), kind: "ephemeral", state: "on-deck", branch: "feat", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + mkdirSync(join(repoPath, ".worktrees", "t1"), { recursive: true }); + const deps = testDeps(); + + await runExpectingProcessExit(() => reposRegister([repoPath, "--json"], {}, deps)); + + expect(deps.lines).toHaveLength(1); + const body = JSON.parse(deps.lines[0]!); + expect(body.error?.code).toBe("locate-failed"); + expect(body.registered).toBeUndefined(); + }); + test("no paths exits 2 with a usage error", async () => { const deps = testDeps(); const code = await runExpectingProcessExit(() => reposRegister([], {}, deps)); @@ -250,4 +294,19 @@ describe("reposPrune", () => { expect(code).toBe(2); expect(loadRepoIndexEntries().map((e) => e.repoName)).toEqual(["gone"]); }); + + test("a retained missing row tells the operator to locate it", async () => { + const { setKvValue } = await import("../../lib/state/index.ts"); + updateRepoIndex("moved-repo", join(home, "gone-away")); + setKvValue("worktree-registry", "moved-repo", [ + { name: "t1", path: join(home, "gone-away", ".worktrees", "t1"), kind: "ephemeral", state: "on-deck", branch: "on-deck/t1", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + const deps = testDeps(); + + await reposPrune([], {}, deps); + + expect(deps.lines.join("\n")).toContain("kept moved-repo"); + expect(deps.lines.join("\n")).toContain("rt repos locate"); + expect(loadRepoIndex()["moved-repo"]).toBeDefined(); + }); }); diff --git a/commands/cd.ts b/commands/cd.ts index 88299aa1..0cab0fe8 100644 --- a/commands/cd.ts +++ b/commands/cd.ts @@ -22,7 +22,7 @@ import { readFileSync, writeFileSync, appendFileSync } from "fs"; import { join } from "path"; import { homedir } from "os"; import { yellow, green, reset } from "../lib/tui.ts"; -import { getRepoIdentity, getKnownRepos, getWorkspacePackages, repoOptions, type KnownRepo } from "../lib/repo.ts"; +import { getRepoIdentity, getKnownRepos, getWorkspacePackages, repoOptions, repoFromOptionValue, missingRepoRefusal, type KnownRepo } from "../lib/repo.ts"; import { pickWorktreeWithSwitch, pickFromAllRepos, @@ -196,8 +196,12 @@ export async function worktreePicker(args: string[]): Promise { // the first time — is absent from `repos`, currentRepo resolves to null, and // rt cd wrongly falls through to the global all-repos picker instead of // recognizing where you are. + // + // includeMissing: true so a lost repo still renders (dimmed, via repoOption) + // in every picker built from `repos` — pickFromAllRepos's missing guard is + // otherwise dead code, since a bare getKnownRepos() never hands it one. const identity = getRepoIdentity(); - const repos = getKnownRepos(); + const repos = getKnownRepos({ includeMissing: true }); const currentRepo = identity ? repos.find((r) => r.repoName === identity.repoName) ?? null : null; @@ -207,14 +211,20 @@ export async function worktreePicker(args: string[]): Promise { // ── --repo flag: always go to repo picker ──────────────────────────────────── if (forceRepo) { if (wtBranch) { - // Pick repo first, then jump to the matching worktree (or show picker) + // Pick repo first, then jump to the matching worktree (or show picker). + // A missing row must be pickable here so it gets the clean + // missingRepoRefusal below instead of resolving via branch name against + // a dead path. const { filterableSelect } = await import("../lib/rt-render.tsx"); - const options = repoOptions(repos); const pickedRepoName = repos.length === 1 ? repos[0]!.repoName - : await filterableSelect({ message: "Pick a repo", options, stderr: true }); + : await filterableSelect({ message: "Pick a repo", options: repoOptions(repos), stderr: true }); if (!pickedRepoName) process.exit(0); // Esc on repo picker - const pickedRepo = repos.find((r) => r.repoName === pickedRepoName)!; + const pickedRepo = repoFromOptionValue(repos, pickedRepoName)!; + if (pickedRepo.missing) { + console.error(`\n ${missingRepoRefusal(pickedRepo)}\n`); + process.exit(1); + } // Try to resolve the worktree in that repo; fall back to picker const lower = wtBranch.toLowerCase(); diff --git a/commands/repos.ts b/commands/repos.ts index 1ace8033..f57c54e4 100644 --- a/commands/repos.ts +++ b/commands/repos.ts @@ -17,11 +17,14 @@ import { realpathSync } from "fs"; import { homedir } from "os"; import { basename } from "path"; import type { CommandContext } from "../lib/command-tree.ts"; -import { pruneRepoIndex, updateRepoIndex, type PrunedEntry } from "../lib/repo-index.ts"; +import { getKnownRepos, pruneRepoIndex, updateRepoIndexAsync, type PrunedEntry } from "../lib/repo-index.ts"; import { deriveRepoIdentity, serializeIdentity } from "../lib/settings/identity.ts"; import { CACHE_KINDS, loadMachineRepoTrackingRaw, parseCachesArg, saveRepoTrackingRaw, type CacheKind, type TrackingMode } from "../lib/repo-tracking.ts"; import { envelope } from "../lib/setup/contract.ts"; import { UserActionableError, exitUserError } from "../lib/setup/errors.ts"; +import { findLocateCandidates } from "../lib/repo-locate.ts"; +import { locateMovedRepo } from "../lib/repo-locate-dispatch.ts"; +import { resolveRepoArg } from "../lib/repo-arg.ts"; export interface RegisterDeps { print: (s: string) => void; @@ -123,7 +126,21 @@ export async function reposRegister(args: string[], _ctx: CommandContext = {}, d const rawTracking = track ? loadMachineRepoTrackingRaw() : null; for (const { name, real, identity } of resolved) { - updateRepoIndex(identity, real); + // A refused move leaves the row naming the gone path, so printing + // "registered" (or a JSON ok envelope) here would tell a script the repo + // is indexed at `real` when nothing points there. + const indexed = await updateRepoIndexAsync(identity, real); + if (!indexed.ok) { + exitUserError( + new UserActionableError( + "locate-failed", + `"${name}" is indexed at a path that no longer exists, and moving it to ${real} failed — ${indexed.error}`, + ), + json, + "repos register", + deps.print, + ); + } let tracking: Registered["tracking"] = null; if (track && caches && rawTracking) { @@ -169,7 +186,8 @@ function describeDataMove(r: PrunedEntry, dryRun: boolean): string { if (carried > 0) parts.push(`${dryRun ? "would carry" : "carried"} ${carried} file${carried === 1 ? "" : "s"} to ${r.keptAs}`); if (d.merged.length > 0) parts.push(`merged ${d.merged.join(", ")}`); if (d.registry === "moved") parts.push(`${dryRun ? "would move" : "moved"} the worktree registry to ${r.keptAs}`); - if (d.registry === "refused") parts.push(`${r.keptAs} already has a worktree registry — both kept`); + if (d.registry === "merged") parts.push(`${dryRun ? "would merge" : "merged"} the worktree registry into ${r.keptAs}'s`); + if (d.registry === "refused") parts.push(`${r.keptAs}'s worktree registry could not be written — both kept`); if (d.refused.length > 0) parts.push(`kept both copies of ${d.refused.join(", ")}`); return parts.length > 0 ? `; ${parts.join("; ")}` : ""; } @@ -207,8 +225,144 @@ export async function reposPrune(args: string[], _ctx: CommandContext = {}, deps for (const r of removed) { const verb = r.retained ? "kept" : dryRun ? "would remove" : "removed"; const why = r.retained - ? `${describeReason(r)}, but its data could not all move${describeDataMove(r, dryRun)} — keeping the row so nothing is orphaned` + ? r.reason === "missing" + ? `${describeReason(r)} but it still owns a worktree registry — keeping the row; run: ${r.hint} --repo ${r.repoName}` + : `${describeReason(r)}, but its data could not all move${describeDataMove(r, dryRun)} — keeping the row so nothing is orphaned` : `${describeReason(r)}${describeDataMove(r, dryRun)}`; deps.print(`${verb} ${r.repoName} (${r.path.replace(homedir(), "~")}) — ${why}`); } } + +// ─── locate ────────────────────────────────────────────────────────────────── + +const LOCATE_USAGE = "usage: rt repos locate [] [--repo ] [--dry-run] [--json]"; +const LOCATE_FLAGS = ["--json", "--dry-run", "--repo"]; + +/** Every non-flag token that is not `--repo`'s value. */ +function locatePositionals(args: string[]): string[] { + const out: string[] = []; + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + if (a === "--repo") { + i++; + continue; + } + if (a.startsWith("--")) continue; + out.push(a); + } + return out; +} + +/** + * rt repos locate — tell rt where a repo moved to. + * + * A folder move keeps the repo identity but leaves every stored path stale. + * The daemon owns the apply whenever it answers; a local apply only happens + * when nothing is up to race. + */ +export async function reposLocate(args: string[], _ctx: CommandContext = {}, deps: RegisterDeps = realRegisterDeps()): Promise { + const json = args.includes("--json"); + const dryRun = args.includes("--dry-run"); + for (const a of args) { + if (a.startsWith("--") && !LOCATE_FLAGS.includes(a)) { + exitUserError(new UserActionableError("usage", `unknown flag "${a}" — ${LOCATE_USAGE}`), json, "repos locate", deps.print); + } + } + + const repoArg = flagValue(args, "--repo"); + if (args.includes("--repo") && (repoArg === undefined || repoArg.startsWith("--"))) { + exitUserError(new UserActionableError("usage", `--repo needs a value — ${LOCATE_USAGE}`), json, "repos locate", deps.print); + } + const repo = repoArg + ? await resolveRepoArg(repoArg, (msg) => + exitUserError(new UserActionableError("repo-unknown", msg), json, "repos locate", deps.print)) + : undefined; + + const positionals = locatePositionals(args); + if (positionals.length > 1) { + exitUserError( + new UserActionableError("usage", `locate takes one path, got ${positionals.length} (${positionals.join(", ")}) — ${LOCATE_USAGE}`), + json, + "repos locate", + deps.print, + ); + } + + const newPath = positionals[0] ?? (await pickLocateTarget(json, deps)); + + const outcome = await locateMovedRepo({ newPath, ...(repo ? { repo } : {}), dryRun }); + if (!outcome.ok) { + exitUserError(new UserActionableError("refused", outcome.error), json, "repos locate", deps.print); + } + + if (outcome.dryRun) { + const p = outcome.plan; + if (json) { + deps.print(JSON.stringify(envelope({ plan: p, dryRun: true }))); + return; + } + deps.print(`would move ${p.identity} from ${p.oldPath} to ${p.newPath}`); + deps.print(` index rows: ${p.indexKeys.join(", ")}`); + deps.print(` worktree records: ${p.registryRewrites.reduce((n, r) => n + r.movedPaths.length, 0)}`); + deps.print(` endpoint claims: ${p.claimRewrites.length}`); + deps.print(` git worktree repair: ${p.gitRepairPaths.length === 0 ? "(main worktree only)" : p.gitRepairPaths.join(", ")}`); + return; + } + + const r = outcome.result; + if (json) { + deps.print(JSON.stringify(envelope({ located: r, via: outcome.via }))); + return; + } + deps.print(`located ${r.identity}: ${r.from} → ${r.to}`); + deps.print(` ${r.treesRewritten} worktree record${r.treesRewritten === 1 ? "" : "s"}, ${r.claimsRewritten} endpoint claim${r.claimsRewritten === 1 ? "" : "s"}, ${r.repaired.length} tree${r.repaired.length === 1 ? "" : "s"} repaired`); + for (const stale of r.stalePaths) deps.print(` stale record kept for the reconciler to prune: ${stale}`); + for (const row of r.legacyRows) { + deps.print(row.outcome === "collapsed" + ? ` collapsed the legacy row ${row.key}` + : ` kept the legacy row ${row.key}, still naming ${r.from} — ${row.reason || "its data dir could not all move"}`); + } +} + +/** + * No ``: propose, never auto-pick. One candidate still asks; several + * open a picker; none is a hard stop that names what is lost. + */ +async function pickLocateTarget(json: boolean, deps: RegisterDeps): Promise { + const lost = getKnownRepos({ includeMissing: true }).filter((r) => r.missing); + if (lost.length === 0) { + deps.print(json ? JSON.stringify(envelope({ lost: [], candidates: [] })) : "no indexed repo is missing — nothing to locate"); + process.exit(1); + } + + const candidates = await findLocateCandidates(); + if (candidates.length === 0 || !process.stdin.isTTY) { + if (json) { + deps.print(JSON.stringify(envelope({ lost: lost.map((r) => ({ repo: r.repoName, path: r.worktrees[0]?.path })), candidates }))); + } else { + deps.print("missing repos:"); + for (const r of lost) deps.print(` ${r.repoName} — last seen at ${r.worktrees[0]?.path}`); + deps.print(candidates.length === 0 + ? `pass the new path: ${LOCATE_USAGE}` + : "run interactively to pick a candidate, or pass the new path"); + } + process.exit(1); + } + + if (candidates.length === 1) { + const only = candidates[0]!; + const { confirm } = await import("../lib/rt-render.tsx"); + const ok = await confirm({ message: `Locate ${only.identity} at ${only.path}?`, stderr: true }); + if (!ok) process.exit(0); + return only.path; + } + + const { filterableSelect } = await import("../lib/rt-render.tsx"); + const picked = await filterableSelect({ + message: "Which directory did it move to?", + options: candidates.map((c) => ({ value: c.path, label: c.path, hint: c.identity })), + stderr: true, + }); + if (!picked) process.exit(0); + return picked; +} diff --git a/docs/repo-identity.md b/docs/repo-identity.md index 7d5ae3f5..f82abf98 100644 --- a/docs/repo-identity.md +++ b/docs/repo-identity.md @@ -132,7 +132,7 @@ not a cosmetic one. Build handles from the label, slugified. ## The legacy world -Machines carry pre-cutover, name-keyed state until it is healed. Three +Machines carry pre-cutover, name-keyed state until it is healed. Four mechanisms, none of which callers should reimplement: - **Daemon boot migration** (`lib/daemon/boot-migrate.ts`): one-shot at every @@ -151,7 +151,43 @@ mechanisms, none of which callers should reimplement: row must survive for prune to collapse the pair. - **`rt repos prune`**: collapses the name/identity pairs the heal leaves behind. Until it runs, both rows point at the same directory, and - name-matching code that counts rows will see doubles. + name-matching code that counts rows will see doubles. The identity row + always wins the pair, whatever the timestamps say — the retired name's data + dir is carried onto it, and its worktree registry too: when BOTH names own + a registry they are **merged** (union by path; the managed record wins a + collision) rather than refused, and only then is the retired row evicted. A + row whose data could not all move is kept and reported, because eviction is + what makes a leftover unreachable — and so is a `missing` row that still + owns a registry, reported `retained` with the hint to run `rt repos locate` + instead. +- **`rt repos locate `**: the verb for a repo whose folder MOVED. + The identity survives a move, so this re-points paths and never re-keys: + every index row of the pair, both registries (re-rooted onto the new root, + external worktrees keeping their own paths, then merged onto the identity) + and the pair's `endpoint_claims` rows (merged onto the identity too, the + legacy key emptied) — one `state.db` transaction, matched by identity and + never by name, with the `repos.json` mirror rewritten as it commits. + - **Repair before commit.** `git worktree repair` and the + `git worktree list` verification run FIRST, while the index still names + the dead path: a reconcile pass that interleaves there finds a repo whose + path does not exist and bails, where a healed index over un-rewritten + registry paths would prune every claimed tree and replenish a fresh pool. + Nothing is written unless the whole move verifies, which is why there is + no rollback. A legacy row retained by a data-dir conflict is written back + to the OLD path for the same reason — a legacy row must never name a live + path without owning a registry. + - **The daemon is never stopped for a move.** It owns the registry, so the + CLI hands the work to the `repos:locate` verb whenever a daemon is + present — presence being a live pid file OR a socket on disk, not a ping, + since a stalled daemon still holds the registry. Present but unanswering + is a hard stop, never a local apply. The handler runs the whole apply + inside the reconciler's in-flight hold. + - **The sync seam cannot do this.** `updateRepoIndex` refuses to re-point a + row whose stored path is gone (the repair it owes is async git, forbidden + on the daemon thread): the row stays `missing` until a locate moves it as + one unit. `updateRepoIndexAsync` — what `rt repos register` calls — routes + that case through the same locate and surfaces a refusal instead of + reporting success over an unhealed index. What this means for a caller: an empty result for a repo that exists usually means its row hasn't been touched since the upgrade. Resolve through diff --git a/docs/superpowers/plans/2026-08-25-repo-locate.md b/docs/superpowers/plans/2026-08-25-repo-locate.md new file mode 100644 index 00000000..635f9410 --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-repo-locate.md @@ -0,0 +1,2775 @@ +# Repo Locate + Registry Merge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make a moved repo folder healable in one atomic operation — every literal path rt stores (repo index, worktree registries, endpoint claims, git's own worktree admin files) re-pointed together — and collapse the name/identity registry pairs the RT-62 cutover left behind. + +**Architecture:** A pure core (`lib/repo-locate.ts`) plans and applies the move: one sync `state.db` transaction rewrites index rows, registry paths, and claim paths; `git worktree repair` runs after the commit; a pre-apply snapshot is restored if verification fails. The daemon owns the apply when it is up, running it inside a new reconciler hold so no reconcile pass can observe an index row that healed ahead of its registry (the ordering that destroys claimed/on-deck state today). A registry merge primitive lets `rt repos prune` collapse the split pool the cutover created, and a prune guard stops evicting a missing row that still owns registry data. + +**Tech Stack:** Bun + TypeScript, `bun:test`, `bun:sqlite` via `state.db` (`lib/state/`), `runCapture`-backed async git (`lib/worktree/git-async.ts`), the daemon's unix-socket IPC (`lib/daemon-client.ts`). + +**Spec:** `docs/superpowers/specs/2026-08-25-repo-locate-design.md` — binding; read it first. Supporting contracts: `docs/repo-identity.md` (the two string forms), `CLAUDE.md` (logging seams, module registry, operating rules). + +## Global Constraints + +- **The daemon is never stopped for any of this.** Mutations of daemon-owned state go through the daemon whenever its socket answers; a local apply happens only when no daemon answers. Never add a "stop the daemon first" step, a `launchctl` call, or a daemon restart. +- **Identity wire form everywhere.** Every index key, registry key, `endpoint_claims.repo` value, and daemon payload `repo` field is a serialized identity (`remote:gitlab.com%2Fg%2Fr` / `path:%2FUsers%2F…`) produced by `serializeIdentity`/validated by `parseIdentity`, imported from `lib/settings/identity.ts` (never `@mattstack/rt-client`, which does not resolve from `lib/`). The raw `host/path` form appears only in settings-store `repos.` sections — this plan touches none of those. Never hand-assemble or string-split a wire. +- **Registry namespace constant is `worktree-registry`**, mirrored in both `lib/worktree/registry.ts` and `lib/repo-index.ts`. Keep both spellings; they are a parity anchor. +- **`bun:sqlite` transactions are sync-only.** No `await` inside a `db.transaction(...)` callback. All git work (`git worktree repair`, `git worktree list`) happens AFTER the transaction commits. +- **No sync git spawns on any path the daemon can reach.** `lib/repo-index.ts` is reachable from daemon handlers (`resolveIndexPathForIdentity`), so anything added there stays free of `execSync`-shaped git beyond what already exists. +- **Logging via seams only.** `dispatch()` (`lib/command-tree.ts`) logs every CLI command's outcome; `handleCommand` (`lib/daemon.ts`) logs every daemon command's ok/rejected/threw. New code logs NO outcomes, wraps no handler in a logging try/catch, and adds no `console.log` progress narration. Daemon handlers that must record a domain event use `ctx.log`; below a seam, an empty catch is acceptable only for a genuinely expected condition. +- **Module registry: no new command module.** `rt repos locate` lives inside the existing `commands/repos.ts`, already thunked at `lib/module-registry.ts:47` (`"./commands/repos.ts": () => import("../commands/repos.ts")`). Do not add a registry entry, do not create a new command module, and do not add a static import of `lib/rt-render.tsx` or `ink` anywhere on a command-tree path. +- **The `packages/rt-client/dist/` freshness rule does not apply to this plan.** `packages/rt-client` is untouched: no `bun run build` in that package, no consumer `bun install`, no rt-client catalog entry for `repos:locate` (the daemon's handler map is the only registration needed — `lib/daemon/__tests__/rt-client-commands.test.ts` asserts catalog ⊆ handlers, never the reverse). +- **Comments are constraint-only** (`~/.claude/rules/clean-code-comments.md`): a parity anchor, an ordering trap, a non-obvious invariant, a why that would otherwise be lost. No narration of the next line, no reviewer-facing justification, no ticket numbers (RT-63/RT-68/spec section refs belong in the commit body, not the source). +- **Test HOME discipline:** every new test repoints `process.env.HOME` to a fresh `mkdtempSync` dir in `beforeEach` and calls `closeStateDb()` before and after (the `getStateDb()` singleton binds to the HOME live at its first call). `bunfig.toml`'s preload gives a process-wide throwaway HOME on top of that — never remove it. +- **Canonical fixtures:** `realpathSync` every temp dir a test hands to git. macOS canonicalizes `/var` → `/private/var`, and `git worktree list` returns the canonical spelling; an uncanonicalized fixture path makes path comparisons fail for the wrong reason. +- **Gates per task, FOREGROUND:** the task's own test files (`bun test `) plus `bunx tsc --noEmit`. State any delta from the baseline. The full sweep `bun test lib commands` runs in the final task. +- **One commit per task**, message body ending with the trailer: + `Co-Authored-By: Claude Fable 5 ` + +--- + +## File structure + +**New files:** +- `lib/repo-locate.ts` — the pure core: `planLocate`, `applyLocate`, `findLocateCandidates`, plan/refusal/result types. No daemon, no CLI, no console output. +- `lib/repo-locate-dispatch.ts` — the one place that decides daemon-vs-local for a locate. Imports `lib/daemon-client.ts`; imported by `commands/repos.ts` and (dynamically) by `lib/repo-index.ts`. +- `lib/daemon/handlers/repos.ts` — the `repos:locate` verb. +- `lib/worktree/__tests__/registry-merge.test.ts`, `lib/__tests__/repo-index-missing.test.ts`, `lib/__tests__/repo-locate.test.ts`, `lib/__tests__/repo-locate-heal.test.ts`, `lib/__tests__/repo-locate-e2e.test.ts`, `lib/daemon/__tests__/reconciler-hold.test.ts`, `lib/daemon/__tests__/repos-handlers.test.ts`, `commands/__tests__/repos-locate.test.ts`. + +**Modified files:** +- `lib/worktree/registry.ts` — `mergeRegistries`, `hasRegistry`, `deleteRegistry`. +- `lib/repo-index.ts` — merge-aware `migrateWorktreeRegistry`; prune retains a missing row that owns a registry; `KnownRepo.missing`; raw row primitives (`setIndexPath`, `removeIndexRow`, `refreshRepoIndexMirror`); move-aware `updateRepoIndex` + `updateRepoIndexAsync`. +- `lib/pickers.ts`, `lib/repo.ts` — refuse to cd into a missing repo. +- `lib/daemon/worktree-reconciler.ts` — `withReconcilerHeld`. +- `lib/daemon/command-router.ts`, `lib/daemon.ts` — wire the repos handlers. +- `lib/daemon/__tests__/rt-client-commands.test.ts` — new `buildRoutedHandlers` opt. +- `lib/__tests__/repo-index-rename.test.ts` — one existing assertion inverts (refuse → merge). +- `commands/repos.ts`, `lib/command-tree-def.ts` — the `rt repos locate` verb and its prune output changes. + +**Task → spec scope item:** T1→1, T2→2, T3→3, T4→7, T5→4, T6→5 (the reconciler hold), T7→5 (the verb), T8→6, T9→8, T10→spec "Verification". + +--- + +## Task 1: Registry merge primitive + +**Files:** +- Modify: `lib/worktree/registry.ts` (append after `findByPath`, `lib/worktree/registry.ts:86-91`) +- Test: `lib/worktree/__tests__/registry-merge.test.ts` (create) + +**Interfaces:** +- Consumes: `TreeRecord`, `TreeKind` (already exported from `lib/worktree/registry.ts`). +- Produces: `mergeRegistries(winner: TreeRecord[], loser: TreeRecord[]): TreeRecord[]` — pure, no I/O beyond a guarded `realpathSync` for path canonicalization. Union by canonical path; winner-side records keep their relative order and come first, loser-only records follow in their own order. + +- [ ] **Step 1: Write the failing test** + +Create `lib/worktree/__tests__/registry-merge.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { mergeRegistries, type TreeRecord } from "../registry.ts"; + +function rec(over: Partial & { path: string }): TreeRecord { + return { + name: over.path.split("/").pop()!, + kind: "unmanaged", + branch: null, + createdAt: "2026-01-01T00:00:00.000Z", + ...over, + }; +} + +describe("mergeRegistries", () => { + test("unions disjoint paths, winner side first", () => { + const merged = mergeRegistries([rec({ path: "/a/main" })], [rec({ path: "/a/tree-1" })]); + expect(merged.map((t) => t.path)).toEqual(["/a/main", "/a/tree-1"]); + }); + + test("an empty loser returns the winner unchanged", () => { + const winner = [rec({ path: "/a/main" }), rec({ path: "/a/tree-1" })]; + expect(mergeRegistries(winner, [])).toEqual(winner); + }); + + test("an empty winner returns the loser's records", () => { + const loser = [rec({ path: "/a/main", kind: "main" })]; + expect(mergeRegistries([], loser)).toEqual(loser); + }); + + test("on a shared path the managed record wins, whichever side it is on", () => { + const claimed = rec({ path: "/a/tree-1", kind: "ephemeral", state: "claimed", owner: "matt" }); + const adopted = rec({ path: "/a/tree-1", kind: "unmanaged" }); + + expect(mergeRegistries([adopted], [claimed])[0]).toEqual(claimed); + expect(mergeRegistries([claimed], [adopted])[0]).toEqual(claimed); + }); + + test("two managed records on one path: the later createdAt wins", () => { + const older = rec({ path: "/a/tree-1", kind: "ephemeral", state: "on-deck", createdAt: "2026-01-01T00:00:00.000Z" }); + const newer = rec({ path: "/a/tree-1", kind: "ephemeral", state: "claimed", createdAt: "2026-02-01T00:00:00.000Z" }); + + expect(mergeRegistries([older], [newer])[0]).toEqual(newer); + expect(mergeRegistries([newer], [older])[0]).toEqual(newer); + }); + + test("an equal createdAt keeps the winner side", () => { + const w = rec({ path: "/a/tree-1", kind: "ephemeral", state: "on-deck", owner: "winner" }); + const l = rec({ path: "/a/tree-1", kind: "ephemeral", state: "on-deck", owner: "loser" }); + expect(mergeRegistries([w], [l])[0]!.owner).toBe("winner"); + }); + + test("an unparseable createdAt never displaces the winner", () => { + const w = rec({ path: "/a/tree-1", kind: "ephemeral", createdAt: "2026-01-01T00:00:00.000Z", owner: "winner" }); + const l = rec({ path: "/a/tree-1", kind: "ephemeral", createdAt: "not a date", owner: "loser" }); + expect(mergeRegistries([w], [l])[0]!.owner).toBe("winner"); + }); + + test("a duplicate path inside one side keeps its first occurrence", () => { + const first = rec({ path: "/a/tree-1", kind: "ephemeral", owner: "first" }); + const second = rec({ path: "/a/tree-1", kind: "ephemeral", owner: "second" }); + expect(mergeRegistries([first, second], [])).toEqual([first]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test lib/worktree/__tests__/registry-merge.test.ts` +Expected: FAIL — `mergeRegistries` is not exported from `../registry.ts`. + +- [ ] **Step 3: Write the implementation** + +In `lib/worktree/registry.ts`, add `realpathSync` to the `fs` imports (the file currently imports nothing from `fs`; add `import { realpathSync } from "fs";` above the `path` import), then append after `findByPath`: + +```ts +/** Canonical path key: a tree that no longer exists compares by its own spelling rather than throwing. */ +function canonPath(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +const MANAGED_KINDS: ReadonlySet = new Set(["main", "ephemeral"]); + +/** + * Total order for two records of the same canonical path: a managed record + * carries claim/ready state no git repository has another copy of, so it beats + * `unmanaged`; within one class the later `createdAt` wins; an equal or + * unparseable stamp keeps the winner side. + */ +function heldRecordWins(held: TreeRecord, challenger: TreeRecord): boolean { + const heldManaged = MANAGED_KINDS.has(held.kind); + const challengerManaged = MANAGED_KINDS.has(challenger.kind); + if (heldManaged !== challengerManaged) return heldManaged; + return !(Date.parse(challenger.createdAt) > Date.parse(held.createdAt)); +} + +/** + * Union two registries of the SAME repo by canonical path — the collapse a + * name/identity index pair needs, where each side owns half of one on-deck + * pool. Name collisions across the two sides are left standing: the union is + * by path, and a record's name is only ever consulted for display and for + * `usedNames` disambiguation, both of which tolerate a duplicate. + */ +export function mergeRegistries(winner: TreeRecord[], loser: TreeRecord[]): TreeRecord[] { + const byPath = new Map(); + const order: string[] = []; + for (const rec of [...winner, ...loser]) { + const key = canonPath(rec.path); + const held = byPath.get(key); + if (!held) { + byPath.set(key, rec); + order.push(key); + continue; + } + if (!heldRecordWins(held, rec)) byPath.set(key, rec); + } + return order.map((key) => byPath.get(key)!); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test lib/worktree/__tests__/registry-merge.test.ts` +Expected: PASS (8 tests) + +- [ ] **Step 5: Typecheck** + +Run: `bunx tsc --noEmit` +Expected: no new errors. + +- [ ] **Step 6: Commit** + +```bash +git add lib/worktree/registry.ts lib/worktree/__tests__/registry-merge.test.ts +git commit -m "feat(worktree): mergeRegistries — union two registries of one repo by canonical path" +``` + +--- + +## Task 2: Prune merges the split registry instead of refusing + +**Files:** +- Modify: `lib/repo-index.ts:290-311` (`DataMigration.registry` doc + union), `lib/repo-index.ts:334-369` (`migrateWorktreeRegistry`) +- Modify: `commands/repos.ts:164-175` (`describeDataMove`) +- Test: `lib/__tests__/repo-index-rename.test.ts:385-464` (one existing assertion inverts; two tests added) + +**Interfaces:** +- Consumes: `mergeRegistries(winner: TreeRecord[], loser: TreeRecord[]): TreeRecord[]` from `lib/worktree/registry.ts` (Task 1). +- Produces: `DataMigration["registry"]` widens to `"moved" | "merged" | "refused" | "none"`. `migrationIncomplete(d: DataMigration): boolean` is unchanged — `"merged"` is a COMPLETE outcome, so a merged pair's index row is evicted like any other duplicate. + +- [ ] **Step 1: Write the failing tests** + +In `lib/__tests__/repo-index-rename.test.ts`, inside `describe("worktree registry migration", …)`, REPLACE the existing test `"refuses when the live name already has one — both hold real claim state"` (currently at line 399) with: + +```ts + test("merges when the live name already has one — one pool, both halves", () => { + setKvValue(WT_NS, "repo-tools", [ + { name: "t1", path: "/x/t1", kind: "ephemeral", state: "on-deck", branch: "on-deck/t1", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + setKvValue(WT_NS, "rt", [ + { name: "main", path: "/x/main", kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + + const result = migrateRepoData("repo-tools", "rt"); + + expect(result.registry).toBe("merged"); + expect((listKvValues(WT_NS)["rt"] as Array<{ path: string }>).map((t) => t.path)).toEqual(["/x/main", "/x/t1"]); + expect(listKvValues(WT_NS)["repo-tools"]).toBeUndefined(); + }); + + test("a merged registry is a COMPLETE migration — the retired index row is evicted", () => { + const dir = realRepo("repo-tools"); + indexRepoAt("repo-tools", dir, 1_000); + indexRepoAt("rt", dir, 2_000); + setKvValue(WT_NS, "repo-tools", tree("/x/retired")); + setKvValue(WT_NS, "rt", tree("/x/live")); + + const removed = pruneRepoIndex(); + + expect(removed.find((r) => r.repoName === "repo-tools")?.data?.registry).toBe("merged"); + expect(removed.find((r) => r.repoName === "repo-tools")?.retained).toBeUndefined(); + expect(loadRepoIndexEntries().map((e) => e.repoName)).toEqual(["rt"]); + }); + + test("--dry-run reports the merge without performing it", () => { + setKvValue(WT_NS, "repo-tools", tree("/x/retired")); + setKvValue(WT_NS, "rt", tree("/x/live")); + + expect(migrateRepoData("repo-tools", "rt", { dryRun: true }).registry).toBe("merged"); + + expect(listKvValues(WT_NS)["repo-tools"]).toEqual(tree("/x/retired")); + expect(listKvValues(WT_NS)["rt"]).toEqual(tree("/x/live")); + }); +``` + +Also REPLACE the existing test `"a refused registry KEEPS the index row — eviction is what makes a leftover unreachable"` (line 435) — the registry can no longer be the cause of a refusal; a refused FILE still is, and the test below it already covers that. Delete that test. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test lib/__tests__/repo-index-rename.test.ts` +Expected: FAIL — `expected "merged", got "refused"`. + +- [ ] **Step 3: Write the implementation** + +In `lib/repo-index.ts`, import the merge (registry.ts imports only `path`, `rt-paths`, and `state/index.ts`, all of which repo-index already loads — no new module weight beyond registry.ts itself): + +```ts +import { mergeRegistries, type TreeRecord } from "./worktree/registry.ts"; +``` + +Widen the `DataMigration.registry` field (`lib/repo-index.ts:310`) and its doc: + +```ts + /** + * The retired name's worktree registry: `"moved"` onto the live name, + * `"merged"` into the live name's own registry (the name/identity pair the + * identity cutover left, each side owning half of one on-deck pool), + * `"refused"` because the write could not be verified, or `"none"` if it + * had none. + * + * This lives in state.db's kv, not the data dir, so it is invisible to a + * directory walk — and it is the record the daemon keys by, so a retired + * name that keeps it while the index row goes away leaves the reconciler + * silently skipping the repo. + */ + registry: "moved" | "merged" | "refused" | "none"; +``` + +Replace the body of `migrateWorktreeRegistry` (`lib/repo-index.ts:349-369`), keeping its existing doc comment's first paragraph and replacing the final "A live name that ALREADY has a registry is refused" paragraph with the merge rule: + +```ts +/** + * Moves the retired name's worktree registry onto the live name. + * + * The daemon keys registries by the INDEX name + * (`lib/daemon/worktree-reconciler.ts` iterates the repo index), while the CLI + * looks them up by git identity. A rename splits those two, and evicting the + * retired index row then makes the registry unreachable: + * `repoHasWorktreeActivity` sees an empty registry under the live name and + * skips the repo, so the reconciler quietly stops managing its worktrees. + * That is why this moves with the data dir instead of being left behind. + * + * A live name that already has a registry is MERGED, not refused: both sides + * describe the same repo's trees, so the union by path (`mergeRegistries`) + * loses neither half of a pool that a name/identity pair split. + */ +function migrateWorktreeRegistry(from: string, to: string, opts: { dryRun?: boolean }): DataMigration["registry"] { + let outcome: "moved" | "merged"; + try { + if (!hasKvValue(WORKTREE_REGISTRY_NS, from)) return "none"; + outcome = hasKvValue(WORKTREE_REGISTRY_NS, to) ? "merged" : "moved"; + if (opts.dryRun) return outcome; + + const retired = getKvValue(WORKTREE_REGISTRY_NS, from, []); + const live = outcome === "merged" ? getKvValue(WORKTREE_REGISTRY_NS, to, []) : []; + const next = outcome === "merged" ? mergeRegistries(live, retired) : retired; + setKvValue(WORKTREE_REGISTRY_NS, to, next); + + // persistOrWarn swallows SQLITE_BUSY, so a returned write is not a landed + // one — and on a merge the destination row already existed, so its mere + // presence proves nothing. Compare the readback. + if (JSON.stringify(getKvValue(WORKTREE_REGISTRY_NS, to, [])) !== JSON.stringify(next)) { + console.warn(`rt: ${from}'s worktree registry did not persist under ${to} — leaving it in place`); + return "refused"; + } + } catch (err) { + console.warn(`rt: could not move ${from}'s worktree registry to ${to} (${(err as Error).message})`); + return "refused"; + } + deleteKvValue(WORKTREE_REGISTRY_NS, from); + return outcome; +} +``` + +In `commands/repos.ts`, extend `describeDataMove` (`commands/repos.ts:164-175`) — add the merge clause immediately after the `"moved"` clause: + +```ts + if (d.registry === "merged") parts.push(`${dryRun ? "would merge" : "merged"} the worktree registry into ${r.keptAs}'s`); + if (d.registry === "refused") parts.push(`${r.keptAs}'s worktree registry could not be written — both kept`); +``` + +(The existing `d.registry === "refused"` line is REPLACED by the wording above; `"refused"` now only ever means a failed write.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test lib/__tests__/repo-index-rename.test.ts commands/__tests__/repos.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck** + +Run: `bunx tsc --noEmit` +Expected: no new errors. + +- [ ] **Step 6: Commit** + +```bash +git add lib/repo-index.ts commands/repos.ts lib/__tests__/repo-index-rename.test.ts +git commit -m "feat(repos): prune merges a split worktree registry instead of refusing" +``` + +--- + +## Task 3: Prune retains a missing row that still owns a registry + +**Files:** +- Modify: `lib/repo-index.ts:273-311` (`PrunedEntry`), `lib/repo-index.ts:494-524` (`pruneRepoIndex`) +- Modify: `commands/repos.ts:187-214` (`reposPrune` output) +- Test: `lib/__tests__/repo-index-rename.test.ts` (append to `describe("pruneRepoIndex", …)`), `commands/__tests__/repos.test.ts` (append to `describe("reposPrune", …)`) + +**Interfaces:** +- Consumes: `WORKTREE_REGISTRY_NS` and `hasKvValue` (both already in `lib/repo-index.ts`). +- Produces: `PrunedEntry` gains `hint?: string`, and `retained?: true` is now set for two reasons — an incomplete duplicate migration (existing) and a `missing` row that owns a worktree registry (new). `PruneReason` is unchanged (`"missing" | "duplicate"`). + +- [ ] **Step 1: Write the failing tests** + +Append to `describe("pruneRepoIndex", …)` in `lib/__tests__/repo-index-rename.test.ts`: + +```ts + test("a missing row that still owns a worktree registry is KEPT, not evicted", () => { + indexRepoAt("moved", join(scratch, "gone-away"), 1_000); + setKvValue("worktree-registry", "moved", [ + { name: "t1", path: join(scratch, "gone-away", ".worktrees", "t1"), kind: "ephemeral", state: "on-deck", branch: "on-deck/t1", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + + const removed = pruneRepoIndex(); + const row = removed.find((r) => r.repoName === "moved"); + + expect(row).toMatchObject({ reason: "missing", retained: true, hint: "rt repos locate" }); + expect(loadRepoIndexEntries().map((e) => e.repoName)).toEqual(["moved"]); + expect(listKvValues("worktree-registry")["moved"]).toBeDefined(); + }); + + test("a missing row with no registry is still evicted", () => { + indexRepoAt("gone", join(scratch, "never-existed"), 1_000); + + const removed = pruneRepoIndex(); + + expect(removed.find((r) => r.repoName === "gone")?.retained).toBeUndefined(); + expect(loadRepoIndexEntries()).toEqual([]); + }); +``` + +Append to `describe("reposPrune", …)` in `commands/__tests__/repos.test.ts`: + +```ts + test("a retained missing row tells the operator to locate it", async () => { + const { setKvValue } = await import("../../lib/state/index.ts"); + updateRepoIndex("moved-repo", join(home, "gone-away")); + setKvValue("worktree-registry", "moved-repo", [ + { name: "t1", path: join(home, "gone-away", ".worktrees", "t1"), kind: "ephemeral", state: "on-deck", branch: "on-deck/t1", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + const deps = testDeps(); + + await reposPrune([], {}, deps); + + expect(deps.lines.join("\n")).toContain("kept moved-repo"); + expect(deps.lines.join("\n")).toContain("rt repos locate"); + expect(loadRepoIndex()["moved-repo"]).toBeDefined(); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test lib/__tests__/repo-index-rename.test.ts commands/__tests__/repos.test.ts` +Expected: FAIL — the missing row is evicted and no `hint` field exists. + +- [ ] **Step 3: Write the implementation** + +In `lib/repo-index.ts`, extend `PrunedEntry` (replacing the existing `retained` doc): + +```ts + /** + * Set when the row is KEPT despite qualifying for eviction: a `duplicate` + * whose migration could not finish, or a `missing` row that still owns a + * worktree registry. Eviction is exactly what makes those leftovers + * unreachable. + */ + retained?: true; + /** Set with `retained`: the verb that resolves this row. */ + hint?: string; +``` + +Replace the missing/live split at the top of `pruneRepoIndex` (`lib/repo-index.ts:499-502`): + +```ts + for (const entry of entries) { + if (existsSync(entry.path)) { + live.push(entry); + continue; + } + // A gone path whose registry is still here is a MOVE, not a deletion: + // dropping the row orphans the pool's claim state under a key nothing + // iterates any more. + let ownsRegistry = false; + try { + ownsRegistry = hasKvValue(WORKTREE_REGISTRY_NS, entry.repoName); + } catch { /* unreadable db — treat as no registry and prune as before */ } + removed.push({ + repoName: entry.repoName, + path: entry.path, + reason: "missing", + ...(ownsRegistry ? { retained: true as const, hint: "rt repos locate" } : {}), + }); + } +``` + +In `commands/repos.ts`, replace the `why` expression inside `reposPrune`'s print loop (`commands/repos.ts:209-211`): + +```ts + const why = r.retained + ? r.reason === "missing" + ? `${describeReason(r)} but it still owns a worktree registry — keeping the row; run: ${r.hint} --repo ${r.repoName}` + : `${describeReason(r)}, but its data could not all move${describeDataMove(r, dryRun)} — keeping the row so nothing is orphaned` + : `${describeReason(r)}${describeDataMove(r, dryRun)}`; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test lib/__tests__/repo-index-rename.test.ts commands/__tests__/repos.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck** + +Run: `bunx tsc --noEmit` +Expected: no new errors. + +- [ ] **Step 6: Commit** + +```bash +git add lib/repo-index.ts commands/repos.ts lib/__tests__/repo-index-rename.test.ts commands/__tests__/repos.test.ts +git commit -m "fix(repos): keep a missing index row that still owns a worktree registry" +``` + +--- + +## Task 4: Lost rows stay visible and are never a cd target + +**Files:** +- Modify: `lib/repo-index.ts:34-42` (`KnownRepo`), `lib/repo-index.ts:659-726` (`getKnownRepos`), `lib/repo-index.ts:896-909` (`repoOption`) +- Modify: `lib/pickers.ts:87-115` (`pickFromAllRepos`) +- Modify: `lib/repo.ts:194-232` (`requireRepoIdentity`), `lib/repo.ts:240-284` (`pickWorktree`) +- Test: `lib/__tests__/repo-index-missing.test.ts` (create) + +**Interfaces:** +- Consumes: `partitionByRealpath(entries: RepoIndexEntry[]): IndexPartition`, `loadRepoIndexEntries(): RepoIndexEntry[]`, `repoDataDir(key: string): string` (all existing). +- Produces: + - `interface KnownRepo` gains `missing?: true`. + - `missingRepoRefusal(r: KnownRepo): string` — exported from `lib/repo-index.ts` and re-exported through `lib/repo.ts`'s existing re-export line. + - `repoOption(r: KnownRepo)` return shape is unchanged (`{ value, label, hint, color? }`); a missing repo gets `hint: "missing — rt repos locate"` and the `dim` color. + +- [ ] **Step 1: Write the failing test** + +Create `lib/__tests__/repo-index-missing.test.ts`: + +```ts +/** + * A moved repo's index row must stay visible: hiding it makes the repo look + * unregistered and re-registers it under a second row at the new path, which + * is the split `rt repos locate` exists to prevent. + */ + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { execSync } from "child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { closeStateDb, setKvValue } from "../state/index.ts"; +import { getKnownRepos, missingRepoRefusal, repoOption } from "../repo-index.ts"; +import { pickFromAllRepos } from "../pickers.ts"; + +describe("missing index rows", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-missing-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-missing-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + function realRepo(name: string): string { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + return dir; + } + + test("a row whose path is gone survives getKnownRepos, marked missing", () => { + setKvValue("repo-index", "moved", join(scratch, "gone-away")); + + const row = getKnownRepos().find((r) => r.repoName === "moved"); + + expect(row?.missing).toBe(true); + expect(row?.worktrees[0]?.path).toBe(join(scratch, "gone-away")); + }); + + test("a live row is never marked missing", () => { + setKvValue("repo-index", "alive", realRepo("alive")); + + expect(getKnownRepos().find((r) => r.repoName === "alive")?.missing).toBeUndefined(); + }); + + test("two lost rows for one directory collapse to a single missing entry", () => { + setKvValue("repo-index", "legacy-name", join(scratch, "gone-away")); + setKvValue("repo-index", "remote:gitlab.com%2Fg%2Fgone", join(scratch, "gone-away")); + + expect(getKnownRepos().filter((r) => r.missing).length).toBe(1); + }); + + test("the picker row says what to run", () => { + const opt = repoOption({ repoName: "moved", worktrees: [{ path: "/x/gone", branch: "", isBare: false }], dataDir: "/d", missing: true }); + expect(opt.hint).toBe("missing — rt repos locate"); + expect(opt.color).toBeDefined(); + }); + + test("the refusal names the repo, the gone path, and the fix", () => { + const msg = missingRepoRefusal({ repoName: "moved", worktrees: [{ path: "/x/gone", branch: "", isBare: false }], dataDir: "/d", missing: true }); + expect(msg).toContain("/x/gone"); + expect(msg).toContain("rt repos locate"); + expect(msg).toContain("--repo moved"); + }); + + test("pickFromAllRepos refuses to cd into a missing repo instead of auto-selecting it", async () => { + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit sentinel"); + }); + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + await pickFromAllRepos( + [{ repoName: "moved", worktrees: [{ path: "/x/gone", branch: "", isBare: false }], dataDir: "/d", missing: true }], + { stderr: true }, + ); + throw new Error("expected pickFromAllRepos to exit"); + } catch (err) { + expect((err as Error).message).toBe("process.exit sentinel"); + expect(exitSpy.mock.calls.at(-1)?.[0]).toBe(1); + expect(errSpy.mock.calls.flat().join(" ")).toContain("rt repos locate"); + } finally { + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test lib/__tests__/repo-index-missing.test.ts` +Expected: FAIL — `missingRepoRefusal` is not exported and lost rows are dropped by `getKnownRepos`. + +- [ ] **Step 3: Write the implementation** + +`lib/repo-index.ts` — extend `KnownRepo`: + +```ts + /** False for repos discovered by scanning sibling directories, never + * explicitly visited by rt. Omitted (implicitly true) for indexed repos. */ + registered?: boolean; + /** The indexed path no longer exists. The row is kept so `rt repos locate` + * can move it as one unit with its registry; it is never a cd target. */ + missing?: true; +``` + +Replace `getKnownRepos`'s partition and tail (`lib/repo-index.ts:670-725`): + +```ts + const repos: KnownRepo[] = []; + + const liveEntries: RepoIndexEntry[] = []; + const lostEntries: RepoIndexEntry[] = []; + for (const e of entries) (existsSync(e.path) ? liveEntries : lostEntries).push(e); + + // Hidden here, not evicted — see partitionByRealpath. + const { keep } = partitionByRealpath(liveEntries); + + for (const { repoName, path: mainPath } of keep) { + // …unchanged worktree enumeration… + } + + const known = repos.filter(r => r.worktrees.length > 0); + // A pair of rows for one gone directory is one lost repo, not two. + const lost: KnownRepo[] = partitionByRealpath(lostEntries).keep.map((e) => ({ + repoName: e.repoName, + worktrees: [{ path: e.path, branch: "", isBare: false }], + dataDir: repoDataDir(e.repoName), + missing: true as const, + })); + const knownNames = new Set([...known, ...lost].map(r => r.repoName)); + // realpath'd for set-membership ONLY — a symlinked path component (macOS + // /tmp → /private/tmp being the canonical case) must not let the same + // directory double-emit under two spellings. `known` itself keeps its + // original, user-visible spellings untouched. Lost paths are deliberately + // absent: the scan must be free to surface the moved repo's NEW directory. + const knownPaths = new Set(known.flatMap(r => r.worktrees.map(w => safeRealpath(w.path)))); + + return [...known, ...lost, ...scanUnregisteredRepos([...known, ...lost], knownNames, knownPaths)]; +``` + +Replace `repoOption` (`lib/repo-index.ts:896-909`) — add the missing branch first: + +```ts +export function repoOption(r: KnownRepo): { value: string; label: string; hint: string; color?: string } { + if (r.missing) { + return { value: r.repoName, label: r.repoName, hint: "missing — rt repos locate", color: dim }; + } + + const location = r.worktrees.length > 1 + ? `${r.worktrees.length} worktrees` + : r.worktrees[0]?.path.replace(homedir(), "~") || ""; + + return { + value: r.repoName, + label: r.repoName, + hint: r.registered === false + ? (location ? `${location} · unregistered` : "unregistered") + : location, + ...(r.registered === false ? { color: dim } : {}), + }; +} +``` + +Add next to it: + +```ts +/** The one-line refusal every picker prints instead of cd-ing into a repo whose indexed path is gone. */ +export function missingRepoRefusal(r: KnownRepo): string { + const gone = r.worktrees[0]?.path ?? "its indexed path"; + return `${r.repoName} is no longer at ${gone} — run: rt repos locate --repo ${r.repoName}`; +} +``` + +`lib/repo.ts` — add `missingRepoRefusal` to BOTH the re-export line (`lib/repo.ts:17`) and the internal import (`lib/repo.ts:22`), then add above `requireRepoIdentity`: + +```ts +/** Never chdir into a repo whose indexed path is gone — locate it first. */ +function refuseIfMissing(repo: KnownRepo): void { + if (!repo.missing) return; + console.log(`\n ${missingRepoRefusal(repo)}\n`); + process.exit(1); +} +``` + +In `requireRepoIdentity`, insert `refuseIfMissing(selectedRepo);` immediately before `process.chdir(selectedRepo.worktrees[0]!.path);`. + +In `pickWorktree`, insert `refuseIfMissing(repos[0]!);` as the first statement inside the `if (totalWorktrees === 1)` block, and `refuseIfMissing(selectedRepo);` immediately after the `if (repos.length === 1) … else … ` block that assigns `selectedRepo`. + +`lib/pickers.ts` — add `missingRepoRefusal` to the existing `./repo.ts` import, then in `pickFromAllRepos` insert the single-repo guard BEFORE the `await import("./rt-render.tsx")` line (so a refusal never pays for loading ink) and the picked-repo guard inside the loop: + +```ts +export async function pickFromAllRepos( + repos: KnownRepo[], + opts?: { stderr?: boolean; errorMessage?: string; includePackages?: boolean }, +): Promise { + const writer = opts?.stderr ? console.error : console.log; + + if (repos.length === 0) { + const msg = opts?.errorMessage || "no known repos found — run rt from inside a git repo first"; + writer(`\n ${msg}\n`); + process.exit(1); + } + + /** Refusing before the picker loads keeps a lost-repo-only index off the ink path entirely. */ + const refuse = (repo: KnownRepo): never => { + writer(`\n ${missingRepoRefusal(repo)}\n`); + process.exit(1); + }; + if (repos.length === 1 && repos[0]!.missing) refuse(repos[0]!); + + const { filterableSelect, BackNavigation } = await import("./rt-render.tsx"); + + // Loop: back from worktree/package picker restarts at repo picker + while (true) { + let selectedRepo: KnownRepo; + + if (repos.length === 1) { + selectedRepo = repos[0]!; + } else { + const picked = await filterableSelect({ + message: "Pick a repo", + options: repoOptionsFromList(repos), + ...(opts?.stderr ? { stderr: true } : {}), + }); + if (!picked) process.exit(1); + selectedRepo = repos.find(r => r.repoName === picked)!; + } + if (selectedRepo.missing) refuse(selectedRepo); + // …unchanged worktree resolution… +``` + +(The pre-existing `const writer = …` inside the `repos.length === 0` block is replaced by the hoisted one above.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test lib/__tests__/repo-index-missing.test.ts lib/__tests__/repo-index-rename.test.ts commands/__tests__/repos.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck** + +Run: `bunx tsc --noEmit` +Expected: no new errors. + +- [ ] **Step 6: Commit** + +```bash +git add lib/repo-index.ts lib/repo.ts lib/pickers.ts lib/__tests__/repo-index-missing.test.ts +git commit -m "feat(repos): keep lost index rows visible and refuse to cd into them" +``` + +--- + +## Task 5: Locate core — planLocate / applyLocate + +**Files:** +- Create: `lib/repo-locate.ts` +- Modify: `lib/worktree/registry.ts` (add `hasRegistry`, `deleteRegistry`) +- Modify: `lib/repo-index.ts` (export `setIndexPath`, `removeIndexRow`, `refreshRepoIndexMirror`; export `REPO_INDEX_NS`) +- Test: `lib/__tests__/repo-locate.test.ts` (create) + +**Interfaces:** +- Consumes: `mergeRegistries(winner, loser)` (Task 1); `loadRegistry(repoName: string): TreeRecord[]`, `saveRegistry(repoName: string, trees: TreeRecord[]): void` (existing); `loadClaims(repoName: string): EndpointClaim[]`, `saveClaims(repoName: string, claims: EndpointClaim[]): void` from `lib/endpoint/store.ts`; `loadRepoIndexEntries(): RepoIndexEntry[]`, `migrateRepoData(from, to, opts?): DataMigration`, `migrationIncomplete(d): boolean`, `getKnownRepos(): KnownRepo[]` (existing); `deriveRepoIdentity`, `serializeIdentity`, `parseIdentity` from `lib/settings/identity.ts`; `runGit(cwd, args, opts?)`, `listWorktreesAsync(repoPath): Promise` from `lib/worktree/git-async.ts`; `getStateDb(): Database` from `lib/state/index.ts`. +- Produces (from `lib/repo-locate.ts`): + - `type LocateRefusalCode = "not-a-git-repo" | "nothing-lost" | "old-path-exists" | "identity-mismatch" | "identity-changed"` + - `interface LocateRefusal { refusal: LocateRefusalCode; message: string }` + - `interface RegistryRewrite { repoKey: string; trees: TreeRecord[]; movedPaths: string[] }` + - `interface ClaimRewrite { repoKey: string; worktree: string; newWorktree: string }` + - `interface LocatePlan { identity: string; oldPath: string; newPath: string; indexKeys: string[]; legacyKeys: string[]; registryRewrites: RegistryRewrite[]; claimRewrites: ClaimRewrite[]; gitRepairPaths: string[] }` + - `interface LocateResult { ok: boolean; identity: string; from: string; to: string; indexKeys: string[]; treesRewritten: number; claimsRewritten: number; repaired: string[]; stalePaths: string[]; legacyRows: { key: string; outcome: "collapsed" | "retained" }[]; restored?: true; error?: string }` + - `interface LocateCandidate { path: string; identity: string }` + - `planLocate(opts: { newPath: string; repo?: string }): Promise` + - `applyLocate(plan: LocatePlan): Promise` + - `findLocateCandidates(): Promise` + - `isRefusal(x: LocatePlan | LocateRefusal): x is LocateRefusal` +- Produces (from `lib/worktree/registry.ts`): `hasRegistry(repoName: string): boolean`, `deleteRegistry(repoName: string): void`. +- Produces (from `lib/repo-index.ts`): `REPO_INDEX_NS` (exported const, value `"repo-index"`), `setIndexPath(key: string, mainPath: string): void`, `removeIndexRow(key: string): void`, `refreshRepoIndexMirror(): void`. + +- [ ] **Step 1: Write the failing test** + +Create `lib/__tests__/repo-locate.test.ts`: + +```ts +/** + * The locate core: plan a move by identity, then apply index + registry + + * claim + git-admin rewrites as one unit. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "child_process"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { closeStateDb, listEndpointClaims, setKvValue } from "../state/index.ts"; +import { loadRepoIndex } from "../repo-index.ts"; +import { loadRegistry, saveRegistry, type TreeRecord } from "../worktree/registry.ts"; +import { saveClaims } from "../endpoint/store.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../settings/identity.ts"; +import { applyLocate, isRefusal, planLocate } from "../repo-locate.ts"; + +describe("repo locate", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + /** A repo with an origin remote, so its identity is remote-kind and survives the move. */ + function repoWithRemote(name: string): string { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync(`git remote add origin https://gitlab.com/g/${name}.git`, { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + return realpathSync(dir); + } + + function localRepo(name: string): string { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + return realpathSync(dir); + } + + function rec(over: Partial & { path: string }): TreeRecord { + return { name: "t", kind: "unmanaged", branch: null, createdAt: "2026-01-01T00:00:00.000Z", ...over }; + } + + test("a directory that is not a git repo is refused", async () => { + const plain = join(scratch, "plain"); + mkdirSync(plain); + const out = await planLocate({ newPath: plain }); + expect(isRefusal(out) && out.refusal).toBe("not-a-git-repo"); + }); + + test("nothing lost in the index is refused", async () => { + const repo = repoWithRemote("alpha"); + const out = await planLocate({ newPath: repo }); + expect(isRefusal(out) && out.refusal).toBe("nothing-lost"); + }); + + test("a derived identity matching no lost row refuses and names both sides", async () => { + setKvValue("repo-index", "remote:gitlab.com%2Fg%2Fsomething-else", join(scratch, "gone")); + const repo = repoWithRemote("beta"); + + const out = await planLocate({ newPath: repo }); + + expect(isRefusal(out) && out.refusal).toBe("identity-mismatch"); + expect(isRefusal(out) && out.message).toContain("remote:gitlab.com%2Fg%2Fbeta"); + expect(isRefusal(out) && out.message).toContain("remote:gitlab.com%2Fg%2Fsomething-else"); + }); + + test("a remote-less repo is refused: its identity IS its path, so a move mints a new one", async () => { + setKvValue("repo-index", `path:${encodeURIComponent(join(scratch, "gone"))}`, join(scratch, "gone")); + const repo = localRepo("gamma"); + + const out = await planLocate({ newPath: repo }); + + expect(isRefusal(out) && out.refusal).toBe("identity-changed"); + expect(isRefusal(out) && out.message).toContain("rt repos register"); + }); + + test("an old path that still exists is a second clone, not a move", async () => { + const original = repoWithRemote("delta"); + const clone = join(scratch, "delta-clone"); + execSync(`git clone -q ${original} ${clone}`, { stdio: "pipe" }); + execSync(`git remote set-url origin https://gitlab.com/g/delta.git`, { cwd: clone, stdio: "pipe" }); + setKvValue("repo-index", serializeIdentity(await deriveRepoIdentity(original)), original); + + const out = await planLocate({ newPath: realpathSync(clone) }); + + expect(isRefusal(out) && out.refusal).toBe("old-path-exists"); + }); + + test("plans the index keys, registry rewrite, claim rewrite and repair paths of a moved repo", async () => { + const repo = repoWithRemote("epsilon"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue("repo-index", identity, repo); + setKvValue("repo-index", "epsilon-legacy", repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + saveRegistry("epsilon-legacy", [rec({ name: "t1", path: treePath, kind: "ephemeral", state: "on-deck", branch: "feat" })]); + saveClaims(identity, [{ worktree: treePath, role: "web", port: 4001, ts: "2026-01-01T00:00:00.000Z" }]); + + const moved = join(scratch, "epsilon-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + + expect(plan.identity).toBe(identity); + expect(plan.oldPath).toBe(repo); + expect(plan.newPath).toBe(moved); + expect(plan.indexKeys.sort()).toEqual([identity, "epsilon-legacy"].sort()); + expect(plan.legacyKeys).toEqual(["epsilon-legacy"]); + expect(plan.gitRepairPaths).toEqual([join(moved, ".worktrees", "t1")]); + expect(plan.claimRewrites).toEqual([ + { repoKey: identity, worktree: treePath, newWorktree: join(moved, ".worktrees", "t1") }, + ]); + }); + + test("apply re-points the index, merges the pair's registries, rewrites claims and repairs git", async () => { + const repo = repoWithRemote("zeta"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue("repo-index", identity, repo); + setKvValue("repo-index", "zeta-legacy", repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + saveRegistry("zeta-legacy", [rec({ name: "t1", path: treePath, kind: "ephemeral", state: "claimed", owner: "matt", branch: "feat" })]); + saveClaims(identity, [{ worktree: treePath, role: "web", port: 4001, ts: "2026-01-01T00:00:00.000Z" }]); + + const moved = join(scratch, "zeta-moved"); + renameSync(repo, moved); + const newTree = join(moved, ".worktrees", "t1"); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + + expect(result.ok).toBe(true); + expect(loadRepoIndex()[identity]).toBe(moved); + expect(loadRepoIndex()["zeta-legacy"]).toBeUndefined(); + expect(loadRegistry(identity).map((t) => t.path).sort()).toEqual([moved, newTree].sort()); + expect(loadRegistry(identity).find((t) => t.path === newTree)).toMatchObject({ state: "claimed", owner: "matt" }); + expect(listEndpointClaims(identity)[0]?.worktree).toBe(newTree); + expect( + execSync("git worktree list --porcelain", { cwd: moved, encoding: "utf8" }), + ).toContain(newTree); + expect(result.legacyRows).toEqual([{ key: "zeta-legacy", outcome: "collapsed" }]); + }); + + test("a registry record whose tree is gone is reported stale, not a failure", async () => { + const repo = repoWithRemote("eta"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + setKvValue("repo-index", identity, repo); + saveRegistry(identity, [ + rec({ name: "main", path: repo, kind: "main", branch: "main" }), + rec({ name: "ghost", path: join(repo, ".worktrees", "ghost"), kind: "ephemeral", state: "on-deck" }), + ]); + + const moved = join(scratch, "eta-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + + expect(result.ok).toBe(true); + expect(result.stalePaths).toEqual([join(moved, ".worktrees", "ghost")]); + expect(loadRepoIndex()[identity]).toBe(moved); + }); + + test("a failed verification restores the pre-apply rows", async () => { + const repo = repoWithRemote("theta"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue("repo-index", identity, repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + + const moved = join(scratch, "theta-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + // A directory that exists but git will never list: the exact shape a + // failed `git worktree repair` leaves behind. + const decoy = join(moved, "decoy"); + mkdirSync(decoy, { recursive: true }); + plan.registryRewrites[0]!.movedPaths.push(decoy); + + const result = await applyLocate(plan); + + expect(result.ok).toBe(false); + expect(result.restored).toBe(true); + expect(loadRepoIndex()[identity]).toBe(repo); + expect(loadRegistry(identity)[0]?.path).toBe(repo); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test lib/__tests__/repo-locate.test.ts` +Expected: FAIL — `lib/repo-locate.ts` does not exist. + +- [ ] **Step 3: Add the store primitives the core needs** + +In `lib/worktree/registry.ts`, add `deleteKvValue` to the `../state/index.ts` import and append: + +```ts +/** Whether this repo has a registry row at all — distinct from an empty registry. */ +export function hasRegistry(repoName: string): boolean { + return hasKvValue(WORKTREE_REGISTRY_NS, repoName); +} + +/** Drop a whole registry row. Only ever the retired half of a pair, after its records have been merged onto the survivor. */ +export function deleteRegistry(repoName: string): void { + deleteKvValue(WORKTREE_REGISTRY_NS, repoName); + epochs.set(repoName, registryEpoch(repoName) + 1); +} +``` + +In `lib/repo-index.ts`, export the namespace constant (`lib/repo-index.ts:50` — change `const REPO_INDEX_NS` to `export const REPO_INDEX_NS`) and add, next to `updateRepoIndex`: + +```ts +/** + * Raw index-row write: no git probe, no move detection. `updateRepoIndex` is + * the caller-facing path that DERIVES the main path; this is the primitive for + * a caller that has already decided what the row must say, and it is the only + * index write that is safe to run inside a state.db transaction. + */ +export function setIndexPath(key: string, mainPath: string): void { + setKvValue(REPO_INDEX_NS, key, mainPath); +} + +/** Drop one index row. */ +export function removeIndexRow(key: string): void { + deleteKvValue(REPO_INDEX_NS, key); +} + +/** Rewrite ~/.mattstack/rt/repos.json from the current rows — a FILE write, so it runs after a transaction commits, never inside one. */ +export function refreshRepoIndexMirror(): void { + try { + writeRepoIndexCompat(loadRepoIndex()); + } catch { /* best effort — see repoIndexCompatPath's doc comment */ } +} +``` + +- [ ] **Step 4: Write `lib/repo-locate.ts`** + +```ts +/** + * Repo locate: re-point every literal path rt stores for a repo whose folder + * moved, as one unit. + * + * Ordering is the whole point. The reconciler prunes a registry row whose path + * is absent from `git worktree list`, so an index row that heals ahead of the + * registry destroys claimed/on-deck state and replenish then mints replacement + * trees. Everything that can be written atomically goes in one state.db + * transaction; `git worktree repair` and the verification run after it, and a + * verification failure puts the pre-apply rows back. + * + * Pure of the daemon and the CLI: `lib/daemon/handlers/repos.ts` and + * `commands/repos.ts` both drive these functions, and neither the caller nor + * the transport is visible from here. + */ + +import { existsSync, realpathSync } from "fs"; +import { join, resolve as resolvePath } from "path"; +import { + getKnownRepos, + loadRepoIndexEntries, + migrateRepoData, + migrationIncomplete, + refreshRepoIndexMirror, + removeIndexRow, + setIndexPath, + type RepoIndexEntry, +} from "./repo-index.ts"; +import { + deleteRegistry, + hasRegistry, + loadRegistry, + mergeRegistries, + saveRegistry, + type TreeRecord, +} from "./worktree/registry.ts"; +import { loadClaims, saveClaims, type EndpointClaim } from "./endpoint/store.ts"; +import { deriveRepoIdentity, parseIdentity, serializeIdentity } from "./settings/identity.ts"; +import { getStateDb } from "./state/index.ts"; +import { listWorktreesAsync, runGit } from "./worktree/git-async.ts"; + +export type LocateRefusalCode = + | "not-a-git-repo" + | "nothing-lost" + | "old-path-exists" + | "identity-mismatch" + | "identity-changed"; + +export interface LocateRefusal { + refusal: LocateRefusalCode; + message: string; +} + +export interface RegistryRewrite { + /** Index key this registry belongs to: the identity, or a legacy-name half of a healed pair. */ + repoKey: string; + /** The whole registry after the re-root, in its original order. */ + trees: TreeRecord[]; + /** New spellings of the records this move re-rooted — what verification checks. */ + movedPaths: string[]; +} + +export interface ClaimRewrite { + repoKey: string; + worktree: string; + newWorktree: string; +} + +export interface LocatePlan { + identity: string; + oldPath: string; + newPath: string; + indexKeys: string[]; + /** Every `indexKeys` entry that is not the identity — collapsed after a verified apply. */ + legacyKeys: string[]; + registryRewrites: RegistryRewrite[]; + claimRewrites: ClaimRewrite[]; + /** In-tree worktree paths (new spellings, main excluded) handed to `git worktree repair`. */ + gitRepairPaths: string[]; +} + +export interface LocateResult { + ok: boolean; + identity: string; + from: string; + to: string; + indexKeys: string[]; + treesRewritten: number; + claimsRewritten: number; + repaired: string[]; + /** Re-rooted registry paths with nothing on disk — a record the reconciler will prune, never a locate failure. */ + stalePaths: string[]; + legacyRows: { key: string; outcome: "collapsed" | "retained" }[]; + restored?: true; + error?: string; +} + +export interface LocateCandidate { + path: string; + identity: string; +} + +export function isRefusal(x: LocatePlan | LocateRefusal): x is LocateRefusal { + return "refusal" in x; +} + +function refuse(refusal: LocateRefusalCode, message: string): LocateRefusal { + return { refusal, message }; +} + +/** realpathSync, degrading to the literal spelling — a gone path must compare, not throw. */ +function canon(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +/** `path` re-rooted onto `newPath`, or null when it lives outside the moved tree (an external worktree keeps its own path). */ +function relocatePath(path: string, oldPath: string, newPath: string): string | null { + if (path === oldPath) return newPath; + if (path.startsWith(`${oldPath}/`)) return newPath + path.slice(oldPath.length); + return null; +} + +/** + * Resolve which index rows a move touches, matching by IDENTITY only. + * + * A legacy-name row joins the plan through the identity row it shares a lost + * directory with — never by name, which is exactly the drift identities exist + * to end. + */ +export async function planLocate(opts: { newPath: string; repo?: string }): Promise { + const newPath = canon(resolvePath(opts.newPath)); + if (!existsSync(join(newPath, ".git"))) { + return refuse("not-a-git-repo", `${newPath} is not a git repository`); + } + + const identity = serializeIdentity(await deriveRepoIdentity(newPath)); + const entries = loadRepoIndexEntries(); + const lost = entries.filter((e) => !existsSync(e.path)); + + const named: RepoIndexEntry | null = opts.repo ? entries.find((e) => e.repoName === opts.repo) ?? null : null; + if (opts.repo && !named) { + return refuse("nothing-lost", `--repo ${opts.repo} is not in the repo index`); + } + if (named && existsSync(named.path)) { + return refuse("old-path-exists", `${opts.repo} is indexed at ${named.path}, which still exists — that is a second clone, not a move`); + } + + const identityRow = entries.find((e) => e.repoName === identity) ?? null; + if (identityRow && existsSync(identityRow.path)) { + return canon(identityRow.path) === newPath + ? refuse("nothing-lost", `${identity} is already indexed at ${newPath}`) + : refuse("old-path-exists", `${identity} is already indexed at ${identityRow.path}, which still exists — that is a second clone, not a move`); + } + + if (!identityRow) { + if (parseIdentity(identity)?.kind === "path") { + return refuse( + "identity-changed", + `${newPath} derives ${identity}, and no index row is keyed by it. A repo with no origin remote is identified BY its main worktree's path, so moving it mints a new identity rather than keeping the old one — locate re-points paths, it never re-keys a repo. Register the new path instead: rt repos register ${newPath}`, + ); + } + return refuse( + "identity-mismatch", + `${newPath} derives ${identity}, which matches no indexed repo whose path is missing (lost rows: ${lost.map((e) => e.repoName).join(", ") || "none"})`, + ); + } + if (named && canon(named.path) !== canon(identityRow.path)) { + return refuse( + "identity-mismatch", + `${newPath} derives ${identity} (indexed at ${identityRow.path}), but --repo names ${named.repoName} at ${named.path} — locate matches by identity, never by name`, + ); + } + + const oldPath = identityRow.path; + const indexKeys = lost.filter((e) => e.path === oldPath).map((e) => e.repoName); + const legacyKeys = indexKeys.filter((key) => key !== identity); + + const registryRewrites: RegistryRewrite[] = []; + const repairPaths = new Set(); + for (const key of indexKeys) { + if (!hasRegistry(key)) continue; + const movedPaths: string[] = []; + const trees = loadRegistry(key).map((rec) => { + const moved = relocatePath(rec.path, oldPath, newPath); + if (moved === null) return rec; + movedPaths.push(moved); + if (moved !== newPath) repairPaths.add(moved); + return { ...rec, path: moved }; + }); + registryRewrites.push({ repoKey: key, trees, movedPaths }); + } + + const claimRewrites: ClaimRewrite[] = []; + for (const key of indexKeys) { + for (const claim of loadClaims(key)) { + const moved = relocatePath(claim.worktree, oldPath, newPath); + if (moved === null) continue; + claimRewrites.push({ repoKey: key, worktree: claim.worktree, newWorktree: moved }); + } + } + + return { + identity, + oldPath, + newPath, + indexKeys, + legacyKeys, + registryRewrites, + claimRewrites, + gitRepairPaths: [...repairPaths], + }; +} + +interface LocateSnapshot { + index: { key: string; path: string | null }[]; + registries: { key: string; trees: TreeRecord[]; existed: boolean }[]; + claims: { key: string; claims: EndpointClaim[] }[]; +} + +function captureSnapshot(plan: LocatePlan): LocateSnapshot { + const claimKeys = [...new Set(plan.claimRewrites.map((c) => c.repoKey))]; + const entries = loadRepoIndexEntries(); + return { + index: [...new Set([...plan.indexKeys, plan.identity])].map((key) => ({ + key, + path: entries.find((e) => e.repoName === key)?.path ?? null, + })), + registries: [...new Set([...plan.registryRewrites.map((r) => r.repoKey), plan.identity])].map((key) => ({ + key, + trees: loadRegistry(key), + existed: hasRegistry(key), + })), + claims: claimKeys.map((key) => ({ key, claims: loadClaims(key) })), + }; +} + +function restoreSnapshot(snapshot: LocateSnapshot): void { + getStateDb().transaction(() => { + for (const row of snapshot.index) { + if (row.path === null) removeIndexRow(row.key); + else setIndexPath(row.key, row.path); + } + for (const reg of snapshot.registries) { + if (reg.existed) saveRegistry(reg.key, reg.trees); + else deleteRegistry(reg.key); + } + for (const c of snapshot.claims) saveClaims(c.key, c.claims); + })(); +} + +/** + * The registry half of the apply: the pair's registries are merged onto the + * IDENTITY key and every legacy registry row is dropped, so the reconciler + * (which iterates identity keys) sees one pool instead of two halves. + */ +function writeRegistries(plan: LocatePlan): void { + const byKey = new Map(plan.registryRewrites.map((r) => [r.repoKey, r.trees])); + let merged = byKey.get(plan.identity) ?? loadRegistry(plan.identity); + let touched = byKey.has(plan.identity); + for (const key of plan.legacyKeys) { + const legacy = byKey.get(key); + if (!legacy) continue; + merged = mergeRegistries(merged, legacy); + deleteRegistry(key); + touched = true; + } + if (touched) saveRegistry(plan.identity, merged); +} + +function writeClaims(plan: LocatePlan): void { + for (const key of new Set(plan.claimRewrites.map((c) => c.repoKey))) { + const moves = new Map( + plan.claimRewrites.filter((c) => c.repoKey === key).map((c) => [c.worktree, c.newWorktree]), + ); + saveClaims( + key, + loadClaims(key).map((c) => { + const moved = moves.get(c.worktree); + return moved === undefined ? c : { ...c, worktree: moved }; + }), + ); + } +} + +/** + * Every re-rooted tree that exists on disk must also be one git knows about; + * a re-rooted tree with nothing on disk is a stale record, which the + * reconciler prunes on its own and which must not fail an otherwise correct + * move. + */ +async function verifyLocate(plan: LocatePlan): Promise<{ error: string | null; stalePaths: string[] }> { + const listed = await listWorktreesAsync(plan.newPath); + if (listed === null) return { error: `git worktree list failed in ${plan.newPath}`, stalePaths: [] }; + const known = new Set(listed.map((w) => canon(w.path))); + if (!known.has(canon(plan.newPath))) { + return { error: `${plan.newPath} is not the main worktree git reports`, stalePaths: [] }; + } + + const stalePaths: string[] = []; + for (const rewrite of plan.registryRewrites) { + for (const path of rewrite.movedPaths) { + if (!existsSync(path)) { + stalePaths.push(path); + continue; + } + if (!known.has(canon(path))) { + return { error: `${path} exists but git does not list it as a worktree of ${plan.newPath}`, stalePaths }; + } + } + } + return { error: null, stalePaths }; +} + +/** + * Collapse the legacy half of a healed pair, on prune's rules: the row is + * dropped only once its data dir has fully moved, because eviction is what + * makes a leftover unreachable. + */ +function collapseLegacyRows(plan: LocatePlan): LocateResult["legacyRows"] { + const out: LocateResult["legacyRows"] = []; + for (const key of plan.legacyKeys) { + const data = migrateRepoData(key, plan.identity); + if (migrationIncomplete(data)) { + out.push({ key, outcome: "retained" }); + continue; + } + removeIndexRow(key); + out.push({ key, outcome: "collapsed" }); + } + return out; +} + +export async function applyLocate(plan: LocatePlan): Promise { + const snapshot = captureSnapshot(plan); + const base = { + identity: plan.identity, + from: plan.oldPath, + to: plan.newPath, + indexKeys: plan.indexKeys, + treesRewritten: plan.registryRewrites.reduce((n, r) => n + r.movedPaths.length, 0), + claimsRewritten: plan.claimRewrites.length, + }; + + // bun:sqlite transactions are sync-only: every git call lives below this + // block, never inside it. + getStateDb().transaction(() => { + for (const key of plan.indexKeys) setIndexPath(key, plan.newPath); + setIndexPath(plan.identity, plan.newPath); + writeRegistries(plan); + writeClaims(plan); + })(); + refreshRepoIndexMirror(); + + if (plan.gitRepairPaths.length > 0) { + await runGit(plan.newPath, ["worktree", "repair", ...plan.gitRepairPaths]); + } + await runGit(plan.newPath, ["worktree", "repair"]); + + const { error, stalePaths } = await verifyLocate(plan); + if (error !== null) { + restoreSnapshot(snapshot); + refreshRepoIndexMirror(); + return { ...base, ok: false, repaired: plan.gitRepairPaths, stalePaths, legacyRows: [], restored: true, error }; + } + + const legacyRows = collapseLegacyRows(plan); + refreshRepoIndexMirror(); + return { ...base, ok: true, repaired: plan.gitRepairPaths, stalePaths, legacyRows }; +} + +/** + * Directories the `rt.repoRoots` scan surfaced whose derived identity is one + * of the index's lost rows — the candidate set `rt repos locate` offers when + * it is given no path. Never auto-picked: this only proposes. + */ +export async function findLocateCandidates(): Promise { + const repos = getKnownRepos(); + const lostKeys = new Set(repos.filter((r) => r.missing).map((r) => r.repoName)); + if (lostKeys.size === 0) return []; + + const candidates: LocateCandidate[] = []; + for (const repo of repos) { + if (repo.registered !== false) continue; + const path = repo.worktrees[0]?.path; + if (!path) continue; + let identity: string; + try { + identity = serializeIdentity(await deriveRepoIdentity(path)); + } catch { + continue; + } + if (!lostKeys.has(identity)) continue; + candidates.push({ path, identity }); + } + return candidates; +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `bun test lib/__tests__/repo-locate.test.ts` +Expected: PASS (9 tests) + +- [ ] **Step 6: Typecheck and re-run the neighbours** + +Run: `bunx tsc --noEmit && bun test lib/__tests__/repo-index-rename.test.ts lib/__tests__/repo-index-missing.test.ts lib/worktree/__tests__/registry-merge.test.ts` +Expected: no errors; all PASS. + +- [ ] **Step 7: Commit** + +```bash +git add lib/repo-locate.ts lib/repo-index.ts lib/worktree/registry.ts lib/__tests__/repo-locate.test.ts +git commit -m "feat(repos): locate core — plan and apply a moved repo's path rewrites atomically" +``` + +--- + +## Task 6: `withReconcilerHeld` on the worktree reconciler + +**Files:** +- Modify: `lib/daemon/worktree-reconciler.ts:1034-1129` (`createWorktreeReconciler`) +- Test: `lib/daemon/__tests__/reconciler-hold.test.ts` (create) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `createWorktreeReconciler(deps: ReconcilerDeps)` return type gains + `withReconcilerHeld: (fn: () => Promise) => Promise` — awaits any pass already in flight, blocks `kick()` from starting a new pass until `fn` settles (a kick arriving during the hold is queued and fires once, on release), and serializes concurrent holders. Existing members (`kick`, `runOnce`, `creationInFlight`, `passInFlight`) are unchanged. + +- [ ] **Step 1: Write the failing test** + +Create `lib/daemon/__tests__/reconciler-hold.test.ts`: + +```ts +/** + * The hold `repos:locate` runs inside: a reconcile pass that observed a healed + * index path against un-rewritten registry paths prunes every registry row as + * "no matching worktree", taking the pool's claim state with it. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, realpathSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import type { Logger } from "pino"; +import { closeStateDb } from "../../state/index.ts"; +import { createWorktreeReconciler } from "../worktree-reconciler.ts"; + +const silentLog = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as unknown as Logger; + +/** An empty index makes each pass a no-op with real awaits — enough to observe pass boundaries without any git. */ +function harness(order: string[]) { + return createWorktreeReconciler({ + cache: { entries: {} }, + repoIndex: () => { + order.push("pass"); + return {}; + }, + emit: () => {}, + log: silentLog, + }); +} + +async function settle(reconciler: { passInFlight: () => boolean }): Promise { + for (let i = 0; i < 200 && reconciler.passInFlight(); i++) await Bun.sleep(5); +} + +describe("withReconcilerHeld", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-hold-home-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(async () => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + }); + + test("a pass already in flight finishes before the held fn runs", async () => { + const order: string[] = []; + const reconciler = harness(order); + + reconciler.kick(); + await reconciler.withReconcilerHeld(async () => { + order.push("fn"); + }); + + expect(order).toEqual(["pass", "fn"]); + }); + + test("a kick during the hold starts no pass until the fn settles", async () => { + const order: string[] = []; + const reconciler = harness(order); + + await reconciler.withReconcilerHeld(async () => { + reconciler.kick(); + await Bun.sleep(10); + expect(order).toEqual([]); + order.push("fn-done"); + }); + + await settle(reconciler); + expect(order).toEqual(["fn-done", "pass"]); + }); + + test("two holders serialize", async () => { + const order: string[] = []; + const reconciler = harness(order); + + await Promise.all([ + reconciler.withReconcilerHeld(async () => { + order.push("a-start"); + await Bun.sleep(10); + order.push("a-end"); + }), + reconciler.withReconcilerHeld(async () => { + order.push("b-start"); + await Bun.sleep(1); + order.push("b-end"); + }), + ]); + + expect(order).toEqual(["a-start", "a-end", "b-start", "b-end"]); + }); + + test("a throwing fn releases the hold", async () => { + const order: string[] = []; + const reconciler = harness(order); + + await expect( + reconciler.withReconcilerHeld(async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + + reconciler.kick(); + await settle(reconciler); + expect(order).toEqual(["pass"]); + }); + + test("the fn's value comes back to the caller", async () => { + const reconciler = harness([]); + expect(await reconciler.withReconcilerHeld(async () => 42)).toBe(42); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test lib/daemon/__tests__/reconciler-hold.test.ts` +Expected: FAIL — `reconciler.withReconcilerHeld is not a function`. + +- [ ] **Step 3: Write the implementation** + +In `lib/daemon/worktree-reconciler.ts`, extend the return type of `createWorktreeReconciler` (after `passInFlight`): + +```ts + /** + * Run `fn` with the reconciler held: any pass in flight is awaited first, + * and `kick()` starts no new pass until `fn` settles (one queued kick fires + * on release). A holder rewrites registry paths that a concurrent pass would + * read as "no matching worktree" and prune, taking the pool's claim state + * with it. Holders serialize. + */ + withReconcilerHeld: (fn: () => Promise) => Promise; +``` + +Replace the closure state and `kick`, and add the holder, inside the function body: + +```ts + let inFlight: Promise | null = null; + /** Non-null while a holder owns the reconciler. */ + let hold: Promise | null = null; + let kickQueued = false; + const creationPromises = new Map>(); +``` + +```ts + function kick(): void { + if (hold) { + kickQueued = true; + return; + } + if (inFlight) return; + const p = runOnce() + .catch((err) => { + deps.log.warn({ err }, "worktree reconciler: kick failed"); + }) + .finally(() => { + if (inFlight === p) inFlight = null; + }); + inFlight = p; + } + + async function withReconcilerHeld(fn: () => Promise): Promise { + while (hold) await hold; + let release!: () => void; + hold = new Promise((resolve) => { + release = resolve; + }); + try { + // A pass that started before the hold was taken still reads the rows the + // holder is about to rewrite, so it has to finish first. + while (inFlight) await inFlight; + return await fn(); + } finally { + hold = null; + release(); + if (kickQueued) { + kickQueued = false; + kick(); + } + } + } +``` + +And add `withReconcilerHeld` to the returned object: + +```ts + return { kick, runOnce, creationInFlight, passInFlight, withReconcilerHeld }; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test lib/daemon/__tests__/reconciler-hold.test.ts` +Expected: PASS (5 tests) + +- [ ] **Step 5: Re-run the reconciler suite and typecheck** + +Run: `bunx tsc --noEmit && bun test lib/daemon/__tests__/worktree-reconciler.test.ts` +Expected: no errors; PASS with no delta from baseline. + +- [ ] **Step 6: Commit** + +```bash +git add lib/daemon/worktree-reconciler.ts lib/daemon/__tests__/reconciler-hold.test.ts +git commit -m "feat(daemon): withReconcilerHeld — exclusive access to the worktree registry" +``` + +--- + +## Task 7: The `repos:locate` daemon verb + +**Files:** +- Create: `lib/daemon/handlers/repos.ts` +- Modify: `lib/daemon/command-router.ts:37-89` (opts + spread) +- Modify: `lib/daemon.ts:386-398` (`buildRoutedHandlers` call) +- Modify: `lib/daemon/__tests__/rt-client-commands.test.ts:36-44` (stub the new opt) +- Test: `lib/daemon/__tests__/repos-handlers.test.ts` (create) + +**Interfaces:** +- Consumes: `planLocate(opts: { newPath: string; repo?: string })`, `applyLocate(plan)`, `isRefusal(x)`, `LocatePlan`, `LocateResult` (Task 5); `withReconcilerHeld: (fn: () => Promise) => Promise` (Task 6); `hooksGuard.refreshWatchedRepos(): void` (`lib/daemon/hooks-guard.ts:100`); the router's local `emitEvent(topic: string, payload: unknown): void`. +- Produces: + - `interface ReposHandlerOpts { withReconcilerHeld: (fn: () => Promise) => Promise; refreshWatchedRepos: () => void; emitEvent: (topic: string, payload: unknown) => void }` + - `createReposHandlers(opts: ReposHandlerOpts): Record<"repos:locate", (payload: any) => Promise> & HandlerMap` + - `buildRoutedHandlers` opts gain `repos: { withReconcilerHeld: (fn: () => Promise) => Promise; refreshWatchedRepos: () => void }`. + - Wire contract: `POST /repos:locate` with `{ newPath: string; repo?: string; dryRun?: boolean }` → `{ ok: true, data: LocateResult }`, or `{ ok: true, data: { dryRun: true, plan: LocatePlan } }`, or `{ ok: false, error: string }`. `repo` is a serialized identity; a non-identity is rejected `repo-unknown`. + - Event: `repo:moved` with payload `{ identity: string; from: string; to: string }`. + +- [ ] **Step 1: Write the failing test** + +Create `lib/daemon/__tests__/repos-handlers.test.ts`: + +```ts +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "child_process"; +import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { closeStateDb, setKvValue } from "../../state/index.ts"; +import { loadRepoIndex } from "../../repo-index.ts"; +import { saveRegistry } from "../../worktree/registry.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../../settings/identity.ts"; +import { createReposHandlers } from "../handlers/repos.ts"; + +describe("repos:locate", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + let order: string[]; + let events: { topic: string; payload: unknown }[]; + let handlers: ReturnType; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-repos-handler-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-repos-handler-repos-"))); + process.env.HOME = home; + closeStateDb(); + order = []; + events = []; + handlers = createReposHandlers({ + withReconcilerHeld: async (fn) => { + order.push("hold-start"); + try { + return await fn(); + } finally { + order.push("hold-end"); + } + }, + refreshWatchedRepos: () => order.push("refresh"), + emitEvent: (topic, payload) => { + order.push(`emit:${topic}`); + events.push({ topic, payload }); + }, + }); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + async function movedRepo(name: string): Promise<{ identity: string; from: string; to: string }> { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync(`git remote add origin https://gitlab.com/g/${name}.git`, { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + saveRegistry(identity, [{ name: "main", path: from, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }]); + const to = join(scratch, `${name}-moved`); + renameSync(from, to); + return { identity, from, to }; + } + + test("a missing newPath is rejected", async () => { + expect(await handlers["repos:locate"]({})).toEqual({ ok: false, error: "newPath-required" }); + }); + + test("a non-identity repo key is rejected, not name-resolved", async () => { + const res = await handlers["repos:locate"]({ newPath: scratch, repo: "repo-tools" }); + expect(res).toEqual({ ok: false, error: "repo-unknown" }); + expect(order).toEqual([]); + }); + + test("applies inside the hold, refreshes watchers, then emits repo:moved", async () => { + const { identity, from, to } = await movedRepo("alpha"); + + const res = await handlers["repos:locate"]({ newPath: to }); + + expect(res.ok).toBe(true); + expect(loadRepoIndex()[identity]).toBe(to); + expect(order).toEqual(["hold-start", "refresh", "emit:repo:moved", "hold-end"]); + expect(events[0]!.payload).toEqual({ identity, from, to }); + }); + + test("dryRun returns the plan and writes nothing", async () => { + const { identity, from, to } = await movedRepo("beta"); + + const res = await handlers["repos:locate"]({ newPath: to, dryRun: true }); + + expect(res.ok).toBe(true); + expect(res.data.dryRun).toBe(true); + expect(res.data.plan.identity).toBe(identity); + expect(loadRepoIndex()[identity]).toBe(from); + expect(events).toEqual([]); + }); + + test("a refusal comes back as a typed error, and nothing is emitted", async () => { + const plain = join(scratch, "plain"); + mkdirSync(plain); + + const res = await handlers["repos:locate"]({ newPath: plain }); + + expect(res.ok).toBe(false); + expect(res.error).toContain("not-a-git-repo"); + expect(events).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test lib/daemon/__tests__/repos-handlers.test.ts` +Expected: FAIL — `../handlers/repos.ts` does not exist. + +- [ ] **Step 3: Write the handler** + +Create `lib/daemon/handlers/repos.ts`: + +```ts +/** + * Repo-index IPC verbs. + * + * `repos:locate` runs the whole apply inside the reconciler's hold: a + * reconcile pass that sees a healed index path against un-rewritten registry + * paths prunes every registry row as "no matching worktree", and replenish + * then mints replacement trees for a pool that never lost anything. + */ + +import { parseIdentity } from "../../settings/identity.ts"; +import { applyLocate, isRefusal, planLocate } from "../../repo-locate.ts"; +import type { HandlerMap } from "./types.ts"; + +export interface ReposHandlerOpts { + /** Exclusive access to the worktree registry for the duration of `fn`. */ + withReconcilerHeld: (fn: () => Promise) => Promise; + /** Re-point the hooks guard's per-repo git-config watchers once paths have moved. */ + refreshWatchedRepos: () => void; + /** Events-bus emit — the router's shared `emitEvent`. */ + emitEvent: (topic: string, payload: unknown) => void; +} + +// Named-key return type (not a bare HandlerMap): under +// noUncheckedIndexedAccess a plain Record makes handlers["repos:locate"] +// resolve to `Handler | undefined` for every caller, tests included. +export function createReposHandlers( + opts: ReposHandlerOpts, +): Record<"repos:locate", (payload: any) => Promise> & HandlerMap { + return { + "repos:locate": async (payload) => { + const newPath = payload?.newPath; + if (typeof newPath !== "string" || newPath.length === 0) return { ok: false, error: "newPath-required" }; + const repo = typeof payload?.repo === "string" ? payload.repo : undefined; + if (repo !== undefined && parseIdentity(repo) === null) return { ok: false, error: "repo-unknown" }; + + return opts.withReconcilerHeld(async () => { + const plan = await planLocate({ newPath, repo }); + if (isRefusal(plan)) return { ok: false, error: `${plan.refusal}: ${plan.message}` }; + if (payload?.dryRun === true) return { ok: true, data: { dryRun: true, plan } }; + + const result = await applyLocate(plan); + if (!result.ok) return { ok: false, error: result.error ?? "locate-failed" }; + + opts.refreshWatchedRepos(); + opts.emitEvent("repo:moved", { identity: result.identity, from: result.from, to: result.to }); + return { ok: true, data: result }; + }); + }, + }; +} +``` + +- [ ] **Step 4: Register it** + +In `lib/daemon/command-router.ts`, add the import beside the others: + +```ts +import { createReposHandlers } from "./handlers/repos.ts"; +``` + +Add the opt to `buildRoutedHandlers`'s parameter object (after `homeSnapshot`): + +```ts + /** Reconciler hold + hooks-guard rewire the repos:locate verb drives. */ + repos: { + withReconcilerHeld: (fn: () => Promise) => Promise; + refreshWatchedRepos: () => void; + }; +``` + +And add to the returned object, next to `createSettingsHandlers()`: + +```ts + ...createReposHandlers({ ...opts.repos, emitEvent }), +``` + +In `lib/daemon.ts`, add to the `buildRoutedHandlers({ … })` call (after `homeSnapshot,`): + +```ts + repos: { + withReconcilerHeld: worktreeReconciler.withReconcilerHeld, + refreshWatchedRepos: hooksGuard.refreshWatchedRepos, + }, +``` + +In `lib/daemon/__tests__/rt-client-commands.test.ts`, add to the `buildRoutedHandlers({ … })` call: + +```ts + repos: { withReconcilerHeld: async (fn) => fn(), refreshWatchedRepos: () => {} }, +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `bun test lib/daemon/__tests__/repos-handlers.test.ts lib/daemon/__tests__/rt-client-commands.test.ts lib/state/__tests__/source-guards.test.ts` +Expected: PASS. + +- [ ] **Step 6: Typecheck** + +Run: `bunx tsc --noEmit` +Expected: no new errors. + +- [ ] **Step 7: Commit** + +```bash +git add lib/daemon/handlers/repos.ts lib/daemon/command-router.ts lib/daemon.ts lib/daemon/__tests__/repos-handlers.test.ts lib/daemon/__tests__/rt-client-commands.test.ts +git commit -m "feat(daemon): repos:locate verb, applied under the reconciler hold" +``` + +--- + +## Task 8: `rt repos locate` CLI + +**Files:** +- Create: `lib/repo-locate-dispatch.ts` +- Modify: `commands/repos.ts` (append the `locate` verb) +- Modify: `lib/command-tree-def.ts:998-1007` (add the subcommand after `prune`) +- Test: `commands/__tests__/repos-locate.test.ts` (create) + +**Interfaces:** +- Consumes: `planLocate`, `applyLocate`, `findLocateCandidates`, `isRefusal`, `LocatePlan`, `LocateResult`, `LocateCandidate` (Task 5); `missingRepoRefusal` / `KnownRepo.missing` (Task 4); `resolveRepoArg(arg: string, fail: (msg: string) => never): Promise` from `lib/repo-arg.ts`; `envelope(body)` from `lib/setup/contract.ts`; `UserActionableError`, `exitUserError` from `lib/setup/errors.ts`; `getKnownRepos()` from `lib/repo-index.ts`. +- Consumes (daemon transport): **`isDaemonRunning(): Promise`** and **`daemonSocketQuery(cmd, payload?, timeoutMs?): Promise`**, both from `lib/daemon-client.ts`. `daemonSocketQuery` is the read-only variant deliberately: unlike `daemonQuery` it never POSTs `/daemon/start` to the tray and never prints a "daemon down" warning, so a locate can probe without starting anything. +- Produces: + - `lib/repo-locate-dispatch.ts`: `type LocateOutcome = { via: "daemon" | "local"; ok: true; dryRun: false; result: LocateResult } | { via: "daemon" | "local"; ok: true; dryRun: true; plan: LocatePlan } | { via: "daemon" | "local"; ok: false; error: string }`; `locateMovedRepo(req: { newPath: string; repo?: string; dryRun?: boolean }): Promise`; `LOCATE_TIMEOUT_MS: number`. + - `commands/repos.ts`: `reposLocate(args: string[], ctx?: CommandContext, deps?: RegisterDeps): Promise` (same `RegisterDeps` = `{ print: (s: string) => void }` the other two verbs take). + +- [ ] **Step 1: Write the failing test** + +Create `commands/__tests__/repos-locate.test.ts`: + +```ts +/** + * The CLI runs the local path here: under a throwaway HOME no daemon socket + * exists, so `isDaemonRunning()` is false and the apply happens in-process — + * which is exactly the "no daemon, nothing to race" branch. + */ + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { execSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { closeStateDb, setKvValue } from "../../lib/state/index.ts"; +import { loadRepoIndex } from "../../lib/repo-index.ts"; +import { saveRegistry, loadRegistry } from "../../lib/worktree/registry.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../../lib/settings/identity.ts"; +import { reposLocate, type RegisterDeps } from "../repos.ts"; + +function testDeps(): RegisterDeps & { lines: string[] } { + const lines: string[] = []; + return { print: (s) => lines.push(s), lines }; +} + +async function runExpectingProcessExit(fn: () => Promise): Promise { + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit sentinel"); + }); + try { + await fn(); + return undefined; + } catch { + return exitSpy.mock.calls.at(-1)?.[0] as number | undefined; + } finally { + exitSpy.mockRestore(); + } +} + +describe("reposLocate", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-cli-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-cli-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + async function movedRepo(name: string): Promise<{ identity: string; from: string; to: string }> { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync(`git remote add origin https://gitlab.com/g/${name}.git`, { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + saveRegistry(identity, [{ name: "main", path: from, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }]); + const to = join(scratch, `${name}-moved`); + renameSync(from, to); + return { identity, from, to }; + } + + test("locates a moved repo and says where it went", async () => { + const { identity, from, to } = await movedRepo("alpha"); + const deps = testDeps(); + + await reposLocate([to], {}, deps); + + expect(loadRepoIndex()[identity]).toBe(to); + expect(loadRegistry(identity)[0]?.path).toBe(to); + expect(deps.lines.join("\n")).toContain(from); + expect(deps.lines.join("\n")).toContain(to); + }); + + test("--dry-run reports the plan and writes nothing", async () => { + const { identity, from, to } = await movedRepo("beta"); + const deps = testDeps(); + + await reposLocate([to, "--dry-run"], {}, deps); + + expect(loadRepoIndex()[identity]).toBe(from); + expect(deps.lines.join("\n")).toContain("would move"); + }); + + test("--json emits a contract envelope", async () => { + const { identity, to } = await movedRepo("gamma"); + const deps = testDeps(); + + await reposLocate([to, "--json"], {}, deps); + + const parsed = JSON.parse(deps.lines[0]!); + expect(parsed.contract).toBe(1); + expect(parsed.located.identity).toBe(identity); + expect(parsed.located.to).toBe(to); + }); + + test("--repo resolves to an identity and is honoured", async () => { + const { identity, to } = await movedRepo("delta"); + const deps = testDeps(); + + await reposLocate([to, "--repo", identity], {}, deps); + + expect(loadRepoIndex()[identity]).toBe(to); + }); + + test("a refusal exits 2 with the typed message", async () => { + const plain = join(scratch, "plain"); + mkdirSync(plain); + const deps = testDeps(); + + const code = await runExpectingProcessExit(() => reposLocate([plain], {}, deps)); + + expect(code).toBe(2); + expect(deps.lines.join("\n")).toContain("not a git repository"); + }); + + test("an unknown flag is a usage error", async () => { + const deps = testDeps(); + const code = await runExpectingProcessExit(() => reposLocate(["--nope"], {}, deps)); + expect(code).toBe(2); + expect(deps.lines.join("\n")).toContain("usage: rt repos locate"); + }); + + test("no path and no lost rows exits 1 saying so", async () => { + const deps = testDeps(); + const code = await runExpectingProcessExit(() => reposLocate([], {}, deps)); + expect(code).toBe(1); + expect(deps.lines.join("\n")).toContain("no indexed repo is missing"); + }); + + test("no path, a lost row and no candidate lists the lost row and exits 1", async () => { + setKvValue("repo-index", "remote:gitlab.com%2Fg%2Fghost", join(scratch, "ghost")); + const deps = testDeps(); + + const code = await runExpectingProcessExit(() => reposLocate([], {}, deps)); + + expect(code).toBe(1); + expect(deps.lines.join("\n")).toContain("remote:gitlab.com%2Fg%2Fghost"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test commands/__tests__/repos-locate.test.ts` +Expected: FAIL — `reposLocate` is not exported from `../repos.ts`. + +- [ ] **Step 3: Write the dispatcher** + +Create `lib/repo-locate-dispatch.ts`: + +```ts +/** + * The one place that decides whether a locate runs in the daemon or in this + * process. + * + * The daemon is the single writer of the worktree registry, so a locate must + * never run locally while it answers: a reconcile pass landing between the + * index write and the registry write is exactly the prune this feature exists + * to prevent. A daemon that is up but does not answer is a hard stop, not a + * fall-through — `daemonSocketQuery` is the read-only client, so probing never + * starts a daemon or warns. + */ + +import { daemonSocketQuery, isDaemonRunning } from "./daemon-client.ts"; +import { applyLocate, isRefusal, planLocate, type LocatePlan, type LocateResult } from "./repo-locate.ts"; + +/** git worktree repair across a large pool is the slow part; the 2s default IPC timeout is a client number, not a daemon-op one. */ +export const LOCATE_TIMEOUT_MS = 2 * 60_000; + +export type LocateOutcome = + | { via: "daemon" | "local"; ok: true; dryRun: false; result: LocateResult } + | { via: "daemon" | "local"; ok: true; dryRun: true; plan: LocatePlan } + | { via: "daemon" | "local"; ok: false; error: string }; + +export async function locateMovedRepo(req: { + newPath: string; + repo?: string; + dryRun?: boolean; +}): Promise { + const dryRun = req.dryRun === true; + + if (await isDaemonRunning()) { + const res = await daemonSocketQuery( + "repos:locate", + { newPath: req.newPath, ...(req.repo ? { repo: req.repo } : {}), dryRun }, + LOCATE_TIMEOUT_MS, + ); + if (!res) { + return { + via: "daemon", + ok: false, + error: "the rt daemon is running but did not answer repos:locate — not applying locally, which would race the worktree reconciler", + }; + } + if (!res.ok) return { via: "daemon", ok: false, error: res.error ?? "repos:locate failed" }; + return dryRun + ? { via: "daemon", ok: true, dryRun: true, plan: res.data.plan as LocatePlan } + : { via: "daemon", ok: true, dryRun: false, result: res.data as LocateResult }; + } + + const plan = await planLocate({ newPath: req.newPath, repo: req.repo }); + if (isRefusal(plan)) return { via: "local", ok: false, error: `${plan.refusal}: ${plan.message}` }; + if (dryRun) return { via: "local", ok: true, dryRun: true, plan }; + + const result = await applyLocate(plan); + return result.ok + ? { via: "local", ok: true, dryRun: false, result } + : { via: "local", ok: false, error: result.error ?? "locate failed" }; +} +``` + +- [ ] **Step 4: Write the CLI verb** + +Append to `commands/repos.ts` (and add the imports it needs to the existing import block: `getKnownRepos` from `../lib/repo-index.ts`, `findLocateCandidates` and the plan/result types from `../lib/repo-locate.ts`, `locateMovedRepo` from `../lib/repo-locate-dispatch.ts`, `resolveRepoArg` from `../lib/repo-arg.ts`): + +```ts +// ─── locate ────────────────────────────────────────────────────────────────── + +const LOCATE_USAGE = "usage: rt repos locate [] [--repo ] [--dry-run] [--json]"; +const LOCATE_FLAGS = ["--json", "--dry-run", "--repo"]; + +/** Every non-flag token that is not `--repo`'s value. */ +function locatePositionals(args: string[]): string[] { + const out: string[] = []; + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + if (a === "--repo") { + i++; + continue; + } + if (a.startsWith("--")) continue; + out.push(a); + } + return out; +} + +/** + * rt repos locate — tell rt where a repo moved to. + * + * A folder move keeps the repo identity but leaves every stored path stale. + * The daemon owns the apply whenever it answers; a local apply only happens + * when nothing is up to race. + */ +export async function reposLocate(args: string[], _ctx: CommandContext = {}, deps: RegisterDeps = realRegisterDeps()): Promise { + const json = args.includes("--json"); + const dryRun = args.includes("--dry-run"); + for (const a of args) { + if (a.startsWith("--") && !LOCATE_FLAGS.includes(a)) { + exitUserError(new UserActionableError("usage", `unknown flag "${a}" — ${LOCATE_USAGE}`), json, "repos locate", deps.print); + } + } + + const repoArg = flagValue(args, "--repo"); + const repo = repoArg + ? await resolveRepoArg(repoArg, (msg) => + exitUserError(new UserActionableError("repo-unknown", msg), json, "repos locate", deps.print)) + : undefined; + + const newPath = locatePositionals(args)[0] ?? (await pickLocateTarget(json, deps)); + + const outcome = await locateMovedRepo({ newPath, ...(repo ? { repo } : {}), dryRun }); + if (!outcome.ok) { + exitUserError(new UserActionableError("refused", outcome.error), json, "repos locate", deps.print); + } + + if (outcome.dryRun) { + const p = outcome.plan; + if (json) { + deps.print(JSON.stringify(envelope({ plan: p, dryRun: true }))); + return; + } + deps.print(`would move ${p.identity} from ${p.oldPath} to ${p.newPath}`); + deps.print(` index rows: ${p.indexKeys.join(", ")}`); + deps.print(` worktree records: ${p.registryRewrites.reduce((n, r) => n + r.movedPaths.length, 0)}`); + deps.print(` endpoint claims: ${p.claimRewrites.length}`); + deps.print(` git worktree repair: ${p.gitRepairPaths.length === 0 ? "(main worktree only)" : p.gitRepairPaths.join(", ")}`); + return; + } + + const r = outcome.result; + if (json) { + deps.print(JSON.stringify(envelope({ located: r, via: outcome.via }))); + return; + } + deps.print(`located ${r.identity}: ${r.from} → ${r.to}`); + deps.print(` ${r.treesRewritten} worktree record${r.treesRewritten === 1 ? "" : "s"}, ${r.claimsRewritten} endpoint claim${r.claimsRewritten === 1 ? "" : "s"}, ${r.repaired.length} tree${r.repaired.length === 1 ? "" : "s"} repaired`); + for (const stale of r.stalePaths) deps.print(` stale record kept for the reconciler to prune: ${stale}`); + for (const row of r.legacyRows) { + deps.print(row.outcome === "collapsed" + ? ` collapsed the legacy row ${row.key}` + : ` kept the legacy row ${row.key} — its data dir could not all move`); + } +} + +/** + * No ``: propose, never auto-pick. One candidate still asks; several + * open a picker; none is a hard stop that names what is lost. + */ +async function pickLocateTarget(json: boolean, deps: RegisterDeps): Promise { + const lost = getKnownRepos().filter((r) => r.missing); + if (lost.length === 0) { + deps.print(json ? JSON.stringify(envelope({ lost: [], candidates: [] })) : "no indexed repo is missing — nothing to locate"); + process.exit(1); + } + + const candidates = await findLocateCandidates(); + if (candidates.length === 0 || !process.stdin.isTTY) { + if (json) { + deps.print(JSON.stringify(envelope({ lost: lost.map((r) => ({ repo: r.repoName, path: r.worktrees[0]?.path })), candidates }))); + } else { + deps.print("missing repos:"); + for (const r of lost) deps.print(` ${r.repoName} — last seen at ${r.worktrees[0]?.path}`); + deps.print(candidates.length === 0 + ? `pass the new path: ${LOCATE_USAGE}` + : "run interactively to pick a candidate, or pass the new path"); + } + process.exit(1); + } + + if (candidates.length === 1) { + const only = candidates[0]!; + const { confirm } = await import("../lib/rt-render.tsx"); + const ok = await confirm({ message: `Locate ${only.identity} at ${only.path}?`, stderr: true }); + if (!ok) process.exit(0); + return only.path; + } + + const { filterableSelect } = await import("../lib/rt-render.tsx"); + const picked = await filterableSelect({ + message: "Which directory did it move to?", + options: candidates.map((c) => ({ value: c.path, label: c.path, hint: c.identity })), + stderr: true, + }); + if (!picked) process.exit(0); + return picked; +} +``` + +- [ ] **Step 5: Register the subcommand** + +In `lib/command-tree-def.ts`, add after the `prune` entry (`lib/command-tree-def.ts:1006`), still inside `repos.subcommands`: + +```ts + locate: { + description: "Tell rt where a repo moved to — re-points the index, worktree registry, endpoint claims and git's worktree admin files together", + module: "./commands/repos.ts", + fn: "reposLocate", + args: [ + { name: "New path", type: "text", placeholder: "/path/to/moved-repo", hint: "Where the repo lives now; omit to pick from candidates under rt.repoRoots" }, + { name: "Repo", flag: "--repo", type: "text", placeholder: "repo-tools", hint: "Which indexed repo moved (identity, path, or name); omit to match by the new path's own identity" }, + { name: "Dry run", flag: "--dry-run", type: "boolean", default: false, hint: "Print what would be re-pointed without writing" }, + SETUP_JSON_ARG, + ], + }, +``` + +`commands/repos.ts` is already thunked in `lib/module-registry.ts` — no registry change. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `bun test commands/__tests__/repos-locate.test.ts commands/__tests__/repos.test.ts` +Expected: PASS. + +- [ ] **Step 7: Typecheck and the command-tree guards** + +Run: `bunx tsc --noEmit && bun test lib/__tests__/no-eager-tui.test.ts lib/__tests__/command-tree.test.ts lib/__tests__/command-tree-def.test.ts` +Expected: no errors; PASS. `no-eager-tui` is the guard that a command module stays lazily reachable — a failure there means a static `lib/rt-render.tsx`/`ink` import crept into a command-tree path. + +- [ ] **Step 8: Commit** + +```bash +git add lib/repo-locate-dispatch.ts commands/repos.ts lib/command-tree-def.ts commands/__tests__/repos-locate.test.ts +git commit -m "feat(repos): rt repos locate — daemon-first, local when nothing answers" +``` + +--- + +## Task 9: The implicit heal is move-aware + +**Files:** +- Modify: `lib/repo-index.ts:133-153` (`updateRepoIndex`) +- Modify: `commands/repos.ts:125-134` (`reposRegister` uses the async seam) +- Test: `lib/__tests__/repo-locate-heal.test.ts` (create) + +**Interfaces:** +- Consumes: `locateMovedRepo(req: { newPath: string; repo?: string; dryRun?: boolean }): Promise` (Task 8). +- Produces: + - `updateRepoIndex(repoName: string, repoRoot: string): void` — unchanged signature and unchanged behavior EXCEPT that it no longer overwrites a stored path that has stopped existing (that row is a move, and re-pointing it alone is the destructive ordering). + - `updateRepoIndexAsync(repoName: string, repoRoot: string): Promise` — the same write, plus the move heal for callers that can await. + +- [ ] **Step 1: Write the failing test** + +Create `lib/__tests__/repo-locate-heal.test.ts`: + +```ts +/** + * The implicit heal must move a repo, not re-point one row of it: the sync + * seam is reachable from the daemon thread (no sync git there) and cannot + * await `git worktree repair`, so it declines the write and the async seam + * performs the whole locate. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "child_process"; +import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { closeStateDb, setKvValue } from "../state/index.ts"; +import { loadRepoIndex, updateRepoIndex, updateRepoIndexAsync } from "../repo-index.ts"; +import { loadRegistry, saveRegistry } from "../worktree/registry.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../settings/identity.ts"; + +describe("move-aware index heal", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-heal-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-heal-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + async function movedRepo(name: string): Promise<{ identity: string; from: string; to: string; tree: string }> { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync(`git remote add origin https://gitlab.com/g/${name}.git`, { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const tree = join(from, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${tree}`, { cwd: from, stdio: "pipe" }); + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + saveRegistry(identity, [ + { name: "main", path: from, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }, + { name: "t1", path: tree, kind: "ephemeral", state: "on-deck", branch: "feat", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + const to = join(scratch, `${name}-moved`); + renameSync(from, to); + return { identity, from, to, tree }; + } + + test("the sync seam refuses to re-point a row whose stored path is gone", async () => { + const { identity, from, to } = await movedRepo("alpha"); + + updateRepoIndex(identity, to); + + expect(loadRepoIndex()[identity]).toBe(from); + }); + + test("the sync seam still writes a live path and a brand-new row", async () => { + const dir = join(scratch, "beta"); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + const live = realpathSync(dir); + + updateRepoIndex("beta-key", live); + expect(loadRepoIndex()["beta-key"]).toBe(live); + + updateRepoIndex("beta-key", live); + expect(loadRepoIndex()["beta-key"]).toBe(live); + }); + + test("the async seam heals the move as one unit", async () => { + const { identity, to } = await movedRepo("gamma"); + + await updateRepoIndexAsync(identity, to); + + expect(loadRepoIndex()[identity]).toBe(to); + expect(loadRegistry(identity).map((t) => t.path).sort()).toEqual( + [to, join(to, ".worktrees", "t1")].sort(), + ); + expect(loadRegistry(identity).find((t) => t.path === join(to, ".worktrees", "t1"))?.state).toBe("on-deck"); + expect(execSync("git worktree list --porcelain", { cwd: to, encoding: "utf8" })).toContain(join(to, ".worktrees", "t1")); + }); + + test("the async seam is a plain write when nothing moved", async () => { + const dir = join(scratch, "delta"); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + const live = realpathSync(dir); + + await updateRepoIndexAsync("delta-key", live); + + expect(loadRepoIndex()["delta-key"]).toBe(live); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test lib/__tests__/repo-locate-heal.test.ts` +Expected: FAIL — `updateRepoIndexAsync` is not exported, and the sync seam overwrites the row. + +- [ ] **Step 3: Write the implementation** + +In `lib/repo-index.ts`, replace `updateRepoIndex` (`lib/repo-index.ts:133-153`) with the extracted probe plus the two seams: + +```ts +/** The repo's MAIN worktree path as git reports it, degrading to `repoRoot`. */ +function observedMainPath(repoRoot: string): string { + try { + const listed = execSync("git worktree list --porcelain", { + cwd: repoRoot, + encoding: "utf8", + stdio: "pipe", + }); + return listed.split("\n")[0]?.replace("worktree ", "").trim() || repoRoot; + } catch { + return repoRoot; + } +} + +/** True when the stored row names a directory that is gone and the repo is now somewhere else — a MOVE, not a second clone. */ +function storedPathMoved(stored: string | undefined, mainPath: string): stored is string { + return stored !== undefined && stored !== mainPath && !existsSync(stored); +} + +export function updateRepoIndex(repoName: string, repoRoot: string): void { + const mainPath = observedMainPath(repoRoot); + try { + // loadRepoIndex() can throw (an unopenable state.db — e.g. root-owned + // after a sudo invocation) — inside the try along with the write it + // depends on, so getRepoIdentity() (which every in-repo command calls) + // degrades to skipping the index update rather than crashing the command. + // + // A moved repo is NOT written here: re-pointing the index row ahead of the + // worktree registry is what makes the reconciler prune every claimed tree, + // and the repair this seam would owe is async git — forbidden on the + // daemon thread, which reaches this function through + // resolveIndexPathForIdentity. The row stays lost (visible as `missing`) + // until `updateRepoIndexAsync` or `rt repos locate` moves it as one unit. + if (storedPathMoved(loadRepoIndex()[repoName], mainPath)) return; + setKvValue(REPO_INDEX_NS, repoName, mainPath); + writeRepoIndexCompat(loadRepoIndex()); + } catch { /* best effort */ } +} + +/** + * `updateRepoIndex` for callers that can await: the same write, plus the move + * heal the sync seam cannot perform. The locate runs in the daemon whenever it + * answers — imported lazily so this module's sync path never pulls the daemon + * client into every rt command's startup. + */ +export async function updateRepoIndexAsync(repoName: string, repoRoot: string): Promise { + const mainPath = observedMainPath(repoRoot); + let stored: string | undefined; + try { + stored = loadRepoIndex()[repoName]; + } catch { + stored = undefined; + } + if (!storedPathMoved(stored, mainPath)) { + updateRepoIndex(repoName, mainPath); + return; + } + const { locateMovedRepo } = await import("./repo-locate-dispatch.ts"); + const outcome = await locateMovedRepo({ newPath: mainPath, repo: repoName }); + if (!outcome.ok) console.warn(`rt: ${repoName} moved to ${mainPath} but could not be located (${outcome.error})`); +} +``` + +In `commands/repos.ts`, switch `reposRegister`'s write to the async seam (`commands/repos.ts:126`) and add `updateRepoIndexAsync` to its `../lib/repo-index.ts` import: + +```ts + await updateRepoIndexAsync(identity, real); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test lib/__tests__/repo-locate-heal.test.ts commands/__tests__/repos.test.ts commands/__tests__/repos-locate.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck and re-run the index suites** + +Run: `bunx tsc --noEmit && bun test lib/__tests__/repo-index.test.ts lib/__tests__/repo-index-rename.test.ts lib/__tests__/repo-index-missing.test.ts lib/__tests__/repo.test.ts` +Expected: no errors; PASS. + +- [ ] **Step 6: Commit** + +```bash +git add lib/repo-index.ts commands/repos.ts lib/__tests__/repo-locate-heal.test.ts +git commit -m "feat(repos): implicit index heal moves a repo instead of re-pointing one row" +``` + +--- + +## Task 10: Real-state verification + +**Files:** +- Test: `lib/__tests__/repo-locate-e2e.test.ts` (create) + +**Interfaces:** +- Consumes everything the earlier tasks produced: `planLocate`, `applyLocate`, `isRefusal` (Task 5); `loadRegistry`/`saveRegistry` (existing); `loadClaims`/`saveClaims` (existing); `pruneRepoIndex` (Task 3); `reconcileRepoRegistry(deps: { repoName: string; repoPath: string; emit: (type: string, data: unknown) => void; log: Logger }): Promise` from `lib/daemon/worktree-reconciler.ts`. +- Produces: no source changes — this task adds the end-to-end proof and runs the full gate. + +- [ ] **Step 1: Write the test** + +Create `lib/__tests__/repo-locate-e2e.test.ts`: + +```ts +/** + * The whole story against real state: a repo with a linked worktree under + * `.worktrees/`, an ephemeral on-deck record, and a live endpoint claim, moved + * on disk and then located. The assertion that matters most is the last one — + * a reconcile pass over the located repo must prune nothing. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "child_process"; +import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import type { Logger } from "pino"; +import { closeStateDb, listEndpointClaims, setKvValue } from "../state/index.ts"; +import { loadRepoIndex, pruneRepoIndex } from "../repo-index.ts"; +import { loadRegistry, saveRegistry } from "../worktree/registry.ts"; +import { saveClaims } from "../endpoint/store.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../settings/identity.ts"; +import { applyLocate, isRefusal, planLocate } from "../repo-locate.ts"; +import { reconcileRepoRegistry } from "../daemon/worktree-reconciler.ts"; + +const silentLog = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as unknown as Logger; + +describe("repo locate — real state", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-e2e-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-e2e-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + test("a moved repo with a claimed pool survives locate intact", async () => { + // ── a throwaway repo with a linked worktree under .worktrees/ + const dir = join(scratch, "acme-dev"); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync("git remote add origin https://gitlab.com/acme/acme-dev.git", { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const oldTree = join(from, ".worktrees", "tree-1"); + execSync(`git worktree add -q -b on-deck/tree-1 ${oldTree}`, { cwd: from, stdio: "pipe" }); + + // ── registered in an isolated HOME's state.db: index row, registry with an + // ephemeral on-deck record, and an endpoint_claims row + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + saveRegistry(identity, [ + { name: "main", path: from, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }, + { + name: "tree-1", + path: oldTree, + kind: "ephemeral", + state: "on-deck", + branch: "on-deck/tree-1", + createdAt: "2026-01-02T00:00:00.000Z", + readyAt: "2026-01-02T01:00:00.000Z", + readyStamp: "abc123", + }, + ]); + saveClaims(identity, [{ worktree: oldTree, role: "web", port: 4010, pid: 4242, ts: "2026-01-02T02:00:00.000Z" }]); + + // ── mv the repo + const to = join(scratch, "moved", "acme-dev"); + mkdirSync(join(scratch, "moved"), { recursive: true }); + renameSync(from, to); + const newTree = join(to, ".worktrees", "tree-1"); + + // ── locate it, locally + const plan = await planLocate({ newPath: to }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + expect(result.ok).toBe(true); + expect(result.error).toBeUndefined(); + + // index path updated + expect(loadRepoIndex()[identity]).toBe(to); + + // registry record path updated, state intact + const trees = loadRegistry(identity); + expect(trees.map((t) => t.path).sort()).toEqual([to, newTree].sort()); + expect(trees.find((t) => t.path === newTree)).toMatchObject({ + kind: "ephemeral", + state: "on-deck", + branch: "on-deck/tree-1", + readyStamp: "abc123", + }); + + // claim row updated + expect(listEndpointClaims(identity)).toEqual([ + { worktree: newTree, role: "web", port: 4010, pid: 4242, ts: "2026-01-02T02:00:00.000Z" }, + ]); + + // git worktree list shows the new path (and not the old one) + const listed = execSync("git worktree list --porcelain", { cwd: to, encoding: "utf8" }); + expect(listed).toContain(newTree); + expect(listed).not.toContain(oldTree); + + // no prunable entries + expect(pruneRepoIndex({ dryRun: true })).toEqual([]); + + // and the reconciler prunes nothing: the ordering this whole feature exists for + const reconciled = await reconcileRepoRegistry({ + repoName: identity, + repoPath: to, + emit: () => {}, + log: silentLog, + }); + expect(reconciled.map((t) => t.path).sort()).toEqual([to, newTree].sort()); + expect(reconciled.find((t) => t.path === newTree)).toMatchObject({ state: "on-deck", readyStamp: "abc123" }); + }); +}); +``` + +- [ ] **Step 2: Run the test** + +Run: `bun test lib/__tests__/repo-locate-e2e.test.ts` +Expected: PASS. If the reconcile assertion fails because git still lists the old path, the repair arguments are wrong — fix `planLocate`'s `gitRepairPaths`, never the assertion. + +- [ ] **Step 3: Run the full gate** + +Run: `bunx tsc --noEmit && bun test lib commands` +Expected: no type errors; no test delta from the baseline other than the suites this plan added. Record the counts in the commit body. + +- [ ] **Step 4: Commit** + +```bash +git add lib/__tests__/repo-locate-e2e.test.ts +git commit -m "test(repos): end-to-end locate against real git state and a live reconcile" +``` + +--- + +## Self-review notes (already applied) + +- **Spec coverage:** 1→T1, 2→T2, 3→T3, 4→T5, 5→T6+T7, 6→T8, 7→T4, 8→T9, "Verification"→T10. Parity anchors: wire keys via `lib/settings/identity.ts` (T5, T7), legacy pair discovered through the index rows the additive heal leaves (T5, never by basename), `worktree-registry` mirrored in both modules (T2 keeps both), `ctx.log`-only logging (T7 adds none), module registry untouched (T8), constraint-only comments throughout. +- **Out of scope, deliberately absent:** gitq's commonDir-hash store, `uow.json`, `board.cwds`, `rt.workspacePrefs.workspaces` — those owners react to `repo:moved`, which T7 emits and nothing here consumes. +- **Type consistency:** `LocatePlan`/`LocateResult`/`LocateRefusal` field names are used identically in T5 (definition), T7 (handler), T8 (dispatcher and CLI), T9 (heal), T10 (assertions). `withReconcilerHeld`'s generic signature is identical in T6 (implementation), T7 (`ReposHandlerOpts`, `buildRoutedHandlers` opt, daemon wiring, and the `rt-client-commands` stub). `DataMigration["registry"]` gains exactly one member (`"merged"`) in T2 and is read in T2's CLI branch only. diff --git a/docs/superpowers/specs/2026-08-25-repo-locate-design.md b/docs/superpowers/specs/2026-08-25-repo-locate-design.md new file mode 100644 index 00000000..4884bf9c --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-repo-locate-design.md @@ -0,0 +1,111 @@ +# Repo locate + registry merge (RT-63, RT-68) — design + +Status: ratified 2026-08-25 (Matt: "get it done"). Binding constraints in **bold**. +Items 2, 4 and 6 were amended after implementation to state what shipped; the +rulings behind each divergence are recorded in +`.superpowers/sdd/2026-08-25-repo-locate/progress.md`. + +## Why + +A folder move keeps the repo identity (RT-62) but leaves literal paths stale in +rt's stores. The worktree reconciler (`lib/daemon/worktree-reconciler.ts`, +step (a)) prunes registry rows whose path is absent from `git worktree list`, +so an index path that heals before the registry is rewritten destroys +claimed/on-deck state and replenish mints replacement trees. Separately, the +RT-62 cutover left a name/identity index pair whose two registries each own +half of one on-deck pool, and `rt repos prune` cannot collapse it. + +**The daemon is never stopped for any of this.** Mutations of daemon-owned +state go through the daemon, never around it. + +## Scope + +1. **Registry merge primitive** (`lib/worktree/registry.ts` or sibling): + `mergeRegistries(winner: TreeRecord[], loser: TreeRecord[]): TreeRecord[]` + — union by canonical path; on a path present in both, the managed record + wins (`main`/`ephemeral` beat `unmanaged`; two managed → later `createdAt`; + tie → winner side). Pure, unit-tested. +2. **Prune uses the merge** (`migrateWorktreeRegistry` in `lib/repo-index.ts`): + when both keys of a name/identity pair own a registry, merge onto the + surviving key — the identity key always wins the pair (`partitionByRealpath` + ranks identity over a legacy name, whatever the timestamps say) — verify + persisted, delete the retired legacy key's registry; `registry: "merged"` + outcome replaces `"refused"` for that case. `rt repos prune` output names + the merge. +3. **Prune guard**: a `missing` row that owns a worktree registry is retained + (reported `retained`, reason `missing`, hint `rt repos locate`) — never + evicted while it owns data. +4. **Locate core** (`lib/repo-locate.ts`, pure of daemon/CLI): + `planLocate({ newPath, repoArg? })` → `{ identity, oldPath, indexKeys, + registryRewrites, claimRewrites, gitRepairPaths }` or a typed refusal; + `applyLocate(plan)` **repairs git before it writes anything**: `git worktree + repair ` from `newPath`, then a no-arg pass, then + verification that every re-rooted path exists on disk and appears in `git + worktree list --porcelain` (a re-rooted path missing on disk is reported as + a stale path, the reconciler's job; present-but-unlisted is a hard failure). + Until that verifies, the index still names the dead path, so a reconcile + pass that interleaves finds a gone repo and bails — and nothing is written, + which is why there is no snapshot/rollback. Only then, in one `state.db` + transaction: index rows (identity + legacy pair) → `newPath`; every registry + of the pair: prefix-replace `path` for records under `oldPath` (external + trees untouched), then merge the pair's registries via (1); the pair's + `endpoint_claims.worktree` prefix, merged onto the identity key with the + legacy key emptied. Post-commit, because file ops cannot join the + transaction: the `repos.json` mirror and the legacy index row's collapse (a + legacy row whose data could not all move is retained, written back to + `oldPath`). + - **Match by identity, never by name**: `serializeIdentity(await + deriveRepoIdentity(newPath))` must equal a lost index key, or the derived + identity of a lost legacy-name row's pair. Mismatch → refusal printing + both identities. + - `oldPath` must not exist on disk; if it does, refuse ("second clone, not + a move"). +5. **Daemon verb** `repos:locate` (`lib/daemon/handlers/repos.ts`): payload + `{ newPath, repo?, dryRun? }`; runs `applyLocate` **inside the reconciler's + in-flight guard** (new `withReconcilerHeld(fn)` on the reconciler object: + awaits any pass in flight, blocks `kick` from starting a pass until `fn` + settles), then `hooksGuard.refreshWatchedRepos()`, then emits + `repo:moved { identity, from, to }` on the events bus. Registered in + `lib/daemon/command-router.ts` like every other handler. +6. **CLI** `rt repos locate [] [--repo ] [--dry-run] [--json]` + (`commands/repos.ts`): resolves `--repo` via `resolveRepoArg`; hands the + work to the daemon verb whenever a daemon is PRESENT — a live pid file or a + socket on disk, not a ping, since a stalled daemon still owns the registry. + Present but not answering is a **hard stop** naming `rt daemon status`, + never a local apply (it would race the reconciler); only with no presence + evidence at all does the CLI run `applyLocate` locally. No ``: scan + `rt.repoRoots` (existing scanner in `lib/repo-index.ts`) for candidates + whose derived identity matches a lost row; one match → confirm; several → + picker; none → list lost rows and exit 1. Never auto-pick. +7. **Lost rows visible**: `getKnownRepos` keeps missing-path rows and marks + them `missing: true`; `rt cd` / pickers render `name (missing — rt repos + locate)` and refuse to cd into them. +8. **Implicit heal is move-aware** (RT-65 seam): `updateRepoIndex(identity, + root)` — when the stored path differs from the observed main path AND the + stored path no longer exists → route through `applyLocate` (local, or via + the daemon when it answers) instead of a bare `setKvValue`. Stored path + still exists → second clone; leave it alone (today's behavior). + +## Out of scope + +gitq's commonDir-hash store, the work-pipeline `uow.json`, `board.cwds`, +`rt.workspacePrefs.workspaces` keys — those owners react to `repo:moved` +(follow-up tickets). The RT-65 `rt worktree list` no-heal bug itself. + +## Parity anchors + +- Wire keys everywhere: `parseIdentity`/`serializeIdentity` from + `lib/settings/identity.ts`; legacy pair discovered via + `resolveIndexPathForIdentity`'s scan, never by basename. +- Registry namespace constant `worktree-registry` (`repo-index.ts` mirrors + `registry.ts`; keep both). +- Logging: handlers use `ctx.log`; no outcome logging (seams cover it). +- Module registry: any new command module referenced from `cli.ts` must be + thunked in `lib/module-registry.ts`. +- Comments: constraint-only (clean-code rule). + +## Verification + +`bunx tsc --noEmit`; `bun test lib commands packages`; a real-state dry run: +`rt repos locate --dry-run ~/Documents/GitHub/acme-dev` against a copy of +`~/.mattstack` (isolated `HOME`) after `mv`-ing a throwaway clone. diff --git a/lib/__tests__/command-tree.test.ts b/lib/__tests__/command-tree.test.ts index ab7312cd..d59fc938 100644 --- a/lib/__tests__/command-tree.test.ts +++ b/lib/__tests__/command-tree.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, afterEach, mock } from "bun:test"; +import { describe, test, expect, afterEach, mock, spyOn } from "bun:test"; import { spawnSync } from "child_process"; import { mkdtempSync, rmSync } from "fs"; import { tmpdir } from "os"; @@ -157,6 +157,46 @@ describe("dispatch --repo flag scoping", () => { expect(capturedArgs).toEqual(["--repo", "foo", "--ticket", "bar"]); }); + + // A `missing: true` row's single worktree is a dead path (its indexed + // directory no longer exists) — resolving it by name must refuse with + // missingRepoRefusal instead of chdir-ing into that path. + test('context:"worktree" node: --repo refuses before any chdir', async () => { + const real = realRepoModule; + const missingRepo: KnownRepo = { + repoName: "moved", + worktrees: [{ path: "/nonexistent/gone", branch: "", isBare: false }], + dataDir: "/fake/moved-data", + missing: true, + }; + mock.module("../repo.ts", () => ({ + ...real, + getKnownRepos: () => [missingRepo], + pickWorktreeFromRepo: async () => null, + getRepoIdentity: () => null, + })); + + const chdirSpy = spyOn(process, "chdir").mockImplementation(() => {}); + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit sentinel"); + }); + + const tree: Record = { + cmd: { description: "test", context: "worktree", handler: noop }, + }; + + try { + await expect(dispatch(tree, ["cmd", "--repo", "moved"])).rejects.toThrow("process.exit sentinel"); + expect(chdirSpy).not.toHaveBeenCalled(); + expect(exitSpy.mock.calls.at(-1)?.[0]).toBe(1); + expect(errSpy.mock.calls.flat().join(" ")).toContain("rt repos locate"); + } finally { + chdirSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); }); // ─── ANSI never reaches a pipe ─────────────────────────────────────────────── diff --git a/lib/__tests__/repo-index-missing.test.ts b/lib/__tests__/repo-index-missing.test.ts new file mode 100644 index 00000000..b5dc6112 --- /dev/null +++ b/lib/__tests__/repo-index-missing.test.ts @@ -0,0 +1,162 @@ +/** + * A moved repo's index row must stay visible: hiding it makes the repo look + * unregistered and re-registers it under a second row at the new path, which + * is the split `rt repos locate` exists to prevent. + */ + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { execSync } from "child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { closeStateDb, setKvValue } from "../state/index.ts"; +import { getKnownRepos, missingRepoRefusal, repoFromOptionValue, repoOption, repoOptions, type KnownRepo } from "../repo-index.ts"; +import { pickFromAllRepos } from "../pickers.ts"; +import { pickWorktree } from "../repo.ts"; + +describe("missing index rows", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-missing-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-missing-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + function realRepo(name: string): string { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + return dir; + } + + test("getKnownRepos() default excludes a row whose path is gone", () => { + setKvValue("repo-index", "moved", join(scratch, "gone-away")); + + expect(getKnownRepos().find((r) => r.repoName === "moved")).toBeUndefined(); + }); + + test("getKnownRepos({ includeMissing: true }) surfaces that same row, marked missing", () => { + setKvValue("repo-index", "moved", join(scratch, "gone-away")); + + const row = getKnownRepos({ includeMissing: true }).find((r) => r.repoName === "moved"); + + expect(row?.missing).toBe(true); + expect(row?.worktrees[0]?.path).toBe(join(scratch, "gone-away")); + }); + + test("a live row is never marked missing, even with includeMissing: true", () => { + setKvValue("repo-index", "alive", realRepo("alive")); + + expect(getKnownRepos({ includeMissing: true }).find((r) => r.repoName === "alive")?.missing).toBeUndefined(); + }); + + test("two lost rows for one directory collapse to a single missing entry", () => { + setKvValue("repo-index", "legacy-name", join(scratch, "gone-away")); + setKvValue("repo-index", "remote:gitlab.com%2Fg%2Fgone", join(scratch, "gone-away")); + + expect(getKnownRepos({ includeMissing: true }).filter((r) => r.missing).length).toBe(1); + }); + + test("a lost legacy-named row does not shadow a scanned directory of the same basename", () => { + // The live anchor is what makes `scratch` an inferred scan root; the lost + // row is the pre-cutover legacy name, which is the moved folder's basename. + setKvValue("repo-index", "remote:gitlab.com%2Fg%2Fanchor", realRepo("anchor")); + setKvValue("repo-index", "mu", join(scratch, "nest", "mu")); + const moved = realRepo("mu"); + + const scanned = getKnownRepos({ includeMissing: true }) + .filter((r) => r.registered === false) + .map((r) => r.worktrees[0]?.path); + + expect(scanned).toContain(moved); + }); + + test("the picker row says what to run", () => { + const opt = repoOption({ repoName: "moved", worktrees: [{ path: "/x/gone", branch: "", isBare: false }], dataDir: "/d", missing: true }); + expect(opt.hint).toBe("missing — rt repos locate"); + expect(opt.color).toBeDefined(); + }); + + test("a lost row and the scanned directory sharing its name get distinct picker values", () => { + const lost: KnownRepo = { repoName: "mu", worktrees: [{ path: "/x/gone/mu", branch: "", isBare: false }], dataDir: "/d", missing: true }; + const scanned: KnownRepo = { repoName: "mu", worktrees: [{ path: "/x/live/mu", branch: "", isBare: false }], dataDir: "/d", registered: false }; + const repos = [lost, scanned]; + + const [lostOpt, scannedOpt] = repoOptions(repos); + + expect(lostOpt!.value).not.toBe(scannedOpt!.value); + expect(repoFromOptionValue(repos, lostOpt!.value)).toBe(lost); + expect(repoFromOptionValue(repos, scannedOpt!.value)).toBe(scanned); + }); + + test("an uncontested name keeps the raw index key as its picker value", () => { + const row: KnownRepo = { repoName: "remote:gitlab.com%2Fg%2Fsolo", worktrees: [{ path: "/x/solo", branch: "", isBare: false }], dataDir: "/d" }; + + expect(repoOptions([row])[0]!.value).toBe(row.repoName); + expect(repoFromOptionValue([row], row.repoName)).toBe(row); + }); + + test("a stale missing row does not cost a single live repo its headless auto-resolve", async () => { + const live = realRepo("solo"); + setKvValue("repo-index", "remote:gitlab.com%2Fg%2Fsolo", live); + setKvValue("repo-index", "gone", join(scratch, "gone-away")); + + expect(await pickWorktree("Pick a repo")).toBe(live); + }); + + test("pickWorktree still refuses when the only row is a missing one", async () => { + setKvValue("repo-index", "gone", join(scratch, "gone-away")); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit sentinel"); + }); + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + await expect(pickWorktree("Pick a repo")).rejects.toThrow("process.exit sentinel"); + expect(exitSpy.mock.calls.at(-1)?.[0]).toBe(1); + expect(errSpy.mock.calls.flat().join(" ")).toContain("rt repos locate"); + } finally { + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); + + test("the refusal names the repo, the gone path, and the fix", () => { + const msg = missingRepoRefusal({ repoName: "moved", worktrees: [{ path: "/x/gone", branch: "", isBare: false }], dataDir: "/d", missing: true }); + expect(msg).toContain("/x/gone"); + expect(msg).toContain("rt repos locate"); + expect(msg).toContain("--repo moved"); + }); + + test("pickFromAllRepos refuses to cd into a missing repo instead of auto-selecting it", async () => { + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit sentinel"); + }); + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + await pickFromAllRepos( + [{ repoName: "moved", worktrees: [{ path: "/x/gone", branch: "", isBare: false }], dataDir: "/d", missing: true }], + { stderr: true }, + ); + throw new Error("expected pickFromAllRepos to exit"); + } catch (err) { + expect((err as Error).message).toBe("process.exit sentinel"); + expect(exitSpy.mock.calls.at(-1)?.[0]).toBe(1); + expect(errSpy.mock.calls.flat().join(" ")).toContain("rt repos locate"); + } finally { + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); +}); diff --git a/lib/__tests__/repo-index-rename.test.ts b/lib/__tests__/repo-index-rename.test.ts index b077bb09..1d9c120e 100644 --- a/lib/__tests__/repo-index-rename.test.ts +++ b/lib/__tests__/repo-index-rename.test.ts @@ -141,6 +141,16 @@ describe("repo-index — rename drift (RT-60)", () => { expect(duplicates).toEqual([{ entry: entry("old", scratch, 1_000), keptAs: "new" }]); }); + test("an identity key beats a legacy name even when the name was written last", () => { + const identity = "remote:gitlab.com%2Fg%2Fdeck"; + const { keep, duplicates } = partitionByRealpath([ + entry(identity, scratch, 1_000), + entry("deck", scratch, 9_000), + ]); + expect(keep.map((e) => e.repoName)).toEqual([identity]); + expect(duplicates.map((d) => d.keptAs)).toEqual([identity]); + }); + test("an equal timestamp — every row of one legacy import — breaks by name, not insertion order", () => { const a = partitionByRealpath([entry("zeta", scratch, 1_000), entry("alpha", scratch, 1_000)]); const b = partitionByRealpath([entry("alpha", scratch, 1_000), entry("zeta", scratch, 1_000)]); @@ -227,6 +237,29 @@ describe("repo-index — rename drift (RT-60)", () => { expect(Object.keys(mirror())).toEqual(["alive"]); }); + + test("a missing row that still owns a worktree registry is KEPT, not evicted", () => { + indexRepoAt("moved", join(scratch, "gone-away"), 1_000); + setKvValue("worktree-registry", "moved", [ + { name: "t1", path: join(scratch, "gone-away", ".worktrees", "t1"), kind: "ephemeral", state: "on-deck", branch: "on-deck/t1", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + + const removed = pruneRepoIndex(); + const row = removed.find((r) => r.repoName === "moved"); + + expect(row).toMatchObject({ reason: "missing", retained: true, hint: "rt repos locate" }); + expect(loadRepoIndexEntries().map((e) => e.repoName)).toEqual(["moved"]); + expect(listKvValues("worktree-registry")["moved"]).toBeDefined(); + }); + + test("a missing row with no registry is still evicted", () => { + indexRepoAt("gone", join(scratch, "never-existed"), 1_000); + + const removed = pruneRepoIndex(); + + expect(removed.find((r) => r.repoName === "gone")?.retained).toBeUndefined(); + expect(loadRepoIndexEntries()).toEqual([]); + }); }); // ─── data migration (RT-60) ──────────────────────────────────────────────── @@ -396,15 +429,19 @@ describe("repo-index — rename drift (RT-60)", () => { expect(Object.keys(listKvValues(WT_NS))).toEqual(["rt"]); }); - test("refuses when the live name already has one — both hold real claim state", () => { - setKvValue(WT_NS, "repo-tools", tree("/x/retired")); - setKvValue(WT_NS, "rt", tree("/x/live")); + test("merges when the live name already has one — one pool, both halves", () => { + setKvValue(WT_NS, "repo-tools", [ + { name: "t1", path: "/x/t1", kind: "ephemeral", state: "on-deck", branch: "on-deck/t1", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + setKvValue(WT_NS, "rt", [ + { name: "main", path: "/x/main", kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); const result = migrateRepoData("repo-tools", "rt"); - expect(result.registry).toBe("refused"); - expect(listKvValues(WT_NS)["rt"]).toEqual(tree("/x/live")); - expect(listKvValues(WT_NS)["repo-tools"]).toEqual(tree("/x/retired")); + expect(result.registry).toBe("merged"); + expect((listKvValues(WT_NS)["rt"] as Array<{ path: string }>).map((t) => t.path)).toEqual(["/x/main", "/x/t1"]); + expect(listKvValues(WT_NS)["repo-tools"]).toBeUndefined(); }); test("no registry under the retired name is 'none', not a failure", () => { @@ -432,7 +469,7 @@ describe("repo-index — rename drift (RT-60)", () => { expect(loadRepoIndexEntries().map((e) => e.repoName)).toEqual(["rt"]); }); - test("a refused registry KEEPS the index row — eviction is what makes a leftover unreachable", () => { + test("a merged registry is a COMPLETE migration — the retired index row is evicted", () => { const dir = realRepo("repo-tools"); indexRepoAt("repo-tools", dir, 1_000); indexRepoAt("rt", dir, 2_000); @@ -440,11 +477,20 @@ describe("repo-index — rename drift (RT-60)", () => { setKvValue(WT_NS, "rt", tree("/x/live")); const removed = pruneRepoIndex(); - const dup = removed.find((r) => r.repoName === "repo-tools"); - expect(dup?.retained).toBe(true); - expect(loadRepoIndexEntries().map((e) => e.repoName).sort()).toEqual(["repo-tools", "rt"]); + expect(removed.find((r) => r.repoName === "repo-tools")?.data?.registry).toBe("merged"); + expect(removed.find((r) => r.repoName === "repo-tools")?.retained).toBeUndefined(); + expect(loadRepoIndexEntries().map((e) => e.repoName)).toEqual(["rt"]); + }); + + test("--dry-run reports the merge without performing it", () => { + setKvValue(WT_NS, "repo-tools", tree("/x/retired")); + setKvValue(WT_NS, "rt", tree("/x/live")); + + expect(migrateRepoData("repo-tools", "rt", { dryRun: true }).registry).toBe("merged"); + expect(listKvValues(WT_NS)["repo-tools"]).toEqual(tree("/x/retired")); + expect(listKvValues(WT_NS)["rt"]).toEqual(tree("/x/live")); }); test("a refused FILE also keeps the row", () => { diff --git a/lib/__tests__/repo-index.test.ts b/lib/__tests__/repo-index.test.ts index 3a0857e1..ebcb6bb8 100644 --- a/lib/__tests__/repo-index.test.ts +++ b/lib/__tests__/repo-index.test.ts @@ -475,14 +475,13 @@ describe("repo-index — rt.repoRoots (RT-49)", () => { // ─── 13. Disposable cache ───────────────────────────────────────────────── describe("13. disposable cache", () => { - test("a pre-migration repos.json entry for a path that no longer exists is imported, then filtered from the picker", () => { + test("a stale on-disk repos.json is ignored — the store is authoritative, nothing crashes", () => { const root = mkdtempSync(join(tmpdir(), "rt-cache-root-")); const repo = markerRepo(root, "stillhere"); setRepoRoots([root]); - // A leftover pre-migration file: getKnownRepos() imports it (empty - // index, file present), but a registered path that no longer exists on - // disk is filtered out the same way it always was. + // A leftover pre-migration file with different, stale data must never + // resurface through getKnownRepos()'s default (missing rows excluded). const p = join(rtDir(), "repos.json"); mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, JSON.stringify({ "stale-repo": "/nonexistent/path" })); @@ -500,6 +499,23 @@ describe("repo-index — rt.repoRoots (RT-49)", () => { rmSync(root, { recursive: true, force: true }); }); + test("includeMissing: true surfaces that same stale entry, marked missing, instead of dropping it", () => { + const root = mkdtempSync(join(tmpdir(), "rt-cache-root-")); + const repo = markerRepo(root, "stillhere"); + setRepoRoots([root]); + + const p = join(rtDir(), "repos.json"); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, JSON.stringify({ "stale-repo": "/nonexistent/path" })); + + const repos = getKnownRepos({ includeMissing: true }); + expect(byName(repos, "stillhere")?.worktrees[0]?.path).toBe(repo); + expect(byName(repos, "stale-repo")?.missing).toBe(true); + expect(byName(repos, "stale-repo")?.worktrees[0]?.path).toBe("/nonexistent/path"); + + rmSync(root, { recursive: true, force: true }); + }); + test("a pre-migration repos.json is imported on first read, and stays in place (refreshed, never renamed) as the live compat mirror", () => { const p = join(rtDir(), "repos.json"); mkdirSync(dirname(p), { recursive: true }); diff --git a/lib/__tests__/repo-locate-dispatch.test.ts b/lib/__tests__/repo-locate-dispatch.test.ts new file mode 100644 index 00000000..5a8eaf0f --- /dev/null +++ b/lib/__tests__/repo-locate-dispatch.test.ts @@ -0,0 +1,195 @@ +/** + * lib/repo-locate-dispatch.ts's whole reason to exist is the daemon-vs-local + * decision — a present daemon must hard-stop rather than fall through to a + * local apply that would race the worktree reconciler holding the registry, + * and "present" must come from liveness evidence (a live pid, or the socket + * file existing), not a ping: an event-loop-stalled daemon fails a ping the + * same way a dead one does, and treating that as "absent" would race the very + * daemon still holding the registry. Both branches — and the true-absent + * local branch — have no real daemon process to exercise them against, so + * they are covered here by faking the transport and the presence checks. + */ + +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { locateMovedRepo } from "../repo-locate-dispatch.ts"; +import type { LocatePlan } from "../repo-locate.ts"; + +// Captured before any mock.module call — mock.module mutates the live +// namespace object in place, so restoring with the ORIGINAL bindings (not a +// re-import) is what undoes it for every other test file sharing this process. +const realDaemonClient = await import("../daemon-client.ts"); +const realDaemonSocketQuery = realDaemonClient.daemonSocketQuery; + +const realDaemonConfig = await import("../daemon-config.ts"); +const realIsDaemonProcessRunning = realDaemonConfig.isDaemonProcessRunning; +const realSockPath = realDaemonConfig.DAEMON_SOCK_PATH; + +const realRepoLocate = await import("../repo-locate.ts"); +const realPlanLocate = realRepoLocate.planLocate; + +/** A path guaranteed not to exist, for the "no socket file" half of presence. */ +const NO_SOCKET_PATH = join(tmpdir(), "rt-locate-dispatch-test-no-such-socket"); + +afterEach(() => { + mock.module("../daemon-client.ts", () => ({ + ...realDaemonClient, + daemonSocketQuery: realDaemonSocketQuery, + })); + mock.module("../daemon-config.ts", () => ({ + ...realDaemonConfig, + isDaemonProcessRunning: realIsDaemonProcessRunning, + DAEMON_SOCK_PATH: realSockPath, + })); + mock.module("../repo-locate.ts", () => ({ + ...realRepoLocate, + planLocate: realPlanLocate, + })); +}); + +describe("locateMovedRepo: presence by pid, no answer", () => { + test("a live pid with an unresponsive daemon hard-stops — planLocate never runs", async () => { + let planLocateCalled = false; + mock.module("../repo-locate.ts", () => ({ + ...realRepoLocate, + planLocate: async () => { + planLocateCalled = true; + throw new Error("must not run planLocate — the daemon is holding the registry"); + }, + })); + mock.module("../daemon-config.ts", () => ({ + ...realDaemonConfig, + isDaemonProcessRunning: () => true, + DAEMON_SOCK_PATH: NO_SOCKET_PATH, + })); + mock.module("../daemon-client.ts", () => ({ + ...realDaemonClient, + daemonSocketQuery: async () => null, // event-loop stalled, or otherwise not answering + })); + + const outcome = await locateMovedRepo({ newPath: "/wherever" }); + + expect(planLocateCalled).toBe(false); + expect(outcome).toEqual({ + via: "daemon", + ok: false, + error: "the rt daemon is present but did not answer repos:locate; not applying locally (would race the worktree reconciler) — check `rt daemon status` and retry", + }); + }); +}); + +describe("locateMovedRepo: presence by socket file, no answer", () => { + test("a socket file with no live pid still hard-stops", async () => { + const scratch = mkdtempSync(join(tmpdir(), "rt-locate-dispatch-sock-")); + const sockPath = join(scratch, "rt.sock"); + writeFileSync(sockPath, ""); // presence is decided by existence, not connectability + let planLocateCalled = false; + try { + mock.module("../repo-locate.ts", () => ({ + ...realRepoLocate, + planLocate: async () => { + planLocateCalled = true; + throw new Error("must not run planLocate — the daemon is holding the registry"); + }, + })); + mock.module("../daemon-config.ts", () => ({ + ...realDaemonConfig, + isDaemonProcessRunning: () => false, // pid file stale/absent + DAEMON_SOCK_PATH: sockPath, + })); + mock.module("../daemon-client.ts", () => ({ + ...realDaemonClient, + daemonSocketQuery: async () => null, + })); + + const outcome = await locateMovedRepo({ newPath: "/wherever" }); + + expect(planLocateCalled).toBe(false); + expect(outcome).toEqual({ + via: "daemon", + ok: false, + error: "the rt daemon is present but did not answer repos:locate; not applying locally (would race the worktree reconciler) — check `rt daemon status` and retry", + }); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); +}); + +describe("locateMovedRepo: daemon transport, present and answering", () => { + test("a daemon refusal surfaces its error verbatim", async () => { + mock.module("../daemon-config.ts", () => ({ + ...realDaemonConfig, + isDaemonProcessRunning: () => true, + DAEMON_SOCK_PATH: NO_SOCKET_PATH, + })); + mock.module("../daemon-client.ts", () => ({ + ...realDaemonClient, + daemonSocketQuery: async () => ({ ok: false, error: "not-a-git-repo: /wherever is not a git repository" }), + })); + + const outcome = await locateMovedRepo({ newPath: "/wherever" }); + + expect(outcome).toEqual({ via: "daemon", ok: false, error: "not-a-git-repo: /wherever is not a git repository" }); + }); + + test("a dry-run success unwraps the plan from the envelope", async () => { + const plan: LocatePlan = { + identity: "path:%2Fx", + oldPath: "/old", + newPath: "/new", + indexKeys: ["path:%2Fx"], + legacyKeys: [], + registryRewrites: [], + claimRewrites: [], + gitRepairPaths: [], + }; + let sentPayload: Record | undefined; + mock.module("../daemon-config.ts", () => ({ + ...realDaemonConfig, + isDaemonProcessRunning: () => true, + DAEMON_SOCK_PATH: NO_SOCKET_PATH, + })); + mock.module("../daemon-client.ts", () => ({ + ...realDaemonClient, + daemonSocketQuery: async (_cmd: string, payload?: Record) => { + sentPayload = payload; + return { ok: true, data: { dryRun: true, plan } }; + }, + })); + + const outcome = await locateMovedRepo({ newPath: "/new", repo: "path:%2Fx", dryRun: true }); + + expect(outcome).toEqual({ via: "daemon", ok: true, dryRun: true, plan }); + expect(sentPayload).toEqual({ newPath: "/new", repo: "path:%2Fx", dryRun: true }); + }); +}); + +describe("locateMovedRepo: daemon absent (no live pid, no socket file)", () => { + test("takes the local path without ever calling the daemon transport", async () => { + let daemonSocketQueryCalled = false; + mock.module("../daemon-config.ts", () => ({ + ...realDaemonConfig, + isDaemonProcessRunning: () => false, + DAEMON_SOCK_PATH: NO_SOCKET_PATH, + })); + mock.module("../daemon-client.ts", () => ({ + ...realDaemonClient, + daemonSocketQuery: async () => { + daemonSocketQueryCalled = true; + return null; + }, + })); + mock.module("../repo-locate.ts", () => ({ + ...realRepoLocate, + planLocate: async () => ({ refusal: "not-a-git-repo" as const, message: "/wherever is not a git repository" }), + })); + + const outcome = await locateMovedRepo({ newPath: "/wherever" }); + + expect(daemonSocketQueryCalled).toBe(false); + expect(outcome).toEqual({ via: "local", ok: false, error: "not-a-git-repo: /wherever is not a git repository" }); + }); +}); diff --git a/lib/__tests__/repo-locate-e2e.test.ts b/lib/__tests__/repo-locate-e2e.test.ts new file mode 100644 index 00000000..985c3883 --- /dev/null +++ b/lib/__tests__/repo-locate-e2e.test.ts @@ -0,0 +1,196 @@ +/** + * The whole story against real state: a repo with a linked worktree under + * `.worktrees/`, an ephemeral on-deck record, and a live endpoint claim, moved + * on disk and then located. The assertion that matters most is the last one — + * a reconcile pass over the located repo must prune nothing. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "child_process"; +import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import type { Logger } from "pino"; +import { closeStateDb, listEndpointClaims, setKvValue } from "../state/index.ts"; +import { loadRepoIndex, pruneRepoIndex } from "../repo-index.ts"; +import { loadRegistry, saveRegistry } from "../worktree/registry.ts"; +import { saveClaims } from "../endpoint/store.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../settings/identity.ts"; +import { applyLocate, isRefusal, planLocate } from "../repo-locate.ts"; +import { reconcileRepoRegistry } from "../daemon/worktree-reconciler.ts"; + +const silentLog = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as unknown as Logger; + +describe("repo locate — real state", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-e2e-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-e2e-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + test("a moved repo with a claimed pool survives locate intact", async () => { + // ── a throwaway repo with a linked worktree under .worktrees/ + const dir = join(scratch, "acme-dev"); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync("git remote add origin https://gitlab.com/acme/acme-dev.git", { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const oldTree = join(from, ".worktrees", "tree-1"); + execSync(`git worktree add -q -b on-deck/tree-1 ${oldTree}`, { cwd: from, stdio: "pipe" }); + + // ── registered in an isolated HOME's state.db: index row, registry with an + // ephemeral on-deck record, and an endpoint_claims row + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + saveRegistry(identity, [ + { name: "main", path: from, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }, + { + name: "tree-1", + path: oldTree, + kind: "ephemeral", + state: "on-deck", + branch: "on-deck/tree-1", + createdAt: "2026-01-02T00:00:00.000Z", + readyAt: "2026-01-02T01:00:00.000Z", + readyStamp: "abc123", + }, + ]); + saveClaims(identity, [{ worktree: oldTree, role: "web", port: 4010, pid: 4242, ts: "2026-01-02T02:00:00.000Z" }]); + + // ── mv the repo + const to = join(scratch, "moved", "acme-dev"); + mkdirSync(join(scratch, "moved"), { recursive: true }); + renameSync(from, to); + const newTree = join(to, ".worktrees", "tree-1"); + + // ── locate it, locally + const plan = await planLocate({ newPath: to }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + expect(result.ok).toBe(true); + expect(result.error).toBeUndefined(); + + // index path updated + expect(loadRepoIndex()[identity]).toBe(to); + + // registry record path updated, state intact + const trees = loadRegistry(identity); + expect(trees.map((t) => t.path).sort()).toEqual([to, newTree].sort()); + expect(trees.find((t) => t.path === newTree)).toMatchObject({ + kind: "ephemeral", + state: "on-deck", + branch: "on-deck/tree-1", + readyStamp: "abc123", + }); + + // claim row updated + expect(listEndpointClaims(identity)).toEqual([ + { worktree: newTree, role: "web", port: 4010, pid: 4242, ts: "2026-01-02T02:00:00.000Z" }, + ]); + + // git worktree list shows the new path (and not the old one) + const listed = execSync("git worktree list --porcelain", { cwd: to, encoding: "utf8" }); + expect(listed).toContain(newTree); + expect(listed).not.toContain(oldTree); + + // no prunable entries + expect(pruneRepoIndex({ dryRun: true })).toEqual([]); + + // and the reconciler prunes nothing: the ordering this whole feature exists for + const reconciled = await reconcileRepoRegistry({ + repoName: identity, + repoPath: to, + emit: () => {}, + log: silentLog, + }); + expect(reconciled.map((t) => t.path).sort()).toEqual([to, newTree].sort()); + expect(reconciled.find((t) => t.path === newTree)).toMatchObject({ state: "on-deck", readyStamp: "abc123" }); + }); + + test("a split pair's two half-pools survive the move as one, and an external tree stays where it is", async () => { + // ── the shape the identity cutover left: a name row and an identity row + // for one repo, each registry owning half of one on-deck pool + const dir = join(scratch, "split-dev"); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync("git remote add origin https://gitlab.com/acme/split-dev.git", { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const treeA = join(from, ".worktrees", "tree-a"); + const treeB = join(from, ".worktrees", "tree-b"); + const external = join(scratch, "external-tree"); + execSync(`git worktree add -q -b on-deck/tree-a ${treeA}`, { cwd: from, stdio: "pipe" }); + execSync(`git worktree add -q -b on-deck/tree-b ${treeB}`, { cwd: from, stdio: "pipe" }); + execSync(`git worktree add -q -b side ${external}`, { cwd: from, stdio: "pipe" }); + + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + setKvValue("repo-index", "split-dev", from); + saveRegistry(identity, [ + { name: "main", path: from, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }, + { + name: "tree-a", + path: treeA, + kind: "ephemeral", + state: "on-deck", + branch: "on-deck/tree-a", + createdAt: "2026-01-02T00:00:00.000Z", + readyStamp: "aaa", + }, + ]); + saveRegistry("split-dev", [ + { + name: "tree-b", + path: treeB, + kind: "ephemeral", + state: "on-deck", + branch: "on-deck/tree-b", + createdAt: "2026-01-03T00:00:00.000Z", + readyStamp: "bbb", + }, + { name: "side", path: external, kind: "unmanaged", branch: "side", createdAt: "2026-01-04T00:00:00.000Z" }, + ]); + + const to = join(scratch, "moved", "split-dev"); + mkdirSync(join(scratch, "moved"), { recursive: true }); + renameSync(from, to); + const newA = join(to, ".worktrees", "tree-a"); + const newB = join(to, ".worktrees", "tree-b"); + + const plan = await planLocate({ newPath: to }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + expect(result.ok).toBe(true); + expect(result.legacyRows).toEqual([{ key: "split-dev", outcome: "collapsed" }]); + + expect(loadRepoIndex()[identity]).toBe(to); + expect(loadRepoIndex()["split-dev"]).toBeUndefined(); + + // git still lists the tree that did NOT move, under its own path + expect(execSync("git worktree list --porcelain", { cwd: to, encoding: "utf8" })).toContain(external); + + const reconciled = await reconcileRepoRegistry({ + repoName: identity, + repoPath: to, + emit: () => {}, + log: silentLog, + }); + expect(reconciled.map((t) => t.path).sort()).toEqual([to, newA, newB, external].sort()); + expect(reconciled.find((t) => t.path === newA)).toMatchObject({ state: "on-deck", readyStamp: "aaa" }); + expect(reconciled.find((t) => t.path === newB)).toMatchObject({ state: "on-deck", readyStamp: "bbb" }); + expect(reconciled.find((t) => t.path === external)).toMatchObject({ name: "side", kind: "unmanaged" }); + }); +}); diff --git a/lib/__tests__/repo-locate-heal.test.ts b/lib/__tests__/repo-locate-heal.test.ts new file mode 100644 index 00000000..2a1a0b71 --- /dev/null +++ b/lib/__tests__/repo-locate-heal.test.ts @@ -0,0 +1,147 @@ +/** + * The implicit heal must move a repo, not re-point one row of it: the sync + * seam is reachable from the daemon thread (no sync git there) and cannot + * await `git worktree repair`, so it declines the write and the async seam + * performs the whole locate. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "child_process"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { closeStateDb, setKvValue } from "../state/index.ts"; +import { loadRepoIndex, updateRepoIndex, updateRepoIndexAsync } from "../repo-index.ts"; +import { loadRegistry, saveRegistry } from "../worktree/registry.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../settings/identity.ts"; + +describe("move-aware index heal", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-heal-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-heal-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + async function movedRepo(name: string): Promise<{ identity: string; from: string; to: string; tree: string }> { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync(`git remote add origin https://gitlab.com/g/${name}.git`, { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const tree = join(from, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${tree}`, { cwd: from, stdio: "pipe" }); + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + saveRegistry(identity, [ + { name: "main", path: from, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }, + { name: "t1", path: tree, kind: "ephemeral", state: "on-deck", branch: "feat", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + const to = join(scratch, `${name}-moved`); + renameSync(from, to); + return { identity, from, to, tree }; + } + + test("the sync seam refuses to re-point a row whose stored path is gone", async () => { + const { identity, from, to } = await movedRepo("alpha"); + + updateRepoIndex(identity, to); + + expect(loadRepoIndex()[identity]).toBe(from); + }); + + test("the sync seam still writes a live path and a brand-new row", async () => { + const dir = join(scratch, "beta"); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + const live = realpathSync(dir); + + updateRepoIndex("beta-key", live); + expect(loadRepoIndex()["beta-key"]).toBe(live); + + updateRepoIndex("beta-key", live); + expect(loadRepoIndex()["beta-key"]).toBe(live); + }); + + test("the sync seam overwrites a stored path that still exists — a second clone, not a move", () => { + const first = join(scratch, "eps-a"); + const second = join(scratch, "eps-b"); + for (const dir of [first, second]) { + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + } + + updateRepoIndex("eps-key", realpathSync(first)); + updateRepoIndex("eps-key", realpathSync(second)); + + expect(loadRepoIndex()["eps-key"]).toBe(realpathSync(second)); + }); + + test("the async seam heals the move as one unit", async () => { + const { identity, to } = await movedRepo("gamma"); + + expect(await updateRepoIndexAsync(identity, to)).toEqual({ ok: true, healed: true }); + + expect(loadRepoIndex()[identity]).toBe(to); + expect(loadRegistry(identity).map((t) => t.path).sort()).toEqual( + [to, join(to, ".worktrees", "t1")].sort(), + ); + expect(loadRegistry(identity).find((t) => t.path === join(to, ".worktrees", "t1"))?.state).toBe("on-deck"); + expect(execSync("git worktree list --porcelain", { cwd: to, encoding: "utf8" })).toContain(join(to, ".worktrees", "t1")); + }); + + test("the async seam is a plain write when nothing moved", async () => { + const dir = join(scratch, "delta"); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + const live = realpathSync(dir); + + expect(await updateRepoIndexAsync("delta-key", live)).toEqual({ ok: true, healed: false }); + + expect(loadRepoIndex()["delta-key"]).toBe(live); + }); + + test("a refused locate is returned, not swallowed, and leaves the row naming the gone path", async () => { + const dir = join(scratch, "zeta"); + mkdirSync(dir, { recursive: true }); + // No origin remote: the repo is identified BY its main worktree path, so + // moving it mints a new identity and locate refuses rather than re-keying. + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + const to = join(scratch, "zeta-moved"); + renameSync(from, to); + + const outcome = await updateRepoIndexAsync(identity, to); + + expect(outcome.ok).toBe(false); + expect(outcome.ok === false && outcome.error).toContain("identity-changed"); + expect(loadRepoIndex()[identity]).toBe(from); + }); + + test("repo-index.ts never statically imports the locate dispatcher or the daemon client", () => { + // The dynamic import is what breaks the repo-locate -> repo-index cycle + // AND keeps daemon-client off every rt command's startup path; a static + // one reintroduces both at once. + const source = readFileSync(join(import.meta.dir, "..", "repo-index.ts"), "utf8"); + const offenders = source + .split("\n") + .filter((line) => /\bfrom\s*["'][^"']*(repo-locate-dispatch|daemon-client)/.test(line)); + + expect(offenders).toEqual([]); + }); +}); diff --git a/lib/__tests__/repo-locate.test.ts b/lib/__tests__/repo-locate.test.ts new file mode 100644 index 00000000..d565b4ed --- /dev/null +++ b/lib/__tests__/repo-locate.test.ts @@ -0,0 +1,462 @@ +/** + * The locate core: plan a move by identity, then apply index + registry + + * claim + git-admin rewrites as one unit. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "child_process"; +import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { repoDataDir } from "../rt-paths.ts"; +import { closeStateDb, listEndpointClaims, setKvValue } from "../state/index.ts"; +import { loadRepoIndex, REPO_INDEX_NS } from "../repo-index.ts"; +import { loadRegistry, saveRegistry, type TreeRecord } from "../worktree/registry.ts"; +import { saveClaims } from "../endpoint/store.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../settings/identity.ts"; +import { applyLocate, findLocateCandidates, isRefusal, planLocate } from "../repo-locate.ts"; + +describe("repo locate", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + /** A repo with an origin remote, so its identity is remote-kind and survives the move. */ + function repoWithRemote(name: string): string { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync(`git remote add origin https://gitlab.com/g/${name}.git`, { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + return realpathSync(dir); + } + + function localRepo(name: string): string { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + return realpathSync(dir); + } + + function rec(over: Partial & { path: string }): TreeRecord { + return { name: "t", kind: "unmanaged", branch: null, createdAt: "2026-01-01T00:00:00.000Z", ...over }; + } + + test("a directory that is not a git repo is refused", async () => { + const plain = join(scratch, "plain"); + mkdirSync(plain); + const out = await planLocate({ newPath: plain }); + expect(isRefusal(out) && out.refusal).toBe("not-a-git-repo"); + }); + + test("nothing lost in the index is refused", async () => { + const repo = repoWithRemote("alpha"); + const out = await planLocate({ newPath: repo }); + expect(isRefusal(out) && out.refusal).toBe("nothing-lost"); + }); + + test("a derived identity matching no lost row refuses and names both sides", async () => { + setKvValue(REPO_INDEX_NS, "remote:gitlab.com%2Fg%2Fsomething-else", join(scratch, "gone")); + const repo = repoWithRemote("beta"); + + const out = await planLocate({ newPath: repo }); + + expect(isRefusal(out) && out.refusal).toBe("identity-mismatch"); + expect(isRefusal(out) && out.message).toContain("remote:gitlab.com%2Fg%2Fbeta"); + expect(isRefusal(out) && out.message).toContain("remote:gitlab.com%2Fg%2Fsomething-else"); + }); + + test("a remote-less repo is refused: its identity IS its path, so a move mints a new one", async () => { + setKvValue(REPO_INDEX_NS, `path:${encodeURIComponent(join(scratch, "gone"))}`, join(scratch, "gone")); + const repo = localRepo("gamma"); + + const out = await planLocate({ newPath: repo }); + + expect(isRefusal(out) && out.refusal).toBe("identity-changed"); + expect(isRefusal(out) && out.message).toContain("rt repos register"); + }); + + test("an old path that still exists is a second clone, not a move", async () => { + const original = repoWithRemote("delta"); + const clone = join(scratch, "delta-clone"); + execSync(`git clone -q ${original} ${clone}`, { stdio: "pipe" }); + execSync(`git remote set-url origin https://gitlab.com/g/delta.git`, { cwd: clone, stdio: "pipe" }); + setKvValue(REPO_INDEX_NS, serializeIdentity(await deriveRepoIdentity(original)), original); + + const out = await planLocate({ newPath: realpathSync(clone) }); + + expect(isRefusal(out) && out.refusal).toBe("old-path-exists"); + }); + + test("plans the index keys, registry rewrite, claim rewrite and repair paths of a moved repo", async () => { + const repo = repoWithRemote("epsilon"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue(REPO_INDEX_NS, identity, repo); + setKvValue(REPO_INDEX_NS, "epsilon-legacy", repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + saveRegistry("epsilon-legacy", [rec({ name: "t1", path: treePath, kind: "ephemeral", state: "on-deck", branch: "feat" })]); + saveClaims(identity, [{ worktree: treePath, role: "web", port: 4001, ts: "2026-01-01T00:00:00.000Z" }]); + + const moved = join(scratch, "epsilon-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + + expect(plan.identity).toBe(identity); + expect(plan.oldPath).toBe(repo); + expect(plan.newPath).toBe(moved); + expect(plan.indexKeys.sort()).toEqual([identity, "epsilon-legacy"].sort()); + expect(plan.legacyKeys).toEqual(["epsilon-legacy"]); + expect(plan.gitRepairPaths).toEqual([join(moved, ".worktrees", "t1")]); + expect(plan.claimRewrites).toEqual([ + { repoKey: identity, worktree: treePath, newWorktree: join(moved, ".worktrees", "t1") }, + ]); + }); + + test("apply re-points the index, merges the pair's registries, rewrites claims and repairs git", async () => { + const repo = repoWithRemote("zeta"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue(REPO_INDEX_NS, identity, repo); + setKvValue(REPO_INDEX_NS, "zeta-legacy", repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + saveRegistry("zeta-legacy", [rec({ name: "t1", path: treePath, kind: "ephemeral", state: "claimed", owner: "matt", branch: "feat" })]); + saveClaims(identity, [{ worktree: treePath, role: "web", port: 4001, ts: "2026-01-01T00:00:00.000Z" }]); + + const moved = join(scratch, "zeta-moved"); + renameSync(repo, moved); + const newTree = join(moved, ".worktrees", "t1"); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + + expect(result.ok).toBe(true); + expect(loadRepoIndex()[identity]).toBe(moved); + expect(loadRepoIndex()["zeta-legacy"]).toBeUndefined(); + expect(loadRegistry(identity).map((t) => t.path).sort()).toEqual([moved, newTree].sort()); + expect(loadRegistry(identity).find((t) => t.path === newTree)).toMatchObject({ state: "claimed", owner: "matt" }); + expect(listEndpointClaims(identity)[0]?.worktree).toBe(newTree); + expect( + execSync("git worktree list --porcelain", { cwd: moved, encoding: "utf8" }), + ).toContain(newTree); + expect(result.legacyRows).toEqual([{ key: "zeta-legacy", outcome: "collapsed" }]); + }); + + test("a worktree outside the moved tree keeps its own path", async () => { + const repo = repoWithRemote("iota"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const inTree = join(repo, ".worktrees", "t1"); + const external = join(scratch, "iota-external"); + execSync(`git worktree add -q -b feat ${inTree}`, { cwd: repo, stdio: "pipe" }); + execSync(`git worktree add -q -b other ${external}`, { cwd: repo, stdio: "pipe" }); + setKvValue(REPO_INDEX_NS, identity, repo); + saveRegistry(identity, [ + rec({ name: "main", path: repo, kind: "main", branch: "main" }), + rec({ name: "t1", path: inTree, kind: "ephemeral", state: "on-deck", branch: "feat" }), + rec({ name: "ext", path: external, kind: "ephemeral", state: "claimed", owner: "matt", branch: "other" }), + ]); + + const moved = join(scratch, "iota-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + + expect(plan.gitRepairPaths).toEqual([join(moved, ".worktrees", "t1")]); + expect(result.ok).toBe(true); + expect(loadRegistry(identity).find((t) => t.name === "ext")).toMatchObject({ + path: external, + state: "claimed", + owner: "matt", + }); + }); + + test("a registry record whose tree is gone is reported stale, not a failure", async () => { + const repo = repoWithRemote("eta"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + setKvValue(REPO_INDEX_NS, identity, repo); + saveRegistry(identity, [ + rec({ name: "main", path: repo, kind: "main", branch: "main" }), + rec({ name: "ghost", path: join(repo, ".worktrees", "ghost"), kind: "ephemeral", state: "on-deck" }), + ]); + + const moved = join(scratch, "eta-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + + expect(result.ok).toBe(true); + expect(result.stalePaths).toEqual([join(moved, ".worktrees", "ghost")]); + expect(loadRepoIndex()[identity]).toBe(moved); + }); + + test("a failed verification writes nothing to state.db", async () => { + const repo = repoWithRemote("theta"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue(REPO_INDEX_NS, identity, repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + saveClaims(identity, [{ worktree: treePath, role: "web", port: 4001, ts: "2026-01-01T00:00:00.000Z" }]); + + const moved = join(scratch, "theta-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + // A directory that exists but git will never list: the exact shape a + // failed `git worktree repair` leaves behind. + const decoy = join(moved, "decoy"); + mkdirSync(decoy, { recursive: true }); + plan.registryRewrites[0]!.movedPaths.push(decoy); + + const result = await applyLocate(plan); + + expect(result.ok).toBe(false); + expect(result.error).toContain(decoy); + expect(loadRepoIndex()[identity]).toBe(repo); + expect(loadRegistry(identity)[0]?.path).toBe(repo); + expect(listEndpointClaims(identity)[0]?.worktree).toBe(treePath); + }); + + test("a linked worktree is refused as the new root", async () => { + const repo = repoWithRemote("lambda"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue(REPO_INDEX_NS, identity, join(scratch, "gone")); + + const out = await planLocate({ newPath: treePath }); + + expect(isRefusal(out) && out.refusal).toBe("not-main-worktree"); + }); + + test("a registry record written between plan and apply is moved, not overwritten", async () => { + const repo = repoWithRemote("pi"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue(REPO_INDEX_NS, identity, repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + + const moved = join(scratch, "pi-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + // The daemon provisioning a tree while the operator confirms the move: it + // writes the pre-move spelling, because the index still says so. + saveRegistry(identity, [ + ...loadRegistry(identity), + rec({ name: "t1", path: treePath, kind: "ephemeral", state: "claimed", owner: "matt", branch: "feat" }), + ]); + + const result = await applyLocate(plan); + + expect(result.ok).toBe(true); + expect(loadRegistry(identity).map((t) => t.path).sort()).toEqual( + [moved, join(moved, ".worktrees", "t1")].sort(), + ); + expect(loadRegistry(identity).find((t) => t.name === "t1")).toMatchObject({ state: "claimed", owner: "matt" }); + }); + + test("a failed git repair aborts before anything is written", async () => { + const repo = repoWithRemote("omicron"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue(REPO_INDEX_NS, identity, repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + + const moved = join(scratch, "omicron-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + // A real directory git cannot repair — `git worktree repair` exits 1 on it. + const decoy = join(moved, "decoy"); + mkdirSync(decoy, { recursive: true }); + plan.gitRepairPaths.push(decoy); + + const result = await applyLocate(plan); + + expect(result.ok).toBe(false); + expect(result.error).toContain("git worktree repair failed"); + expect(result.repaired).toEqual([]); + expect(loadRepoIndex()[identity]).toBe(repo); + expect(loadRegistry(identity)[0]?.path).toBe(repo); + }); + + test("verification rejects a plan rooted at a linked worktree, whatever built it", async () => { + const repo = repoWithRemote("xi"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue(REPO_INDEX_NS, identity, join(scratch, "gone")); + + const result = await applyLocate({ + identity, + oldPath: join(scratch, "gone"), + newPath: treePath, + indexKeys: [identity], + legacyKeys: [], + registryRewrites: [], + claimRewrites: [], + gitRepairPaths: [], + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("not the main worktree"); + expect(loadRepoIndex()[identity]).toBe(join(scratch, "gone")); + }); + + test("a claim whose worktree lies outside the moved root is left untouched", async () => { + const repo = repoWithRemote("nu"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const inTree = join(repo, ".worktrees", "t1"); + const external = join(scratch, "nu-external"); + execSync(`git worktree add -q -b feat ${inTree}`, { cwd: repo, stdio: "pipe" }); + execSync(`git worktree add -q -b other ${external}`, { cwd: repo, stdio: "pipe" }); + setKvValue(REPO_INDEX_NS, identity, repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + saveClaims(identity, [ + { worktree: inTree, role: "web", port: 4001, ts: "2026-01-01T00:00:00.000Z" }, + { worktree: external, role: "web", port: 4002, ts: "2026-01-01T00:00:00.000Z" }, + ]); + + const moved = join(scratch, "nu-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + + expect(result.ok).toBe(true); + expect(result.claimsRewritten).toBe(1); + expect(listEndpointClaims(identity).map((c) => c.worktree).sort()).toEqual( + [external, join(moved, ".worktrees", "t1")].sort(), + ); + }); + + test("a legacy key's claims land on the identity, re-rooted, and are left nowhere else", async () => { + const repo = repoWithRemote("rho"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue(REPO_INDEX_NS, identity, repo); + setKvValue(REPO_INDEX_NS, "rho-legacy", repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + saveClaims(identity, [{ worktree: treePath, role: "web", port: 4001, ts: "2026-01-01T00:00:00.000Z" }]); + // The `web` row collides with the identity's on (worktree, role); `api` does not. + saveClaims("rho-legacy", [ + { worktree: treePath, role: "web", port: 4999, ts: "2026-01-01T00:00:00.000Z" }, + { worktree: treePath, role: "api", port: 4002, ts: "2026-01-01T00:00:00.000Z" }, + ]); + + const moved = join(scratch, "rho-moved"); + renameSync(repo, moved); + const newTree = join(moved, ".worktrees", "t1"); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + + expect(result.ok).toBe(true); + expect(listEndpointClaims("rho-legacy")).toEqual([]); + expect(listEndpointClaims(identity).map((c) => [c.worktree, c.role, c.port])).toEqual([ + [newTree, "api", 4002], + [newTree, "web", 4001], + ]); + }); + + test("a legacy row whose data dir cannot all move keeps naming the OLD path", async () => { + const repo = repoWithRemote("sigma"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue(REPO_INDEX_NS, identity, repo); + setKvValue(REPO_INDEX_NS, "sigma-legacy", repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + saveRegistry("sigma-legacy", [rec({ name: "t1", path: treePath, kind: "ephemeral", state: "on-deck", branch: "feat" })]); + // The one collision migrateRepoData refuses to guess at: the same filename + // under both names. + for (const key of [identity, "sigma-legacy"]) { + mkdirSync(repoDataDir(key), { recursive: true }); + writeFileSync(join(repoDataDir(key), "notes.json"), "{}"); + } + + const moved = join(scratch, "sigma-moved"); + renameSync(repo, moved); + const newTree = join(moved, ".worktrees", "t1"); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + + expect(result.ok).toBe(true); + expect(result.legacyRows).toEqual([ + { key: "sigma-legacy", outcome: "retained", reason: "both names hold notes.json" }, + ]); + expect(loadRepoIndex()["sigma-legacy"]).toBe(repo); + expect(loadRepoIndex()[identity]).toBe(moved); + expect(loadRegistry(identity).map((t) => t.path).sort()).toEqual([moved, newTree].sort()); + expect(loadRegistry("sigma-legacy")).toEqual([]); + }); + + test("candidates pair a scanned directory with the lost row it derives", async () => { + const repo = repoWithRemote("kappa"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + setKvValue(REPO_INDEX_NS, identity, repo); + + const moved = join(scratch, "kappa-moved"); + renameSync(repo, moved); + + expect(await findLocateCandidates()).toEqual([{ path: moved, identity }]); + }); + + test("a lost legacy row named after the moved folder does not hide it from the candidates", async () => { + // The live anchor is what makes `scratch` a scan root once the pair's own + // parent stops naming one. + const anchor = repoWithRemote("anchor"); + setKvValue(REPO_INDEX_NS, serializeIdentity(await deriveRepoIdentity(anchor)), anchor); + + const nest = join(scratch, "nest"); + mkdirSync(nest, { recursive: true }); + const repo = join(nest, "mu"); + mkdirSync(repo, { recursive: true }); + execSync("git init -q -b main", { cwd: repo, stdio: "pipe" }); + execSync("git remote add origin https://gitlab.com/g/mu.git", { cwd: repo, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: repo, stdio: "pipe" }); + const identity = serializeIdentity(await deriveRepoIdentity(realpathSync(repo))); + setKvValue(REPO_INDEX_NS, identity, realpathSync(repo)); + setKvValue(REPO_INDEX_NS, "mu", realpathSync(repo)); + + const moved = join(scratch, "mu"); + renameSync(repo, moved); + + expect(await findLocateCandidates()).toEqual([{ path: moved, identity }]); + }); +}); diff --git a/lib/command-tree-def.ts b/lib/command-tree-def.ts index e00b2bfe..1644761a 100644 --- a/lib/command-tree-def.ts +++ b/lib/command-tree-def.ts @@ -1010,6 +1010,17 @@ export const TREE: Record = { SETUP_JSON_ARG, ], }, + locate: { + description: "Tell rt where a repo moved to — re-points the index, worktree registry, endpoint claims and git's worktree admin files together", + module: "./commands/repos.ts", + fn: "reposLocate", + args: [ + { name: "New path", type: "text", placeholder: "/path/to/moved-repo", hint: "Where the repo lives now; omit to pick from candidates under rt.repoRoots" }, + { name: "Repo", flag: "--repo", type: "text", placeholder: "repo-tools", hint: "Which indexed repo moved (identity, path, or name); omit to match by the new path's own identity" }, + { name: "Dry run", flag: "--dry-run", type: "boolean", default: false, hint: "Print what would be re-pointed without writing" }, + SETUP_JSON_ARG, + ], + }, }, }, diff --git a/lib/command-tree.ts b/lib/command-tree.ts index f316d5b3..52ab1d7d 100644 --- a/lib/command-tree.ts +++ b/lib/command-tree.ts @@ -246,8 +246,8 @@ export async function dispatch( if (repoFlag) { // --repo provided: resolve that repo and show worktree picker (skip repo picker + cwd detection) - const { getKnownRepos, pickWorktreeFromRepo, getRepoIdentity } = await import("./repo.ts"); - const repos = getKnownRepos(); + const { getKnownRepos, pickWorktreeFromRepo, getRepoIdentity, missingRepoRefusal } = await import("./repo.ts"); + const repos = getKnownRepos({ includeMissing: true }); const repo = repos.find(r => r.repoName === repoFlag); if (!repo) { const { yellow } = await import("./tui.ts"); @@ -255,6 +255,12 @@ export async function dispatch( console.error(` ${dim}known: ${repos.map(r => r.repoName).join(", ")}${reset}\n`); process.exit(1); } + // A missing row still resolves by name (that's the point — locate it), + // but its one synthetic worktree is a dead path: never chdir into it. + if (repo.missing) { + console.error(`\n ${missingRepoRefusal(repo)}\n`); + process.exit(1); + } if (repo.worktrees.length === 1) { process.chdir(repo.worktrees[0]!.path); } else { @@ -340,7 +346,10 @@ export async function dispatch( const { pickWorktreeWithSwitch, pickFromAllRepos, isSwitchRepo } = await import("./pickers.ts"); - const repos = getKnownRepos(); + // includeMissing: true so pickFromAllRepos's missing guard (below, via + // "Switch repo") actually sees a lost row instead of a silently + // filtered list. + const repos = getKnownRepos({ includeMissing: true }); // KnownRepo.repoName holds the index key — a serialized identity, not // the display name ctx.identity.repoName carries. const currentRepo = repos.find(r => r.repoName === ctx.identity!.identity); diff --git a/lib/daemon.ts b/lib/daemon.ts index 971bdbaf..e793c31c 100644 --- a/lib/daemon.ts +++ b/lib/daemon.ts @@ -443,6 +443,10 @@ export function startDaemon(): void { }, eventsBus, homeSnapshot, + repos: { + withReconcilerHeld: worktreeReconciler.withReconcilerHeld, + refreshWatchedRepos: hooksGuard.refreshWatchedRepos, + }, chatDb: getStateDb("daemon"), }); diff --git a/lib/daemon/__tests__/reconciler-hold.test.ts b/lib/daemon/__tests__/reconciler-hold.test.ts new file mode 100644 index 00000000..ee300dea --- /dev/null +++ b/lib/daemon/__tests__/reconciler-hold.test.ts @@ -0,0 +1,116 @@ +/** + * The hold `repos:locate` runs inside: a reconcile pass that observed a healed + * index path against un-rewritten registry paths prunes every registry row as + * "no matching worktree", taking the pool's claim state with it. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, realpathSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import type { Logger } from "pino"; +import { closeStateDb } from "../../state/index.ts"; +import { createWorktreeReconciler } from "../worktree-reconciler.ts"; + +const silentLog = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as unknown as Logger; + +/** An empty index makes each pass a no-op with real awaits — enough to observe pass boundaries without any git. */ +function harness(order: string[]) { + return createWorktreeReconciler({ + cache: { entries: {} }, + repoIndex: () => { + order.push("pass"); + return {}; + }, + emit: () => {}, + log: silentLog, + }); +} + +async function settle(reconciler: { passInFlight: () => boolean }): Promise { + for (let i = 0; i < 200 && reconciler.passInFlight(); i++) await Bun.sleep(5); +} + +describe("withReconcilerHeld", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-hold-home-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(async () => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + }); + + test("a pass already in flight finishes before the held fn runs", async () => { + const order: string[] = []; + const reconciler = harness(order); + + reconciler.kick(); + await reconciler.withReconcilerHeld(async () => { + order.push("fn"); + }); + + expect(order).toEqual(["pass", "fn"]); + }); + + test("a kick during the hold starts no pass until the fn settles", async () => { + const order: string[] = []; + const reconciler = harness(order); + + await reconciler.withReconcilerHeld(async () => { + reconciler.kick(); + await Bun.sleep(10); + expect(order).toEqual([]); + order.push("fn-done"); + }); + + await settle(reconciler); + expect(order).toEqual(["fn-done", "pass"]); + }); + + test("two holders serialize", async () => { + const order: string[] = []; + const reconciler = harness(order); + + await Promise.all([ + reconciler.withReconcilerHeld(async () => { + order.push("a-start"); + await Bun.sleep(10); + order.push("a-end"); + }), + reconciler.withReconcilerHeld(async () => { + order.push("b-start"); + await Bun.sleep(1); + order.push("b-end"); + }), + ]); + + expect(order).toEqual(["a-start", "a-end", "b-start", "b-end"]); + }); + + test("a throwing fn releases the hold", async () => { + const order: string[] = []; + const reconciler = harness(order); + + await expect( + reconciler.withReconcilerHeld(async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + + reconciler.kick(); + await settle(reconciler); + expect(order).toEqual(["pass"]); + }); + + test("the fn's value comes back to the caller", async () => { + const reconciler = harness([]); + expect(await reconciler.withReconcilerHeld(async () => 42)).toBe(42); + }); +}); diff --git a/lib/daemon/__tests__/repos-handlers.test.ts b/lib/daemon/__tests__/repos-handlers.test.ts new file mode 100644 index 00000000..6edd6010 --- /dev/null +++ b/lib/daemon/__tests__/repos-handlers.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "child_process"; +import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { closeStateDb, setKvValue } from "../../state/index.ts"; +import { loadRepoIndex } from "../../repo-index.ts"; +import { saveRegistry } from "../../worktree/registry.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../../settings/identity.ts"; +import { createReposHandlers } from "../handlers/repos.ts"; + +describe("repos:locate", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + let order: string[]; + let events: { topic: string; payload: unknown }[]; + let handlers: ReturnType; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-repos-handler-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-repos-handler-repos-"))); + process.env.HOME = home; + closeStateDb(); + order = []; + events = []; + handlers = createReposHandlers({ + withReconcilerHeld: async (fn) => { + order.push("hold-start"); + try { + return await fn(); + } finally { + order.push("hold-end"); + } + }, + refreshWatchedRepos: () => order.push("refresh"), + emitEvent: (topic, payload) => { + order.push(`emit:${topic}`); + events.push({ topic, payload }); + }, + }); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + async function movedRepo(name: string): Promise<{ identity: string; from: string; to: string }> { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync(`git remote add origin https://gitlab.com/g/${name}.git`, { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + saveRegistry(identity, [{ name: "main", path: from, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }]); + const to = join(scratch, `${name}-moved`); + renameSync(from, to); + return { identity, from, to }; + } + + test("a missing newPath is rejected", async () => { + expect(await handlers["repos:locate"]({})).toEqual({ ok: false, error: "newPath-required" }); + }); + + test("a non-identity repo key is rejected, not name-resolved", async () => { + const res = await handlers["repos:locate"]({ newPath: scratch, repo: "repo-tools" }); + expect(res).toEqual({ ok: false, error: "repo-unknown" }); + expect(order).toEqual([]); + }); + + test("a non-string repo key is rejected, not dropped to an unscoped locate", async () => { + const res = await handlers["repos:locate"]({ newPath: scratch, repo: 42 }); + expect(res).toEqual({ ok: false, error: "repo-unknown" }); + expect(order).toEqual([]); + }); + + test("applies inside the hold, refreshes watchers, then emits repo:moved", async () => { + const { identity, from, to } = await movedRepo("alpha"); + + const res = await handlers["repos:locate"]({ newPath: to }); + + expect(res.ok).toBe(true); + expect(loadRepoIndex()[identity]).toBe(to); + expect(order).toEqual(["hold-start", "refresh", "emit:repo:moved", "hold-end"]); + expect(events[0]!.payload).toEqual({ identity, from, to }); + }); + + test("dryRun returns the plan and writes nothing", async () => { + const { identity, from, to } = await movedRepo("beta"); + + const res = await handlers["repos:locate"]({ newPath: to, dryRun: true }); + + expect(res.ok).toBe(true); + expect(res.data.dryRun).toBe(true); + expect(res.data.plan.identity).toBe(identity); + expect(loadRepoIndex()[identity]).toBe(from); + expect(events).toEqual([]); + }); + + test("a refusal comes back as a typed error, and nothing is emitted", async () => { + const plain = join(scratch, "plain"); + mkdirSync(plain); + + const res = await handlers["repos:locate"]({ newPath: plain }); + + expect(res.ok).toBe(false); + expect(res.error).toContain("not-a-git-repo"); + expect(events).toEqual([]); + }); +}); diff --git a/lib/daemon/__tests__/rt-client-commands.test.ts b/lib/daemon/__tests__/rt-client-commands.test.ts index 96aef518..be5d1b47 100644 --- a/lib/daemon/__tests__/rt-client-commands.test.ts +++ b/lib/daemon/__tests__/rt-client-commands.test.ts @@ -40,6 +40,7 @@ describe("rt-client command coverage", () => { worktree: { emit: () => {}, kick: () => {}, creationInFlight: () => null }, eventsBus: createEventsBus({ dbPath: ":memory:", log: pino({ level: "silent" }) }), homeSnapshot: { stop: () => {}, runNow: async () => ({}) as any, status: () => ({}) as any, ready: Promise.resolve() }, + repos: { withReconcilerHeld: async (fn) => fn(), refreshWatchedRepos: () => {} }, chatDb: openStateDb(":memory:"), }); for (const name of COMMAND_NAMES) { diff --git a/lib/daemon/command-router.ts b/lib/daemon/command-router.ts index 0106f7a2..c03fc0e9 100644 --- a/lib/daemon/command-router.ts +++ b/lib/daemon/command-router.ts @@ -24,6 +24,7 @@ import { createChatHandlers } from "./handlers/chat.ts"; import { createEndpointHandlers } from "./handlers/endpoint.ts"; import { createSettingsHandlers } from "./handlers/settings.ts"; import { createHomeHandlers } from "./handlers/home.ts"; +import { createReposHandlers } from "./handlers/repos.ts"; import { reconcileFreshness, getFreshnessSnapshot } from "./freshness.ts"; import type { SystemProcessScanner } from "./system-process-scanner.ts"; import type { EventsBus } from "./events-bus.ts"; @@ -44,6 +45,11 @@ export function buildRoutedHandlers(opts: { eventsBus: EventsBus; /** Home-repo snapshot daemon (H2) — inert handle when disabled/not-a-repo. */ homeSnapshot: HomeSnapshotHandle; + /** Reconciler hold + hooks-guard rewire the repos:locate verb drives. */ + repos: { + withReconcilerHeld: (fn: () => Promise) => Promise; + refreshWatchedRepos: () => void; + }; /** * state.db, for chat:* handlers (RT-48 Task 6). Passed in already-open * rather than resolved here with getStateDb(): this function is called at @@ -79,6 +85,7 @@ export function buildRoutedHandlers(opts: { ...createEndpointHandlers(ctx), ...createSettingsHandlers(), ...createHomeHandlers(opts.homeSnapshot), + ...createReposHandlers({ ...opts.repos, emitEvent }), // Applies repo-tracking edits immediately (rt daemon track // live|poll|off) instead of waiting for the next refresh-tail reconcile. diff --git a/lib/daemon/handlers/repos.ts b/lib/daemon/handlers/repos.ts new file mode 100644 index 00000000..1b5e13ae --- /dev/null +++ b/lib/daemon/handlers/repos.ts @@ -0,0 +1,54 @@ +/** + * Repo-index IPC verbs. + * + * `repos:locate` runs the whole apply inside the reconciler's hold: a + * reconcile pass that sees a healed index path against un-rewritten registry + * paths prunes every registry row as "no matching worktree", and replenish + * then mints replacement trees for a pool that never lost anything. + */ + +import { parseIdentity } from "../../settings/identity.ts"; +import { applyLocate, isRefusal, planLocate } from "../../repo-locate.ts"; +import type { HandlerMap } from "./types.ts"; + +export interface ReposHandlerOpts { + /** Excludes reconciler passes — not other registry writers — for the duration of `fn`. */ + withReconcilerHeld: (fn: () => Promise) => Promise; + /** Re-point the hooks guard's per-repo git-config watchers once paths have moved. */ + refreshWatchedRepos: () => void; + /** Events-bus emit — the router's shared `emitEvent`. */ + emitEvent: (topic: string, payload: unknown) => void; +} + +// Named-key return type (not a bare HandlerMap): under +// noUncheckedIndexedAccess a plain Record makes handlers["repos:locate"] +// resolve to `Handler | undefined` for every caller, tests included. +export function createReposHandlers( + opts: ReposHandlerOpts, +): Record<"repos:locate", (payload: any) => Promise> & HandlerMap { + return { + "repos:locate": async (payload) => { + const newPath = payload?.newPath; + if (typeof newPath !== "string" || newPath.length === 0) return { ok: false, error: "newPath-required" }; + // A supplied-but-unusable `repo` must not degrade to "unscoped": planLocate + // would then relocate whichever lost row matches newPath. + const repo = payload?.repo; + if (repo !== undefined && (typeof repo !== "string" || parseIdentity(repo) === null)) { + return { ok: false, error: "repo-unknown" }; + } + + return opts.withReconcilerHeld(async () => { + const plan = await planLocate({ newPath, repo }); + if (isRefusal(plan)) return { ok: false, error: `${plan.refusal}: ${plan.message}` }; + if (payload?.dryRun === true) return { ok: true, data: { dryRun: true, plan } }; + + const result = await applyLocate(plan); + if (!result.ok) return { ok: false, error: result.error ?? "locate-failed" }; + + opts.refreshWatchedRepos(); + opts.emitEvent("repo:moved", { identity: result.identity, from: result.from, to: result.to }); + return { ok: true, data: result }; + }); + }, + }; +} diff --git a/lib/daemon/worktree-reconciler.ts b/lib/daemon/worktree-reconciler.ts index c6c2cce3..d427c942 100644 --- a/lib/daemon/worktree-reconciler.ts +++ b/lib/daemon/worktree-reconciler.ts @@ -1044,8 +1044,19 @@ export function createWorktreeReconciler(deps: ReconcilerDeps): { * background pass survives into a later test's HOME once its own * `beforeEach` repoints that (shared, global) env var. */ passInFlight: () => boolean; + /** + * Run `fn` with the reconciler held: any pass in flight is awaited first, + * and `kick()` starts no new pass until `fn` settles (one queued kick fires + * on release). A holder rewrites registry paths that a concurrent pass would + * read as "no matching worktree" and prune, taking the pool's claim state + * with it. Holders serialize, so `fn` must not take the hold again. + */ + withReconcilerHeld: (fn: () => Promise) => Promise; } { let inFlight: Promise | null = null; + /** Non-null while a holder owns the reconciler. */ + let hold: Promise | null = null; + let kickQueued = false; const creationPromises = new Map>(); async function runOnce(): Promise { @@ -1108,6 +1119,10 @@ export function createWorktreeReconciler(deps: ReconcilerDeps): { } function kick(): void { + if (hold) { + kickQueued = true; + return; + } if (inFlight) return; const p = runOnce() .catch((err) => { @@ -1119,6 +1134,29 @@ export function createWorktreeReconciler(deps: ReconcilerDeps): { inFlight = p; } + async function withReconcilerHeld(fn: () => Promise): Promise { + // Claiming the hold must stay synchronous from the last `hold` read to the + // assignment below, or two woken waiters both see null and both run. + while (hold) await hold; + let release!: () => void; + hold = new Promise((resolve) => { + release = resolve; + }); + try { + // A pass that started before the hold was taken still reads the rows the + // holder is about to rewrite, so it has to finish first. + while (inFlight) await inFlight; + return await fn(); + } finally { + hold = null; + release(); + if (kickQueued) { + kickQueued = false; + kick(); + } + } + } + function creationInFlight(repoName: string): Promise | null { return creationPromises.get(repoName) ?? null; } @@ -1127,7 +1165,7 @@ export function createWorktreeReconciler(deps: ReconcilerDeps): { return inFlight !== null; } - return { kick, runOnce, creationInFlight, passInFlight }; + return { kick, runOnce, creationInFlight, passInFlight, withReconcilerHeld }; } export const __test__ = { diff --git a/lib/pickers.ts b/lib/pickers.ts index 64bd9ade..e35521ed 100644 --- a/lib/pickers.ts +++ b/lib/pickers.ts @@ -7,7 +7,7 @@ import { execSync } from "child_process"; import { join } from "path"; -import { getRepoIdentity, getKnownRepos, pickWorktreeFromRepo, getWorkspacePackages, repoOptions, type KnownRepo } from "./repo.ts"; +import { getRepoIdentity, getKnownRepos, pickWorktreeFromRepo, getWorkspacePackages, repoOptions, repoFromOptionValue, missingRepoRefusal, type KnownRepo } from "./repo.ts"; import { enrichBranches, formatBranchLabel } from "./enrich.ts"; const SWITCH_REPO = "__switch_repo__" as const; @@ -88,15 +88,23 @@ export async function pickFromAllRepos( repos: KnownRepo[], opts?: { stderr?: boolean; errorMessage?: string; includePackages?: boolean }, ): Promise { - const { filterableSelect, BackNavigation } = await import("./rt-render.tsx"); + const writer = opts?.stderr ? console.error : console.log; if (repos.length === 0) { const msg = opts?.errorMessage || "no known repos found — run rt from inside a git repo first"; - const writer = opts?.stderr ? console.error : console.log; writer(`\n ${msg}\n`); process.exit(1); } + /** Refusing before the picker loads keeps a lost-repo-only index off the ink path entirely. */ + const refuse = (repo: KnownRepo): never => { + writer(`\n ${missingRepoRefusal(repo)}\n`); + process.exit(1); + }; + if (repos.length === 1 && repos[0]!.missing) refuse(repos[0]!); + + const { filterableSelect, BackNavigation } = await import("./rt-render.tsx"); + // Loop: back from worktree/package picker restarts at repo picker while (true) { let selectedRepo: KnownRepo; @@ -110,8 +118,9 @@ export async function pickFromAllRepos( ...(opts?.stderr ? { stderr: true } : {}), }); if (!picked) process.exit(1); - selectedRepo = repos.find(r => r.repoName === picked)!; + selectedRepo = repoFromOptionValue(repos, picked)!; } + if (selectedRepo.missing) refuse(selectedRepo); // Resolve worktree path (or auto-select if only one) let worktreePath: string | null; diff --git a/lib/repo-index.ts b/lib/repo-index.ts index 8d29496f..418f79c8 100644 --- a/lib/repo-index.ts +++ b/lib/repo-index.ts @@ -5,7 +5,9 @@ * The index is a DISPOSABLE CACHE (RT-49, collapsed into state.db by RT-50): * it self-populates as rt visits repos (`updateRepoIndex`, called from * lib/repo.ts's `getRepoIdentity`). Losing it loses nothing durable — every - * entry regenerates the next time rt runs inside that repo, and meanwhile the + * entry regenerates the next time rt runs inside that repo — except a row + * whose repo MOVED, which only `updateRepoIndexAsync` or `rt repos locate` + * can re-point (see `writeIndexRow`) — and meanwhile the * picker still surfaces every repo reachable under the `rt.repoRoots` * settings key (below) as an unregistered candidate. It is not part of any * backup/restore story and never will be. @@ -29,6 +31,7 @@ import { deriveRepoIdentity, parseIdentity, serializeIdentity } from "./settings import { repoLabel, repoLabelFull, repoLabelQualified } from "./repo-label.ts"; import { dim } from "./ansi.ts"; import { getSetting } from "./settings/resolve.ts"; +import { mergeRegistries, type TreeRecord } from "./worktree/registry.ts"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -40,6 +43,9 @@ export interface KnownRepo { /** False for repos discovered by scanning sibling directories, never * explicitly visited by rt. Omitted (implicitly true) for indexed repos. */ registered?: boolean; + /** The indexed path no longer exists. The row is kept so `rt repos locate` + * can move it as one unit with its registry; it is never a cd target. */ + missing?: true; } // ─── Index CRUD ────────────────────────────────────────────────────────────── @@ -48,7 +54,7 @@ interface RepoIndex { [repoName: string]: string; // repoName → primary repo root path } -const REPO_INDEX_NS = "repo-index"; +export const REPO_INDEX_NS = "repo-index"; /** * Deprecated derived-compatibility path: state.db is authoritative, but @@ -131,28 +137,116 @@ export function loadRepoIndex(): RepoIndex { return Object.keys(imported).length > 0 ? imported : existing; } -export function updateRepoIndex(repoName: string, repoRoot: string): void { - let mainPath: string; +/** The repo's MAIN worktree path as git reports it, degrading to `repoRoot`. */ +function observedMainPath(repoRoot: string): string { try { - const mainWorktree = execSync("git worktree list --porcelain", { + const listed = execSync("git worktree list --porcelain", { cwd: repoRoot, encoding: "utf8", stdio: "pipe", }); - mainPath = mainWorktree.split("\n")[0]?.replace("worktree ", "").trim() || repoRoot; + return listed.split("\n")[0]?.replace("worktree ", "").trim() || repoRoot; } catch { - mainPath = repoRoot; + return repoRoot; } +} + +/** + * The row's current path, read straight from the namespace rather than through + * `loadRepoIndex()`: that function's legacy-repos.json import is a migration + * side effect (it writes rows AND rewrites the mirror), and firing it from + * inside the write path would reorder it ahead of the write it guards. + */ +function storedIndexPath(repoName: string): string | undefined { + return getKvValue(REPO_INDEX_NS, repoName, undefined); +} + +/** True when the stored row names a directory that is gone and the repo is now somewhere else — a MOVE, not a second clone. */ +function storedPathMoved(stored: string | undefined, mainPath: string): boolean { + return stored !== undefined && stored !== mainPath && !existsSync(stored); +} + +function writeIndexRow(repoName: string, mainPath: string): void { try { - // loadRepoIndex() can throw (an unopenable state.db — e.g. root-owned - // after a sudo invocation) — inside the try along with the write it - // depends on, so getRepoIdentity() (which every in-repo command calls) + // The read and loadRepoIndex() can throw (an unopenable state.db — e.g. + // root-owned after a sudo invocation) — inside the try along with the write + // they bracket, so getRepoIdentity() (which every in-repo command calls) // degrades to skipping the index update rather than crashing the command. + // + // A moved repo is NOT written here: re-pointing the index row ahead of the + // worktree registry is what makes the reconciler prune every claimed tree, + // and the repair that ordering owes is async git — forbidden on the daemon + // thread, which reaches this function through resolveIndexPathForIdentity. + // The row stays lost (visible as `missing`) until `updateRepoIndexAsync` + // or `rt repos locate` moves it as one unit. + if (storedPathMoved(storedIndexPath(repoName), mainPath)) return; setKvValue(REPO_INDEX_NS, repoName, mainPath); writeRepoIndexCompat(loadRepoIndex()); } catch { /* best effort */ } } +export function updateRepoIndex(repoName: string, repoRoot: string): void { + writeIndexRow(repoName, observedMainPath(repoRoot)); +} + +/** + * `healed` distinguishes a plain index write from a whole move; `ok: false` is + * ONLY ever a refused/failed locate — the plain write keeps the sync seam's + * best-effort contract and never reports failure. + */ +export type IndexHealResult = { ok: true; healed: boolean } | { ok: false; error: string }; + +/** + * `updateRepoIndex` for callers that can await: the same write, plus the move + * heal the sync seam cannot perform. The locate runs in the daemon whenever + * one is present — imported lazily, both to keep the daemon client off every + * rt command's startup path and because repo-locate.ts imports this module. + * + * A refused move is RETURNED, never thrown and never warned about here: the + * row is left naming the gone path, so a caller that reports success without + * checking is claiming a repo is indexed when it is not. + */ +export async function updateRepoIndexAsync(repoName: string, repoRoot: string): Promise { + const mainPath = observedMainPath(repoRoot); + let stored: string | undefined; + try { + stored = storedIndexPath(repoName); + } catch { + stored = undefined; + } + if (!storedPathMoved(stored, mainPath)) { + writeIndexRow(repoName, mainPath); + return { ok: true, healed: false }; + } + const { locateMovedRepo } = await import("./repo-locate-dispatch.ts"); + const outcome = await locateMovedRepo({ newPath: mainPath, repo: repoName }); + return outcome.ok ? { ok: true, healed: true } : { ok: false, error: outcome.error }; +} + +/** + * Raw index-row write: no git probe, no move detection. `updateRepoIndex` is + * the caller-facing path that DERIVES the main path; this is the primitive for + * a caller that has already decided what the row must say, and it is the only + * index write that is safe to run inside a state.db transaction. + */ +export function setIndexPath(key: string, mainPath: string): void { + setKvValue(REPO_INDEX_NS, key, mainPath); +} + +/** Drop one index row. */ +export function removeIndexRow(key: string): void { + deleteKvValue(REPO_INDEX_NS, key); +} + +/** Rewrite ~/.mattstack/rt/repos.json from the current rows — a FILE write, so it runs after a transaction commits, never inside one. */ +export function refreshRepoIndexMirror(): void { + try { + writeRepoIndexCompat(loadRepoIndex()); + } catch { + // best effort — see repoIndexCompatPath's doc comment + } +} + /** * Resolves a serialized identity to its indexed main-worktree path, tolerating * an index whose rows still carry legacy repo-name keys (the state every @@ -229,6 +323,10 @@ export interface IndexPartition { duplicates: DuplicateEntry[]; } +function identityRank(entry: RepoIndexEntry): number { + return parseIdentity(entry.repoName) === null ? 0 : 1; +} + /** * Splits index rows that point at the SAME directory under two names. * @@ -238,11 +336,15 @@ export interface IndexPartition { * resolving — `existsSync` follows symlinks, so the dead row passes the * liveness filter and the picker shows the tree twice. * - * The most recently written row wins, because `updateRepoIndex` restamps a - * name every time rt runs inside that repo: the live identity keeps moving - * forward while the retired one stays frozen at whenever it was last used. - * Name order breaks ties so a legacy import (every row stamped within the - * same millisecond) is at least deterministic. + * An identity key beats a legacy name outright, whatever the stamps say: the + * loser is what prune migrates ONTO the winner, and carrying identity-keyed + * data back onto a name would re-mint the split the cutover ended. + * + * Among rows of the same kind the most recently written wins, because + * `updateRepoIndex` restamps a name every time rt runs inside that repo: the + * live identity keeps moving forward while the retired one stays frozen at + * whenever it was last used. Name order breaks ties so a legacy import (every + * row stamped within the same millisecond) is at least deterministic. * * Losers are only hidden, never dropped, by the caller in `getKnownRepos`. * Lookups by name elsewhere (`loadRepoIndex()[name]`, and the per-repo data @@ -262,7 +364,10 @@ export function partitionByRealpath(entries: RepoIndexEntry[]): IndexPartition { const duplicates: DuplicateEntry[] = []; for (const group of groups.values()) { const sorted = [...group].sort( - (a, b) => b.updatedAt - a.updatedAt || a.repoName.localeCompare(b.repoName), + (a, b) => + identityRank(b) - identityRank(a) || + b.updatedAt - a.updatedAt || + a.repoName.localeCompare(b.repoName), ); const winner = sorted[0]!; keep.push(winner); @@ -282,10 +387,14 @@ export interface PrunedEntry { /** Set only for `duplicate`: what became of the retired name's data dir. */ data?: DataMigration; /** - * Set on a `duplicate` whose migration could not finish: the row is KEPT so - * whatever is still keyed to the retired name stays reachable. + * Set when the row is KEPT despite qualifying for eviction: a `duplicate` + * whose migration could not finish, or a `missing` row that still owns a + * worktree registry. Eviction is exactly what makes those leftovers + * unreachable. */ retained?: true; + /** Set with `retained`: the verb that resolves this row. */ + hint?: string; } /** Outcome of carrying everything keyed to a retired name onto the live name. */ @@ -300,15 +409,17 @@ export interface DataMigration { removedDir: boolean; /** * The retired name's worktree registry: `"moved"` onto the live name, - * `"refused"` because the live name already had one (both are real tree - * records; picking a winner would guess), or `"none"` if it had none. + * `"merged"` into the live name's own registry (the name/identity pair the + * identity cutover left, each side owning half of one on-deck pool), + * `"refused"` because the write could not be verified, or `"none"` if it + * had none. * * This lives in state.db's kv, not the data dir, so it is invisible to a * directory walk — and it is the record the daemon keys by, so a retired * name that keeps it while the index row goes away leaves the reconciler * silently skipping the repo. */ - registry: "moved" | "refused" | "none"; + registry: "moved" | "merged" | "refused" | "none"; } /** True when anything is still keyed to the retired name after a migration. */ @@ -337,36 +448,41 @@ const WORKTREE_REGISTRY_NS = "worktree-registry"; * * The daemon keys registries by the INDEX name * (`lib/daemon/worktree-reconciler.ts` iterates the repo index), while the CLI - * looks them up by git identity (`deriveRepoName`). A rename splits those two, - * and evicting the retired index row then makes the registry unreachable: + * looks them up by git identity. A rename splits those two, and evicting the + * retired index row then makes the registry unreachable: * `repoHasWorktreeActivity` sees an empty registry under the live name and * skips the repo, so the reconciler quietly stops managing its worktrees. * That is why this moves with the data dir instead of being left behind. * - * A live name that ALREADY has a registry is refused, never merged — both - * sides are real tree records carrying claim state and ready stamps that no - * git repository has another record of. + * A live name that already has a registry is MERGED, not refused: both sides + * describe the same repo's trees, so the union by path (`mergeRegistries`) + * loses neither half of a pool that a name/identity pair split. */ function migrateWorktreeRegistry(from: string, to: string, opts: { dryRun?: boolean }): DataMigration["registry"] { - let retired: unknown; + let outcome: "moved" | "merged"; try { if (!hasKvValue(WORKTREE_REGISTRY_NS, from)) return "none"; - if (hasKvValue(WORKTREE_REGISTRY_NS, to)) return "refused"; - if (opts.dryRun) return "moved"; - retired = getKvValue(WORKTREE_REGISTRY_NS, from, null); - setKvValue(WORKTREE_REGISTRY_NS, to, retired); + outcome = hasKvValue(WORKTREE_REGISTRY_NS, to) ? "merged" : "moved"; + if (opts.dryRun) return outcome; + + const retired = getKvValue(WORKTREE_REGISTRY_NS, from, []); + const live = outcome === "merged" ? getKvValue(WORKTREE_REGISTRY_NS, to, []) : []; + const next = outcome === "merged" ? mergeRegistries(live, retired) : retired; + setKvValue(WORKTREE_REGISTRY_NS, to, next); + + // persistOrWarn swallows SQLITE_BUSY, so a returned write is not a landed + // one — and on a merge the destination row already existed, so its mere + // presence proves nothing. Compare the readback. + if (JSON.stringify(getKvValue(WORKTREE_REGISTRY_NS, to, [])) !== JSON.stringify(next)) { + console.warn(`rt: ${from}'s worktree registry did not persist under ${to} — leaving it in place`); + return "refused"; + } } catch (err) { console.warn(`rt: could not move ${from}'s worktree registry to ${to} (${(err as Error).message})`); return "refused"; } - // Delete only after the write is readable: persistOrWarn swallows - // SQLITE_BUSY, so a returned write is not a landed one. - if (!hasKvValue(WORKTREE_REGISTRY_NS, to)) { - console.warn(`rt: ${from}'s worktree registry did not persist under ${to} — leaving it in place`); - return "refused"; - } deleteKvValue(WORKTREE_REGISTRY_NS, from); - return "moved"; + return outcome; } /** @@ -491,6 +607,9 @@ export function migrateRepoData(from: string, to: string, opts: { dryRun?: boole * eviction is exactly what makes a leftover unreachable. A `missing` row is * left un-migrated on purpose: its path is gone, so there is no surviving name * to carry it to, and its data dir stays untouched rather than being deleted. + * A `missing` row that still owns a worktree registry is likewise `retained`: + * the registry is the daemon's only handle to that repo's trees, keyed by + * this row's name, so evicting the row would strand it. */ export function pruneRepoIndex(opts: { dryRun?: boolean } = {}): PrunedEntry[] { const entries = loadRepoIndexEntries(); @@ -498,8 +617,23 @@ export function pruneRepoIndex(opts: { dryRun?: boolean } = {}): PrunedEntry[] { const live: RepoIndexEntry[] = []; for (const entry of entries) { - if (existsSync(entry.path)) live.push(entry); - else removed.push({ repoName: entry.repoName, path: entry.path, reason: "missing" }); + if (existsSync(entry.path)) { + live.push(entry); + continue; + } + // A gone path whose registry is still here is a MOVE, not a deletion: + // dropping the row orphans the pool's claim state under a key nothing + // iterates any more. + let ownsRegistry = false; + try { + ownsRegistry = hasKvValue(WORKTREE_REGISTRY_NS, entry.repoName); + } catch { /* unreadable db — treat as no registry and prune as before */ } + removed.push({ + repoName: entry.repoName, + path: entry.path, + reason: "missing", + ...(ownsRegistry ? { retained: true as const, hint: "rt repos locate" } : {}), + }); } for (const dup of partitionByRealpath(live).duplicates) { @@ -656,8 +790,13 @@ function buildRootSet(known: KnownRepo[]): RootEntry[] { /** * Get all known repos from the global index, with worktree discovery. * Used when rt is run outside a git repo to offer a picker. + * + * `includeMissing` is opt-in: a caller that resolves a repo and then chdirs + * or spawns against its worktree path must ask for `missing` rows explicitly + * and refuse them (`missingRepoRefusal`) before acting, or leave the default + * off and keep today's silent-exclusion behavior. */ -export function getKnownRepos(): KnownRepo[] { +export function getKnownRepos(opts?: { includeMissing?: boolean }): KnownRepo[] { // Same degrade-don't-crash rule as getRepoIdentity()'s index write: an // unopenable state.db (root-owned after a `sudo rt …`) must not take down // the `rt cd`/`rt run` picker — it falls back to the unregistered-scan @@ -670,8 +809,12 @@ export function getKnownRepos(): KnownRepo[] { } const repos: KnownRepo[] = []; + const liveEntries: RepoIndexEntry[] = []; + const lostEntries: RepoIndexEntry[] = []; + for (const e of entries) (existsSync(e.path) ? liveEntries : lostEntries).push(e); + // Hidden here, not evicted — see partitionByRealpath. - const { keep } = partitionByRealpath(entries.filter((e) => existsSync(e.path))); + const { keep } = partitionByRealpath(liveEntries); for (const { repoName, path: mainPath } of keep) { const worktrees: KnownRepo["worktrees"] = []; @@ -715,15 +858,29 @@ export function getKnownRepos(): KnownRepo[] { } const known = repos.filter(r => r.worktrees.length > 0); + // A pair of rows for one gone directory is one lost repo, not two. + const lost: KnownRepo[] = opts?.includeMissing + ? partitionByRealpath(lostEntries).keep.map((e) => ({ + repoName: e.repoName, + worktrees: [{ path: e.path, branch: "", isBare: false }], + dataDir: repoDataDir(e.repoName), + missing: true as const, + })) + : []; + // Lost names are excluded for the same reason lost paths are (below): a lost + // legacy-name row is named after the directory that moved, so counting it as + // known would shadow that directory's NEW location out of the scan — the one + // candidate `rt repos locate` exists to surface. const knownNames = new Set(known.map(r => r.repoName)); // realpath'd for set-membership ONLY — a symlinked path component (macOS // /tmp → /private/tmp being the canonical case) must not let the same // directory double-emit under two spellings. `known` itself keeps its // original, user-visible spellings (KnownRepo.worktrees[].path, repos.json, - // `rt cd` targets) untouched. + // `rt cd` targets) untouched. Lost paths are deliberately absent: the scan + // must be free to surface the moved repo's NEW directory. const knownPaths = new Set(known.flatMap(r => r.worktrees.map(w => safeRealpath(w.path)))); - return [...known, ...scanUnregisteredRepos(known, knownNames, knownPaths)]; + return [...known, ...lost, ...scanUnregisteredRepos([...known, ...lost], knownNames, knownPaths)]; } interface Candidate { @@ -898,6 +1055,10 @@ function scanUnregisteredRepos( it); only `label` is decoded for humans. Prefer `repoOptions` for a full list — it disambiguates repos whose identities share a last segment. */ export function repoOption(r: KnownRepo, label: string = repoLabel(r.repoName)): { value: string; label: string; hint: string; color?: string } { + if (r.missing) { + return { value: r.repoName, label, hint: "missing — rt repos locate", color: dim }; + } + const location = r.worktrees.length > 1 ? `${r.worktrees.length} worktrees` : r.worktrees[0]?.path.replace(homedir(), "~") || ""; @@ -912,10 +1073,27 @@ export function repoOption(r: KnownRepo, label: string = repoLabel(r.repoName)): }; } +function duplicateRepoNames(repos: KnownRepo[]): Set { + const counts = new Map(); + for (const r of repos) counts.set(r.repoName, (counts.get(r.repoName) ?? 0) + 1); + return new Set([...counts].filter(([, n]) => n > 1).map(([name]) => name)); +} + +/** + * One list, one value per row. A lost legacy-name row and the scanned + * directory that name moved to carry the SAME `repoName`, so an unqualified + * value resolves the live directory to the dead row. The qualifier trails the + * name because fzf matches on this field (`--nth=1`). + */ +function repoOptionValue(r: KnownRepo, i: number, duplicated: Set): string { + return duplicated.has(r.repoName) ? `${r.repoName}#${i}` : r.repoName; +} + /** Picker options for a repo list: short labels, upgraded to owner/name where two repos would otherwise render identically, and to the full decoded id when even owner/name collides (same owner/name on two hosts; two path - repos sharing a basename). */ + repos sharing a basename). Resolve what the picker returns with + `repoFromOptionValue` — the values are list-scoped, not bare index keys. */ export function repoOptions(repos: KnownRepo[]): Array> { const shortCounts = new Map(); const qualifiedCounts = new Map(); @@ -925,14 +1103,30 @@ export function repoOptions(repos: KnownRepo[]): Array { + const duplicated = duplicateRepoNames(repos); + return repos.map((r, i) => { const short = repoLabel(r.repoName); - if ((shortCounts.get(short) ?? 0) <= 1) return repoOption(r, short); const qualified = repoLabelQualified(r.repoName); - return repoOption(r, (qualifiedCounts.get(qualified) ?? 0) > 1 ? repoLabelFull(r.repoName) : qualified); + const label = (shortCounts.get(short) ?? 0) <= 1 + ? short + : (qualifiedCounts.get(qualified) ?? 0) > 1 ? repoLabelFull(r.repoName) : qualified; + return { ...repoOption(r, label), value: repoOptionValue(r, i, duplicated) }; }); } +/** The row a `repoOptions` value came from. The list must be the one the + options were built from — values are positional when names collide. */ +export function repoFromOptionValue(repos: KnownRepo[], value: string): KnownRepo | undefined { + const duplicated = duplicateRepoNames(repos); + return repos.find((r, i) => repoOptionValue(r, i, duplicated) === value); +} + +/** The one-line refusal every picker prints instead of cd-ing into a repo whose indexed path is gone. */ +export function missingRepoRefusal(r: KnownRepo): string { + const gone = r.worktrees[0]?.path ?? "its indexed path"; + return `${r.repoName} is no longer at ${gone} — run: rt repos locate --repo ${r.repoName}`; +} + // ─── Test seam ─────────────────────────────────────────────────────────────── export const __test__ = { diff --git a/lib/repo-locate-dispatch.ts b/lib/repo-locate-dispatch.ts new file mode 100644 index 00000000..e4cedaf4 --- /dev/null +++ b/lib/repo-locate-dispatch.ts @@ -0,0 +1,70 @@ +/** + * The one place that decides whether a locate runs in the daemon or in this + * process. + * + * The daemon is the single writer of the worktree registry, so a locate must + * never run locally while it is present: a reconcile pass landing between the + * index write and the registry write is exactly the prune this feature exists + * to prevent. Presence is decided from liveness EVIDENCE (a live pid, or the + * socket file existing) rather than a ping: an event-loop-stalled daemon — + * alive, holding the registry, just not servicing requests — fails a ping + * exactly like a dead one does, and treating that as "absent" would take the + * local branch anyway and race the very daemon still holding the registry. So + * once presence is established, an unanswered `repos:locate` is a hard stop, + * never a fall-through — `daemonSocketQuery` is the read-only client, so + * probing never starts a daemon or warns. + */ + +import { existsSync } from "fs"; +import { daemonSocketQuery } from "./daemon-client.ts"; +import { DAEMON_SOCK_PATH, isDaemonProcessRunning } from "./daemon-config.ts"; +import { applyLocate, isRefusal, planLocate, type LocatePlan, type LocateResult } from "./repo-locate.ts"; + +/** git worktree repair across a large pool is the slow part; the 2s default IPC timeout is a client number, not a daemon-op one. */ +export const LOCATE_TIMEOUT_MS = 2 * 60_000; + +export type LocateOutcome = + | { via: "daemon" | "local"; ok: true; dryRun: false; result: LocateResult } + | { via: "daemon" | "local"; ok: true; dryRun: true; plan: LocatePlan } + | { via: "daemon" | "local"; ok: false; error: string }; + +/** A live pid file OR a socket file on disk — either is evidence the daemon holds the registry, whether or not it is currently answering requests. */ +function daemonPresent(): boolean { + return isDaemonProcessRunning() || existsSync(DAEMON_SOCK_PATH); +} + +export async function locateMovedRepo(req: { + newPath: string; + repo?: string; + dryRun?: boolean; +}): Promise { + const dryRun = req.dryRun === true; + + if (daemonPresent()) { + const res = await daemonSocketQuery( + "repos:locate", + { newPath: req.newPath, ...(req.repo ? { repo: req.repo } : {}), dryRun }, + LOCATE_TIMEOUT_MS, + ); + if (!res) { + return { + via: "daemon", + ok: false, + error: "the rt daemon is present but did not answer repos:locate; not applying locally (would race the worktree reconciler) — check `rt daemon status` and retry", + }; + } + if (!res.ok) return { via: "daemon", ok: false, error: res.error ?? "repos:locate failed" }; + return dryRun + ? { via: "daemon", ok: true, dryRun: true, plan: res.data.plan as LocatePlan } + : { via: "daemon", ok: true, dryRun: false, result: res.data as LocateResult }; + } + + const plan = await planLocate({ newPath: req.newPath, repo: req.repo }); + if (isRefusal(plan)) return { via: "local", ok: false, error: `${plan.refusal}: ${plan.message}` }; + if (dryRun) return { via: "local", ok: true, dryRun: true, plan }; + + const result = await applyLocate(plan); + return result.ok + ? { via: "local", ok: true, dryRun: false, result } + : { via: "local", ok: false, error: result.error ?? "locate failed" }; +} diff --git a/lib/repo-locate.ts b/lib/repo-locate.ts new file mode 100644 index 00000000..612bfee2 --- /dev/null +++ b/lib/repo-locate.ts @@ -0,0 +1,509 @@ +/** + * Repo locate: re-point every literal path rt stores for a repo whose folder + * moved, as one unit. + * + * Ordering is the whole point. The reconciler prunes a registry row whose path + * is absent from `git worktree list`, so an index row that heals ahead of the + * registry destroys claimed/on-deck state and replenish then mints replacement + * trees. So the apply repairs git and verifies FIRST, while the index still + * names the dead path (a reconciler pass that interleaves there finds a repo + * whose path does not exist and bails), and only then commits index rows, + * registries and claims in one state.db transaction. Nothing is written until + * the whole move is known to be sound, which is why there is no rollback path. + * + * An in-daemon caller still runs this under the reconciler's in-flight hold: + * the transaction is atomic against a reader, but the git repair passes are + * not, and a pass that starts mid-repair can see a half-linked worktree. + * + * Pure of the daemon and the CLI: the daemon handler and `commands/repos.ts` + * both drive these functions, and neither the caller nor the transport is + * visible from here. + */ + +import { existsSync, realpathSync, statSync } from "fs"; +import { join, resolve as resolvePath } from "path"; +import { + getKnownRepos, + loadRepoIndexEntries, + migrateRepoData, + migrationIncomplete, + refreshRepoIndexMirror, + removeIndexRow, + setIndexPath, + type DataMigration, + type RepoIndexEntry, +} from "./repo-index.ts"; +import { + deleteRegistry, + hasRegistry, + loadRegistry, + mergeRegistries, + saveRegistry, + type TreeRecord, +} from "./worktree/registry.ts"; +import { loadClaims, saveClaims, type EndpointClaim } from "./endpoint/store.ts"; +import { deriveRepoIdentity, parseIdentity, serializeIdentity } from "./settings/identity.ts"; +import { getStateDb } from "./state/index.ts"; +import { listWorktreesAsync, runGit } from "./worktree/git-async.ts"; + +export type LocateRefusalCode = + | "not-a-git-repo" + | "not-main-worktree" + | "nothing-lost" + | "old-path-exists" + | "identity-mismatch" + | "identity-changed"; + +export interface LocateRefusal { + refusal: LocateRefusalCode; + message: string; +} + +export interface RegistryRewrite { + /** Index key this registry belongs to: the identity, or a legacy-name half of a healed pair. */ + repoKey: string; + /** The whole registry after the re-root, in its original order. */ + trees: TreeRecord[]; + /** New spellings of the records this move re-rooted — what verification checks. */ + movedPaths: string[]; +} + +export interface ClaimRewrite { + repoKey: string; + worktree: string; + newWorktree: string; +} + +export interface LocatePlan { + identity: string; + oldPath: string; + newPath: string; + indexKeys: string[]; + /** Every `indexKeys` entry that is not the identity — collapsed after a verified apply. */ + legacyKeys: string[]; + /** Plan-time preview (dry-run display) and the path set verification checks — the apply re-reads and re-roots each registry itself. */ + registryRewrites: RegistryRewrite[]; + /** Plan-time preview, same as `registryRewrites`. */ + claimRewrites: ClaimRewrite[]; + /** In-tree worktree paths (new spellings, main excluded) handed to `git worktree repair`. */ + gitRepairPaths: string[]; +} + +export interface LocateResult { + ok: boolean; + identity: string; + from: string; + to: string; + indexKeys: string[]; + treesRewritten: number; + claimsRewritten: number; + repaired: string[]; + /** Re-rooted registry paths with nothing on disk — a record the reconciler will prune, never a locate failure. */ + stalePaths: string[]; + /** `retained` carries the reason its data dir could not all move; the row is left naming `from`, never `to`. */ + legacyRows: { key: string; outcome: "collapsed" | "retained"; reason?: string }[]; + error?: string; +} + +export interface LocateCandidate { + path: string; + identity: string; +} + +export function isRefusal(x: LocatePlan | LocateRefusal): x is LocateRefusal { + return "refusal" in x; +} + +function refuse(refusal: LocateRefusalCode, message: string): LocateRefusal { + return { refusal, message }; +} + +/** realpathSync, degrading to the literal spelling — a gone path must compare, not throw. */ +function canon(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +/** `path` re-rooted onto `newPath`, or null when it lives outside the moved tree (an external worktree keeps its own path). */ +function relocatePath(path: string, oldPath: string, newPath: string): string | null { + if (path === oldPath) return newPath; + if (path.startsWith(`${oldPath}/`)) return newPath + path.slice(oldPath.length); + return null; +} + +/** The one re-root both the plan and the apply run, so what lands is never a plan-time snapshot of the registry. */ +function relocateTrees( + trees: TreeRecord[], + oldPath: string, + newPath: string, +): { trees: TreeRecord[]; movedPaths: string[] } { + const movedPaths: string[] = []; + const next = trees.map((rec) => { + const moved = relocatePath(rec.path, oldPath, newPath); + if (moved === null) return rec; + movedPaths.push(moved); + return { ...rec, path: moved }; + }); + return { trees: next, movedPaths }; +} + +/** + * Whether `path` is a repo's MAIN worktree. Locating a linked worktree would + * re-root every stored path onto a base that is one directory of the repo, so + * this is a gate, not a nicety: a linked worktree derives the same identity as + * its main worktree and would otherwise plan cleanly. + * + * A `.git` directory is main by construction. A `.git` FILE is either a linked + * worktree or a `--separate-git-dir` main worktree, and only git can tell them + * apart: git-dir equals git-common-dir for main, and is + * `/worktrees/` for a linked tree. + */ +async function isMainWorktree(path: string): Promise { + try { + if (statSync(join(path, ".git")).isDirectory()) return true; + } catch { + return false; + } + const r = await runGit(path, ["rev-parse", "--git-dir", "--git-common-dir"]); + if (r.exitCode !== 0) return false; + const [gitDir, commonDir] = r.stdout.trim().split("\n"); + if (gitDir === undefined || commonDir === undefined) return false; + // git prints these relative to the worktree unless they are absolute — which + // is exactly what `resolve` handles and `join` would corrupt. + return canon(resolvePath(path, gitDir)) === canon(resolvePath(path, commonDir)); +} + +/** + * Resolve which index rows a move touches, matching by IDENTITY only. + * + * A legacy-name row joins the plan through the identity row it shares a lost + * directory with — never by name, which is exactly the drift identities exist + * to end. + */ +export async function planLocate(opts: { newPath: string; repo?: string }): Promise { + const newPath = canon(resolvePath(opts.newPath)); + if (!existsSync(join(newPath, ".git"))) { + return refuse("not-a-git-repo", `${newPath} is not a git repository`); + } + if (!(await isMainWorktree(newPath))) { + return refuse( + "not-main-worktree", + `${newPath} is a linked worktree, not the repo's main worktree — locate re-roots every stored path onto the path it is given, so it must be given the repo root`, + ); + } + + const identity = serializeIdentity(await deriveRepoIdentity(newPath)); + const entries = loadRepoIndexEntries(); + const lost = entries.filter((e) => !existsSync(e.path)); + + const named: RepoIndexEntry | null = opts.repo ? entries.find((e) => e.repoName === opts.repo) ?? null : null; + if (opts.repo && !named) { + return refuse("nothing-lost", `--repo ${opts.repo} is not in the repo index`); + } + if (named && existsSync(named.path)) { + return refuse("old-path-exists", `${opts.repo} is indexed at ${named.path}, which still exists — that is a second clone, not a move`); + } + + const identityRow = entries.find((e) => e.repoName === identity) ?? null; + if (identityRow && existsSync(identityRow.path)) { + return canon(identityRow.path) === newPath + ? refuse("nothing-lost", `${identity} is already indexed at ${newPath}`) + : refuse("old-path-exists", `${identity} is already indexed at ${identityRow.path}, which still exists — that is a second clone, not a move`); + } + + if (!identityRow) { + if (lost.length === 0) { + return refuse("nothing-lost", `no indexed repo is missing from disk, so ${newPath} has nothing to be located as`); + } + if (parseIdentity(identity)?.kind === "path") { + return refuse( + "identity-changed", + `${newPath} derives ${identity}, and no index row is keyed by it. A repo with no origin remote is identified BY its main worktree's path, so moving it mints a new identity rather than keeping the old one — locate re-points paths, it never re-keys a repo. Register the new path instead: rt repos register ${newPath}`, + ); + } + return refuse( + "identity-mismatch", + `${newPath} derives ${identity}, which matches no indexed repo whose path is missing (lost rows: ${lost.map((e) => e.repoName).join(", ")})`, + ); + } + if (named && canon(named.path) !== canon(identityRow.path)) { + return refuse( + "identity-mismatch", + `${newPath} derives ${identity} (indexed at ${identityRow.path}), but --repo names ${named.repoName} at ${named.path} — locate matches by identity, never by name`, + ); + } + + const oldPath = identityRow.path; + const indexKeys = lost.filter((e) => e.path === oldPath).map((e) => e.repoName); + const legacyKeys = indexKeys.filter((key) => key !== identity); + + const registryRewrites: RegistryRewrite[] = []; + const repairPaths = new Set(); + for (const key of indexKeys) { + if (!hasRegistry(key)) continue; + const { trees, movedPaths } = relocateTrees(loadRegistry(key), oldPath, newPath); + for (const moved of movedPaths) { + if (moved !== newPath) repairPaths.add(moved); + } + registryRewrites.push({ repoKey: key, trees, movedPaths }); + } + + const claimRewrites: ClaimRewrite[] = []; + for (const key of indexKeys) { + for (const claim of loadClaims(key)) { + const moved = relocatePath(claim.worktree, oldPath, newPath); + if (moved === null) continue; + claimRewrites.push({ repoKey: key, worktree: claim.worktree, newWorktree: moved }); + } + } + + return { + identity, + oldPath, + newPath, + indexKeys, + legacyKeys, + registryRewrites, + claimRewrites, + gitRepairPaths: [...repairPaths], + }; +} + +function indexWriteKeys(plan: LocatePlan): string[] { + return [...new Set([...plan.indexKeys, plan.identity])]; +} + +/** + * The registry half of the apply: every registry is re-read and re-rooted + * HERE, not carried over from the plan, so a tree provisioned between plan and + * apply is moved rather than overwritten. The pair's registries are merged onto + * the IDENTITY key and every legacy registry row is dropped, so the reconciler + * (which iterates identity keys) sees one pool instead of two halves. + */ +function writeRegistries(plan: LocatePlan): number { + const relocate = (key: string) => relocateTrees(loadRegistry(key), plan.oldPath, plan.newPath); + let moved = 0; + let touched = hasRegistry(plan.identity); + let merged: TreeRecord[] = []; + if (touched) { + const own = relocate(plan.identity); + merged = own.trees; + moved += own.movedPaths.length; + } + for (const key of plan.legacyKeys) { + if (!hasRegistry(key)) continue; + const legacy = relocate(key); + merged = mergeRegistries(merged, legacy.trees); + moved += legacy.movedPaths.length; + deleteRegistry(key); + touched = true; + } + if (touched) saveRegistry(plan.identity, merged); + return moved; +} + +/** + * The claims half of the apply, on the same rules as `writeRegistries`: every + * claim is re-read HERE (a claim taken between plan and apply must move with + * the repo, not be reverted to the plan's copy), and the pair's claims are + * merged onto the IDENTITY key with the legacy keys emptied — a legacy row + * whose index row `collapseLegacyRows` then drops would otherwise keep claim + * rows under a key nothing looks up again. + * + * `endpoint_claims` is keyed `(repo, worktree, role)`, so a pair that claimed + * the same tree in the same role collides on the merge; the identity's own row + * wins, which is why it is absorbed last. + */ +function writeClaims(plan: LocatePlan): number { + const merged = new Map(); + const absorb = (key: string): number => { + const claims = loadClaims(key); + for (const c of claims) { + const moved = relocatePath(c.worktree, plan.oldPath, plan.newPath); + const claim = moved === null ? c : { ...c, worktree: moved }; + merged.set(JSON.stringify([claim.worktree, claim.role]), { claim, relocated: moved !== null }); + } + return claims.length; + }; + + let touched = false; + for (const key of plan.legacyKeys) { + if (absorb(key) === 0) continue; + saveClaims(key, []); + touched = true; + } + if (absorb(plan.identity) > 0) touched = true; + if (!touched) return 0; + + const entries = [...merged.values()]; + saveClaims(plan.identity, entries.map((e) => e.claim)); + return entries.filter((e) => e.relocated).length; +} + +/** + * Every re-rooted tree that exists on disk must also be one git knows about; + * a re-rooted tree with nothing on disk is a stale record, which the + * reconciler prunes on its own and which must not fail an otherwise correct + * move. + */ +async function verifyLocate(plan: LocatePlan): Promise<{ error: string | null; stalePaths: string[] }> { + const listed = await listWorktreesAsync(plan.newPath); + if (listed === null) return { error: `git worktree list failed in ${plan.newPath}`, stalePaths: [] }; + const known = new Set(listed.map((w) => canon(w.path))); + // git lists the main worktree FIRST — membership alone would accept a linked + // worktree of the same repo as the new root. + if (listed[0] === undefined || canon(listed[0].path) !== canon(plan.newPath)) { + return { error: `${plan.newPath} is not the main worktree git reports`, stalePaths: [] }; + } + + const stalePaths: string[] = []; + for (const rewrite of plan.registryRewrites) { + for (const path of rewrite.movedPaths) { + if (!existsSync(path)) { + stalePaths.push(path); + continue; + } + if (!known.has(canon(path))) { + return { error: `${path} exists but git does not list it as a worktree of ${plan.newPath}`, stalePaths }; + } + } + } + return { error: null, stalePaths }; +} + +/** Why a legacy row outlived the collapse, in the terms the operator has to act on. */ +function retentionReason(data: DataMigration): string { + const parts: string[] = []; + if (data.refused.length > 0) parts.push(`both names hold ${data.refused.join(", ")}`); + if (data.registry === "refused") parts.push("its worktree registry could not be written"); + return parts.join("; "); +} + +/** + * Collapse the legacy half of a healed pair, on prune's rules: the row is + * dropped only once its data dir has fully moved, because eviction is what + * makes a leftover unreachable. + * + * INVARIANT: a legacy index row must never name a LIVE path without owning a + * worktree registry. Its registry merged onto the identity inside the + * transaction, so a retained row is written back to the (now dead) `oldPath`: + * a reconcile pass keyed on that row then bails on the missing path, where a + * live path would make it derive the repo's worktree settings, adopt every + * tree as unmanaged under the legacy key, and replenish a second pool beside + * the real one. + */ +function collapseLegacyRows(plan: LocatePlan): LocateResult["legacyRows"] { + const out: LocateResult["legacyRows"] = []; + for (const key of plan.legacyKeys) { + const data = migrateRepoData(key, plan.identity); + if (migrationIncomplete(data)) { + setIndexPath(key, plan.oldPath); + out.push({ key, outcome: "retained", reason: retentionReason(data) }); + continue; + } + removeIndexRow(key); + out.push({ key, outcome: "collapsed" }); + } + return out; +} + +/** + * Repair git's admin files for the moved trees. + * + * A path argument fixes both halves of the link for the tree it names (the + * main repo's `worktrees//gitdir` entry and that tree's own `.git` file); + * the no-arg pass then re-links the trees that did NOT move, whose `.git` + * files still point at the main worktree's old location. Both are needed + * because a folder move breaks both populations at once. + * + * `git worktree repair` exits non-zero on a path argument it cannot resolve, + * so the list is filtered to what exists — a re-rooted record with nothing on + * disk is the stale case verification reports, not a failed repair. + */ +async function repairGit(plan: LocatePlan): Promise<{ repaired: string[]; error: string | null }> { + const repaired = plan.gitRepairPaths.filter((path) => existsSync(path)); + if (repaired.length > 0) { + const r = await runGit(plan.newPath, ["worktree", "repair", ...repaired]); + if (r.exitCode !== 0) return { repaired: [], error: `git worktree repair failed: ${r.stderr.trim() || `exit ${r.exitCode}`}` }; + } + const all = await runGit(plan.newPath, ["worktree", "repair"]); + if (all.exitCode !== 0) return { repaired: [], error: `git worktree repair failed: ${all.stderr.trim() || `exit ${all.exitCode}`}` }; + return { repaired, error: null }; +} + +/** + * Git first, state.db last. Until the transaction commits, the index still + * names the dead path, so a reconciler pass that interleaves with the repair + * finds a repo whose path does not exist and bails instead of pruning trees + * whose gitdir pointers are still being fixed. Nothing is written unless the + * whole move verifies, which is why no rollback exists. + */ +export async function applyLocate(plan: LocatePlan): Promise { + const base = { + identity: plan.identity, + from: plan.oldPath, + to: plan.newPath, + indexKeys: plan.indexKeys, + }; + const failed = (repaired: string[], stalePaths: string[], error: string): LocateResult => ({ + ...base, + ok: false, + treesRewritten: 0, + claimsRewritten: 0, + repaired, + stalePaths, + legacyRows: [], + error, + }); + + const repair = await repairGit(plan); + if (repair.error !== null) return failed(repair.repaired, [], repair.error); + + const { error, stalePaths } = await verifyLocate(plan); + if (error !== null) return failed(repair.repaired, stalePaths, error); + + // bun:sqlite transactions are sync-only: every git call lives above this + // block, never inside it. + let treesRewritten = 0; + let claimsRewritten = 0; + getStateDb().transaction(() => { + for (const key of indexWriteKeys(plan)) setIndexPath(key, plan.newPath); + treesRewritten = writeRegistries(plan); + claimsRewritten = writeClaims(plan); + })(); + refreshRepoIndexMirror(); + + const legacyRows = collapseLegacyRows(plan); + refreshRepoIndexMirror(); + return { ...base, ok: true, treesRewritten, claimsRewritten, repaired: repair.repaired, stalePaths, legacyRows }; +} + +/** + * Directories the repo scan surfaced whose derived identity is one of the + * index's lost rows — the candidate set `rt repos locate` offers when it is + * given no path. Never auto-picked: this only proposes. + */ +export async function findLocateCandidates(): Promise { + const repos = getKnownRepos({ includeMissing: true }); + const lostKeys = new Set(repos.filter((r) => r.missing).map((r) => r.repoName)); + if (lostKeys.size === 0) return []; + + const candidates: LocateCandidate[] = []; + for (const repo of repos) { + if (repo.registered !== false) continue; + const path = repo.worktrees[0]?.path; + if (!path) continue; + let identity: string; + try { + identity = serializeIdentity(await deriveRepoIdentity(path)); + } catch { + continue; + } + if (!lostKeys.has(identity)) continue; + candidates.push({ path, identity }); + } + return candidates; +} diff --git a/lib/repo.ts b/lib/repo.ts index 6484bf4e..6fff086e 100644 --- a/lib/repo.ts +++ b/lib/repo.ts @@ -14,12 +14,12 @@ import { identityFromRemote, serializeIdentity } from "./settings/identity.ts"; // ─── Re-exports ────────────────────────────────────────────────────────────── export { getRepoRoot, getCurrentBranch, getRemoteUrl } from "./git.ts"; -export { updateRepoIndex, getKnownRepos, repoOption, repoOptions, type KnownRepo } from "./repo-index.ts"; +export { updateRepoIndex, getKnownRepos, repoOption, repoOptions, repoFromOptionValue, missingRepoRefusal, type KnownRepo } from "./repo-index.ts"; // ─── Internal imports ──────────────────────────────────────────────────────── import { getRepoRoot, getRemoteUrl } from "./git.ts"; -import { updateRepoIndex, getKnownRepos, repoOption, repoOptions, type KnownRepo } from "./repo-index.ts"; +import { updateRepoIndex, getKnownRepos, repoOption, repoOptions, repoFromOptionValue, missingRepoRefusal, type KnownRepo } from "./repo-index.ts"; // ─── Constants ─────────────────────────────────────────────────────────────── @@ -184,6 +184,23 @@ export async function requireIdentity(commandLabel?: string): Promise !r.missing); + return live.length === 1 ? live : repos; +} + /** * Get repo identity at the repo level (no worktree picker step). * Falls back to a repo-only picker if not currently inside a git repo. @@ -195,7 +212,7 @@ export async function requireRepoIdentity(commandLabel?: string): Promise 1) { + if (choices.length > 1) { if (!process.stdin.isTTY) { console.log(`\n not in a git repo — run interactively to pick one\n`); process.exit(1); @@ -214,14 +232,15 @@ export async function requireRepoIdentity(commandLabel?: string): Promise r.repoName === picked); + const match = repoFromOptionValue(choices, picked); if (!match) process.exit(0); selectedRepo = match; } + refuseIfMissing(selectedRepo); process.chdir(selectedRepo.worktrees[0]!.path); identity = getRepoIdentity(); @@ -239,7 +258,7 @@ export async function requireRepoIdentity(commandLabel?: string): Promise { - const repos = getKnownRepos(); + const repos = getKnownRepos({ includeMissing: true }); if (repos.length === 0) { console.log(`\n not in a git repo and no known repos found`); @@ -247,9 +266,11 @@ export async function pickWorktree(prompt: string): Promise { process.exit(1); } - const totalWorktrees = repos.reduce((n, r) => n + r.worktrees.length, 0); + const choices = pickableRepos(repos); + const totalWorktrees = choices.reduce((n, r) => n + r.worktrees.length, 0); if (totalWorktrees === 1) { - return repos[0]!.worktrees[0]!.path; + refuseIfMissing(choices[0]!); + return choices[0]!.worktrees[0]!.path; } if (!process.stdin.isTTY) { @@ -259,18 +280,19 @@ export async function pickWorktree(prompt: string): Promise { let selectedRepo: KnownRepo; - if (repos.length === 1) { - selectedRepo = repos[0]!; + if (choices.length === 1) { + selectedRepo = choices[0]!; } else { const { filterableSelect } = await import("./rt-render.tsx"); - const options = repoOptions(repos); + const options = repoOptions(choices); const picked = await filterableSelect({ message: "Select a repo", options }); if (!picked) process.exit(0); // user escaped — clean exit, no error - const match = repos.find(r => r.repoName === picked); + const match = repoFromOptionValue(choices, picked); if (!match) process.exit(0); // shouldn't happen, but don't crash selectedRepo = match; } + refuseIfMissing(selectedRepo); if (selectedRepo.worktrees.length === 1) { return selectedRepo.worktrees[0]!.path; @@ -401,7 +423,7 @@ async function pickFromAllRepos(repos: KnownRepo[]): Promise { const pickedRepo = await filterableSelect({ message: "Pick a repo", options }); if (!pickedRepo) process.exit(0); // Esc on all-repos picker - const repo = repos.find((r) => r.repoName === pickedRepo); + const repo = repoFromOptionValue(repos, pickedRepo); if (!repo) process.exit(0); if (repo.worktrees.length === 1) { diff --git a/lib/setup/__tests__/steps-a.test.ts b/lib/setup/__tests__/steps-a.test.ts index d49d7862..7e573612 100644 --- a/lib/setup/__tests__/steps-a.test.ts +++ b/lib/setup/__tests__/steps-a.test.ts @@ -8,7 +8,8 @@ import { HELPERS_DIR, RT_BUNDLE_PATH, __test__ as bundleLayoutTest } from "../.. import { rtDir, teamSettingsPath } from "../../rt-paths.ts"; import { getSetting } from "../../settings/resolve.ts"; import { setSetting } from "../../settings/write.ts"; -import { closeStateDb } from "../../state/index.ts"; +import { closeStateDb, setKvValue } from "../../state/index.ts"; +import { serializeIdentity } from "../../settings/identity.ts"; import { linkPath } from "../../deps/links.ts"; import type { ExecResult, Probes } from "../probes.ts"; import type { SecretsExecResult, SecretsExecSeam, SecretsSeams } from "../../secrets/store.ts"; @@ -827,6 +828,23 @@ describe("path.link / settings.seed / repos.clone / intercepts.install (real HOM expect(logs.some((l) => l.line.includes("isn't a clone of"))).toBe(true); }); + test("repos.clone: an identity whose index row moved and cannot be located is counted failed, never cloned", async () => { + setSetting("rt.repoRoots", [join(home, "code")], "machine"); + mkdirSync(join(home, "code"), { recursive: true }); + const dest = join(home, "code", "acme-dev"); + // The row names a directory that is gone, so indexing `dest` is a MOVE, + // not a write — and the clone here is faked, so there is nothing at + // `dest` for the locate to move onto and it refuses. + setKvValue("repo-index", serializeIdentity({ kind: "remote", id: "gitlab.com/acme/acme-dev" }), join(home, "gone-away")); + + const p = fakeProbes({ home, exec: async () => ok() }); + const { ctx, logs } = makeCtx(p, { snapshot: { slug: "acme", integrations: {}, trackingIdentities: ["gitlab.com/acme/acme-dev"], marketplaces: [], plugins: [], remote: null } }); + + const outcome = await reposCloneStep.run(ctx); + expect(outcome).toEqual({ state: "done", detail: "cloned 0, present 0, failed 1" }); + expect(logs.some((l) => l.line.includes("could not be moved"))).toBe(true); + }); + test("repos.clone: zero identities to clone -> skipped, never a hard failure", async () => { const p = fakeProbes({ home }); // no rt.repoRoots configured either — must not matter const { ctx } = makeCtx(p); diff --git a/lib/setup/steps/repos.ts b/lib/setup/steps/repos.ts index d5f75086..8c507701 100644 --- a/lib/setup/steps/repos.ts +++ b/lib/setup/steps/repos.ts @@ -8,7 +8,7 @@ import { join } from "path"; import { getSetting } from "../../settings/resolve.ts"; -import { updateRepoIndex } from "../../repo-index.ts"; +import { updateRepoIndexAsync } from "../../repo-index.ts"; import { serializeIdentity } from "../../settings/identity.ts"; import { withoutUrls } from "../../team/redact.ts"; import type { ApplyContext } from "../apply.ts"; @@ -34,6 +34,20 @@ function skippedIdentities(env: Record): Set ); } +/** + * Index `dest` under the tracked identity, reporting a refused move rather + * than counting the repo present/cloned: the row would still name the path + * the repo moved away from, so the tally would claim a repo rt cannot reach. + */ +async function indexDest(ctx: ApplyContext, identity: string, base: string, dest: string): Promise { + // The index keys on the serialized identity; `identity` here is already the + // raw host/path the tracked-repos setting carries. + const indexed = await updateRepoIndexAsync(serializeIdentity({ kind: "remote", id: identity }), dest); + if (indexed.ok) return true; + ctx.log("repos.clone", `${base}: ${dest} is in place, but ${identity} is indexed at a path that no longer exists and could not be moved — ${indexed.error}; run: rt repos locate ${dest}`); + return false; +} + /** A real clone of `identity`, not just any directory that happens to share its basename — two tracked identities can collide on basename (`gitlab.com/a/api`, `github.com/b/api`), and an unrelated folder can already occupy the path. */ function isCloneOf(p: ApplyContext["p"], dest: string, identity: string): boolean { const config = p.readFile(join(dest, ".git", "config")); @@ -83,13 +97,13 @@ async function reposCloneRunUnsafe(ctx: ApplyContext): Promise { const dest = join(root, base); if (p.exists(dest)) { - if (isCloneOf(p, dest, identity)) { - present++; - updateRepoIndex(serializeIdentity({ kind: "remote", id: identity }), dest); - } else { + if (!isCloneOf(p, dest, identity)) { failed++; ctx.log("repos.clone", `${base}: ${dest} exists but isn't a clone of ${identity} (basename collision or unrelated folder) — resolve by hand`); + continue; } + if (await indexDest(ctx, identity, base, dest)) present++; + else failed++; continue; } @@ -100,10 +114,8 @@ async function reposCloneRunUnsafe(ctx: ApplyContext): Promise { continue; } - cloned++; - // The index keys on the serialized identity; `identity` here is already - // the raw host/path the tracked-repos setting carries. - updateRepoIndex(serializeIdentity({ kind: "remote", id: identity }), dest); + if (await indexDest(ctx, identity, base, dest)) cloned++; + else failed++; } return { state: "done", detail: `cloned ${cloned}, present ${present}, failed ${failed}` }; diff --git a/lib/worktree/__tests__/registry-merge.test.ts b/lib/worktree/__tests__/registry-merge.test.ts new file mode 100644 index 00000000..04518d6e --- /dev/null +++ b/lib/worktree/__tests__/registry-merge.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import { mergeRegistries, type TreeRecord } from "../registry.ts"; + +function rec(over: Partial & { path: string }): TreeRecord { + return { + name: over.path.split("/").pop()!, + kind: "unmanaged", + branch: null, + createdAt: "2026-01-01T00:00:00.000Z", + ...over, + }; +} + +describe("mergeRegistries", () => { + test("unions disjoint paths, winner side first", () => { + const merged = mergeRegistries([rec({ path: "/a/main" })], [rec({ path: "/a/tree-1" })]); + expect(merged.map((t) => t.path)).toEqual(["/a/main", "/a/tree-1"]); + }); + + test("an empty loser returns the winner unchanged", () => { + const winner = [rec({ path: "/a/main" }), rec({ path: "/a/tree-1" })]; + expect(mergeRegistries(winner, [])).toEqual(winner); + }); + + test("an empty winner returns the loser's records", () => { + const loser = [rec({ path: "/a/main", kind: "main" })]; + expect(mergeRegistries([], loser)).toEqual(loser); + }); + + test("on a shared path the managed record wins, whichever side it is on", () => { + const claimed = rec({ path: "/a/tree-1", kind: "ephemeral", state: "claimed", owner: "matt" }); + const adopted = rec({ path: "/a/tree-1", kind: "unmanaged" }); + + expect(mergeRegistries([adopted], [claimed])[0]).toEqual(claimed); + expect(mergeRegistries([claimed], [adopted])[0]).toEqual(claimed); + }); + + test("two managed records on one path: the later createdAt wins", () => { + const older = rec({ path: "/a/tree-1", kind: "ephemeral", state: "on-deck", createdAt: "2026-01-01T00:00:00.000Z" }); + const newer = rec({ path: "/a/tree-1", kind: "ephemeral", state: "claimed", createdAt: "2026-02-01T00:00:00.000Z" }); + + expect(mergeRegistries([older], [newer])[0]).toEqual(newer); + expect(mergeRegistries([newer], [older])[0]).toEqual(newer); + }); + + test("an equal createdAt keeps the winner side", () => { + const w = rec({ path: "/a/tree-1", kind: "ephemeral", state: "on-deck", owner: "winner" }); + const l = rec({ path: "/a/tree-1", kind: "ephemeral", state: "on-deck", owner: "loser" }); + expect(mergeRegistries([w], [l])[0]!.owner).toBe("winner"); + }); + + test("an unparseable createdAt never displaces the winner", () => { + const w = rec({ path: "/a/tree-1", kind: "ephemeral", createdAt: "2026-01-01T00:00:00.000Z", owner: "winner" }); + const l = rec({ path: "/a/tree-1", kind: "ephemeral", createdAt: "not a date", owner: "loser" }); + expect(mergeRegistries([w], [l])[0]!.owner).toBe("winner"); + }); + + test("a duplicate path inside one side keeps its first occurrence", () => { + const first = rec({ path: "/a/tree-1", kind: "ephemeral", owner: "first" }); + const second = rec({ path: "/a/tree-1", kind: "ephemeral", owner: "second" }); + expect(mergeRegistries([first, second], [])).toEqual([first]); + }); +}); diff --git a/lib/worktree/registry.ts b/lib/worktree/registry.ts index 762bbe14..aadf0ead 100644 --- a/lib/worktree/registry.ts +++ b/lib/worktree/registry.ts @@ -1,6 +1,7 @@ +import { realpathSync } from "fs"; import { join } from "path"; import { repoDataDir } from "../rt-paths.ts"; -import { getKvValue, hasKvValue, importLegacyJsonFile, setKvValue } from "../state/index.ts"; +import { deleteKvValue, getKvValue, hasKvValue, importLegacyJsonFile, setKvValue } from "../state/index.ts"; export type TreeKind = "main" | "ephemeral" | "unmanaged"; export type TreeState = "creating" | "on-deck" | "claimed" | "disposable"; @@ -97,3 +98,61 @@ export function findByBranch(trees: TreeRecord[], branch: string): TreeRecord[] export function usedNames(trees: TreeRecord[]): Set { return new Set(trees.map((t) => t.name)); } + +/** Canonical path key: a tree that no longer exists compares by its own spelling rather than throwing. */ +function canonPath(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +const MANAGED_KINDS: ReadonlySet = new Set(["main", "ephemeral"]); + +/** + * Total order for two records of the same canonical path: a managed record + * carries claim/ready state no git repository has another copy of, so it beats + * `unmanaged`; within one class the later `createdAt` wins; an equal or + * unparseable stamp keeps the winner side. + */ +function heldRecordWins(held: TreeRecord, challenger: TreeRecord): boolean { + const heldManaged = MANAGED_KINDS.has(held.kind); + const challengerManaged = MANAGED_KINDS.has(challenger.kind); + if (heldManaged !== challengerManaged) return heldManaged; + return !(Date.parse(challenger.createdAt) > Date.parse(held.createdAt)); +} + +/** + * Union two registries of the SAME repo by canonical path — the collapse a + * name/identity index pair needs, where each side owns half of one on-deck + * pool. Name collisions across the two sides are left standing: the union is + * by path, and a record's name is only ever consulted for display and for + * `usedNames` disambiguation, both of which tolerate a duplicate. + */ +export function mergeRegistries(winner: TreeRecord[], loser: TreeRecord[]): TreeRecord[] { + const byPath = new Map(); + const order: string[] = []; + for (const rec of [...winner, ...loser]) { + const key = canonPath(rec.path); + const held = byPath.get(key); + if (!held) { + byPath.set(key, rec); + order.push(key); + continue; + } + if (!heldRecordWins(held, rec)) byPath.set(key, rec); + } + return order.map((key) => byPath.get(key)!); +} + +/** Whether this repo has a registry row at all — distinct from an empty registry. */ +export function hasRegistry(repoName: string): boolean { + return hasKvValue(WORKTREE_REGISTRY_NS, repoName); +} + +/** Drop a whole registry row. Only ever the retired half of a pair, after its records have been merged onto the survivor. */ +export function deleteRegistry(repoName: string): void { + deleteKvValue(WORKTREE_REGISTRY_NS, repoName); + epochs.set(repoName, registryEpoch(repoName) + 1); +} diff --git a/website/docs/reference/repos/index.mdx b/website/docs/reference/repos/index.mdx index 00705e55..a9bb3b39 100644 --- a/website/docs/reference/repos/index.mdx +++ b/website/docs/reference/repos/index.mdx @@ -21,5 +21,6 @@ rt repos | --- | --- | | [`register`](register) | Add repo paths to the rt index, optionally granting background tracking | | [`prune`](prune) | Drop index entries whose path is gone, and duplicate names left behind by a repo rename | +| [`locate`](locate) | Tell rt where a repo moved to — re-points the index, worktree registry, endpoint claims and git's worktree admin files together | {/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/repos/locate.mdx b/website/docs/reference/repos/locate.mdx new file mode 100644 index 00000000..c6e3bd9c --- /dev/null +++ b/website/docs/reference/repos/locate.mdx @@ -0,0 +1,29 @@ +--- +title: rt repos locate +sidebar_label: locate +--- + +# rt repos locate + +`rt › repos › locate` + +Tell rt where a repo moved to — re-points the index, worktree registry, endpoint claims and git's worktree admin files together + +## Usage + +```bash +rt repos locate [flags] +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| `` | text | | Where the repo lives now; omit to pick from candidates under rt.repoRoots | +| [`--repo`](/guides/common-flags) | text | | Which indexed repo moved (identity, path, or name); omit to match by the new path's own identity | +| [`--dry-run`](/guides/common-flags) | boolean | `false` | Print what would be re-pointed without writing | +| [`--json`](/guides/common-flags) | boolean | `false` | Machine-readable result | + +_See code: [commands/repos.ts › reposLocate](https://github.com/m4ttstack/rt/blob/main/commands/repos.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file