diff --git a/commands/cd.ts b/commands/cd.ts index bfb48d67..5e2012b6 100644 --- a/commands/cd.ts +++ b/commands/cd.ts @@ -52,6 +52,12 @@ const SHELL_FUNCTION = [ ` elif [ "$1" = "nav" ]; then`, ` local dir`, ` dir="$(COLUMNS=$COLUMNS "$rt_bin" nav "\${@:2}")" && [ -n "$dir" ] && builtin cd "$dir"`, + ` elif [ "$1" = "worktree" ] && [ -z "$2" ]; then`, + ` # Bare 'rt worktree' is the nav picker (same contract as cd/nav) — jump`, + ` # into the selected tree. 'rt worktree ' passes straight`, + ` # through below.`, + ` local dir`, + ` dir="$(COLUMNS=$COLUMNS "$rt_bin" worktree)" && [ -n "$dir" ] && builtin cd "$dir"`, ` elif [ "$1" = "x" ]; then`, ` "$rt_bin" "$@"`, ` local rt_cwd`, @@ -79,7 +85,13 @@ async function ensureShellFunction(): Promise { } catch { /* no rc file yet */ } // Latest version marker: whence -p / type -P PATH-only lookup (fixes FUNCNEST recursion) - if (rcContent.includes('rt() {') && rcContent.includes('whence -p rt') && rcContent.includes('"$rt_bin" nav')) return; + // + bare 'rt worktree' cd-jump support. + if ( + rcContent.includes('rt() {') && + rcContent.includes('whence -p rt') && + rcContent.includes('"$rt_bin" nav') && + rcContent.includes('"$1" = "worktree"') + ) return; // Redirect stdout → stderr before showing prompts const origWrite = process.stdout.write.bind(process.stdout); @@ -95,7 +107,9 @@ async function ensureShellFunction(): Promise { const hasHashCacheBug = rcContent.includes("rt() {") && rcContent.includes("command rt cd") && !rcContent.includes("local rt_bin"); // Uses command -v which returns the function name in zsh, causing infinite recursion const hasFuncnestBug = rcContent.includes("rt() {") && rcContent.includes("command -v rt") && !rcContent.includes("whence -p rt"); - const hasOldFunction = hasLegacyRtcd || hasOldRtWrapper || hasPreRehashWrapper || hasHashCacheBug || hasFuncnestBug; + // Function exists and is otherwise current, but predates bare 'rt worktree' cd-jump support. + const hasNoWorktreeNav = rcContent.includes("rt() {") && rcContent.includes('"$rt_bin" nav') && !rcContent.includes('"$1" = "worktree"'); + const hasOldFunction = hasLegacyRtcd || hasOldRtWrapper || hasPreRehashWrapper || hasHashCacheBug || hasFuncnestBug || hasNoWorktreeNav; if (hasFuncnestBug) { console.error(`\n ${yellow}Upgrading rt shell wrapper: fix FUNCNEST recursion in zsh${reset}`); @@ -103,6 +117,8 @@ async function ensureShellFunction(): Promise { console.error(`\n ${yellow}Upgrading rt shell wrapper: auto-rehash after dev-mode toggle${reset}`); } else if (hasNoNav) { console.error(`\n ${yellow}Upgrading rt shell wrapper: adding rt nav cd support${reset}`); + } else if (hasNoWorktreeNav) { + console.error(`\n ${yellow}Upgrading rt shell wrapper: adding rt worktree cd support${reset}`); } else if (hasHashCacheBug) { console.error(`\n ${yellow}Upgrading rt shell wrapper: resolve dev-mode binary by absolute path${reset}`); } else if (hasOldRtWrapper) { @@ -131,7 +147,7 @@ async function ensureShellFunction(): Promise { process.exit(0); } - if (hasOldRtWrapper || hasPreRehashWrapper || hasNoNav || hasHashCacheBug || hasFuncnestBug) { + if (hasOldRtWrapper || hasPreRehashWrapper || hasNoNav || hasHashCacheBug || hasFuncnestBug || hasNoWorktreeNav) { rcContent = rcContent .replace(/\n?# rt — shell wrapper \(enables rt cd to change directory\)\n?/g, "") .replace(/\n?rt\(\) \{[\s\S]*?\n\}\n?/g, "\n"); diff --git a/commands/hooks.ts b/commands/hooks.ts index 3b986fb4..1e33f6cb 100644 --- a/commands/hooks.ts +++ b/commands/hooks.ts @@ -88,18 +88,18 @@ function generateShims(dataDir: string, discoveredHooks: string[]): void { const configFile = hooksConfigPath(dataDir); - // Always include pre-commit so the parking-lot guard runs even if the repo + // Always include pre-commit so the on-deck guard runs even if the repo // has no .husky/pre-commit of its own. const hookNames = [...new Set(["pre-commit", ...discoveredHooks])]; for (const hookName of hookNames) { const shimPath = join(hooksDir, hookName); - const parkingLotGuard = hookName === "pre-commit" ? ` -# Parking-lot guard: block commits on parking-lot/* branches. + const onDeckGuard = hookName === "pre-commit" ? ` +# On-deck guard: block commits on on-deck/* branches. CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null) -if [[ "$CURRENT_BRANCH" == parking-lot/* ]]; then - echo "rt: commits are not allowed on parking-lot branches (on \\"$CURRENT_BRANCH\\")" +if [[ "$CURRENT_BRANCH" == on-deck/* ]]; then + echo "rt: commits are not allowed on on-deck branches (on \\"$CURRENT_BRANCH\\")" echo "rt: switch to a feature branch before committing" exit 1 fi @@ -108,7 +108,7 @@ fi const shim = `#!/bin/bash # rt hook shim — checks ~/.rt config before running the real hook # Fail-safe: if config is missing or unreadable, the real hook runs -${parkingLotGuard} +${onDeckGuard} HOOKS_CONFIG="${configFile}" HOOK_NAME="${hookName}" diff --git a/commands/parking-lot.ts b/commands/parking-lot.ts deleted file mode 100644 index 8da74860..00000000 --- a/commands/parking-lot.ts +++ /dev/null @@ -1,356 +0,0 @@ -#!/usr/bin/env bun - -/** - * rt parking-lot — inspect and control the daemon's auto-park feature. - * - * The daemon watches tracked worktree branches for MRs that transition to - * `merged` / `closed` and auto-parks the worktree onto `parking-lot/` - * (stash → fast-forward from origin/master). This command is the user-facing - * lever: toggle the feature, view the current bindings, or fire a manual scan. - * - * Usage: - * rt parking-lot → same as `status` - * rt parking-lot status → show enabled flag + worktree bindings - * rt parking-lot enable → turn auto-park on - * rt parking-lot disable → turn auto-park off - * rt parking-lot scan → run the park check once against live cache - * rt park this → park the current worktree now - * rt park pick → multi-select worktrees in this repo to park - */ - -import { existsSync, readFileSync } from "fs"; -import { join } from "path"; -import { bold, cyan, dim, green, reset, yellow, red } from "../lib/tui.ts"; -import { RT_DIR } from "../lib/daemon-config.ts"; -import { - loadParkingLotConfig, - saveParkingLotConfig, - PARKING_LOT_CONFIG_PATH, -} from "../lib/parking-lot-config.ts"; -import { describeRepoBindings, isParkable, park } from "../lib/daemon/parking-lot.ts"; -import { daemonQuery, lastQueryTimedOut } from "../lib/daemon-client.ts"; -import { getRepoIdentity, requireRepoIdentity } from "../lib/repo.ts"; -import { getCurrentBranch } from "../lib/git-ops.ts"; - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -function loadRepos(): Record { - try { - return JSON.parse(readFileSync(join(RT_DIR, "repos.json"), "utf8")); - } catch { - return {}; - } -} - -function dot(enabled: boolean): string { - return enabled ? `${green}●${reset}` : `${dim}○${reset}`; -} - -/** Render one repo's parking-lot bindings (auto-park slot table). */ -function printRepoBindings(repoName: string, repoPath: string): void { - console.log(` ${bold}${repoName}${reset} ${dim}${repoPath}${reset}`); - - if (!existsSync(repoPath)) { - console.log(` ${yellow}⚠${reset} path missing on disk\n`); - return; - } - - const bindings = describeRepoBindings(repoName, repoPath, { withStaleness: true }) - .sort((a, b) => a.index - b.index); - if (bindings.length === 0) { - console.log(` ${dim}no worktrees${reset}\n`); - return; - } - - // Staleness is only worth showing where it means something is wrong. An - // unparked slot trails master by design... its worktree is off on a feature - // branch, and park() fast-forwards the slot when it comes back. A *parked* - // slot that trails is the real signal: the background sweep isn't keeping it - // current. Annotating both drowns the second in the first. - const STALE_AT = 50; - - const widest = Math.max(...bindings.map(b => String(b.index).length)); - const repoDir = repoPath.replace(/\/[^/]+\/?$/, ""); - const wtNames = bindings.map(b => b.path.startsWith(repoDir + "/") ? b.path.slice(repoDir.length + 1) : b.path); - const widestWt = Math.max(...wtNames.map(s => s.length)); - let staleCount = 0; - for (let i = 0; i < bindings.length; i++) { - const b = bindings[i]!; - const idx = String(b.index).padStart(widest); - const wt = wtNames[i]!.padEnd(widestWt); - const slot = `parking-lot/${b.index}`; - const status = b.branch === null ? `${dim}(detached)${reset}` - : b.branch === slot ? `${green}parked${reset}` - : b.branch; - const behind = b.slotBehind ?? 0; - const stuck = b.branch === slot && behind >= STALE_AT; - if (stuck) staleCount++; - const stale = stuck ? ` ${yellow}${behind} behind${reset}` : ""; - console.log(` ${cyan}park/${idx}${reset} ${dim}${wt}${reset} ${status}${stale}`); - } - console.log(""); - if (staleCount > 0) { - console.log(` ${yellow}⚠${reset} ${staleCount} parked slot${staleCount > 1 ? "s are" : " is"} not being kept current ${dim}... check the daemon: rt daemon logs${reset}\n`); - } -} - -// ─── Commands ──────────────────────────────────────────────────────────────── - -export async function statusCommand(): Promise { - const config = loadParkingLotConfig(); - const repos = loadRepos(); - - console.log(` ${dot(config.enabled)} auto-park ${config.enabled ? `${green}enabled${reset}` : `${dim}disabled${reset}`}`); - console.log(` ${dim}config: ${PARKING_LOT_CONFIG_PATH}${reset}`); - console.log(""); - - if (Object.keys(repos).length === 0) { - console.log(` ${dim}no repos tracked — register one with rt from inside a repo${reset}\n`); - return; - } - - // Scope to a single repo rather than dumping every tracked repo at once: - // the current repo when invoked from inside one, otherwise the shared repo - // picker (auto-selects when only one repo is known). - const identity = await requireRepoIdentity("park status"); - const repoPath = repos[identity.repoName] ?? identity.repoRoot; - printRepoBindings(identity.repoName, repoPath); -} - -export async function enableCommand(): Promise { - const current = loadParkingLotConfig(); - if (current.enabled) { - console.log(`\n ${dim}auto-park is already enabled${reset}\n`); - return; - } - saveParkingLotConfig({ ...current, enabled: true }); - console.log(`\n ${green}✓${reset} auto-park enabled\n`); - console.log(` ${dim}the daemon will resume parking worktrees on the next cache refresh${reset}\n`); -} - -export async function disableCommand(): Promise { - const current = loadParkingLotConfig(); - if (!current.enabled) { - console.log(`\n ${dim}auto-park is already disabled${reset}\n`); - return; - } - saveParkingLotConfig({ ...current, enabled: false }); - console.log(`\n ${green}✓${reset} auto-park disabled\n`); - console.log(` ${dim}daemon scans will no-op until you run: rt parking-lot enable${reset}\n`); -} - -interface ParkOutcome { - result: { ok: boolean; action: string; detail?: string }; - logs: string[]; -} - -/** - * Park one worktree, animating a spinner with `label` while we wait. Routes - * through the daemon so spinner animation isn't blocked by the execSync chain - * inside park(); falls back to in-process if the daemon isn't reachable. - */ -async function runParkWithSpinner( - label: string, - worktreePath: string, - repoPath: string, - branch: string | null, - index: number, -): Promise { - const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠣", "⠏"]; - let fi = 0; - const renderFrame = () => { - process.stderr.write(`\r ${cyan}${frames[fi++ % frames.length]}${reset} ${dim}${label}…${reset}`); - }; - renderFrame(); - const spinner = setInterval(renderFrame, 80); - - let result: ParkOutcome["result"]; - let logs: string[] = []; - - const response = await daemonQuery( - "parking-lot:park-this", - { worktreePath, repoPath, branch, index }, - 60_000, - ); - - if (response?.ok && response.data?.result) { - result = response.data.result as typeof result; - logs = (response.data.lines as string[]) ?? []; - } else if (response === null && lastQueryTimedOut()) { - // The daemon accepted the park but didn't answer within the timeout — it - // may still be executing in this worktree. Running a second park - // concurrently risks duplicate stashes and index.lock contention. - result = { - ok: false, - action: "skip", - detail: "daemon park timed out — check the worktree state before retrying", - }; - } else { - result = park(worktreePath, repoPath, branch, index, { - killProcesses: loadParkingLotConfig().killProcesses, - }); - } - - clearInterval(spinner); - process.stderr.write(`\r\x1b[K`); - - return { result, logs }; -} - -function relWorktreeName(repoPath: string, worktreePath: string): string { - const repoDir = repoPath.replace(/\/[^/]+\/?$/, ""); - return worktreePath.startsWith(repoDir + "/") - ? worktreePath.slice(repoDir.length + 1) - : worktreePath; -} - -export async function parkThisCommand(): Promise { - const identity = getRepoIdentity(); - if (!identity) { - console.log(` ${red}✗${reset} not in a git repo\n`); - process.exit(1); - } - - const repos = loadRepos(); - const repoPath = repos[identity.repoName]; - if (!repoPath) { - console.log(` ${red}✗${reset} repo "${identity.repoName}" not registered in ~/.rt/repos.json\n`); - process.exit(1); - } - - const worktreePath = identity.repoRoot; - // A null branch means the worktree is detached (a warm-pool entry) — that's - // parkable too: we claim it onto its clean parking-lot/N slot. - const branch = getCurrentBranch(worktreePath); - - const bindings = describeRepoBindings(identity.repoName, repoPath); - const binding = bindings.find(b => b.path === worktreePath); - if (!binding || !binding.index) { - console.log(` ${red}✗${reset} no parking-lot index for ${worktreePath}\n`); - process.exit(1); - } - - const parkBranch = `parking-lot/${binding.index}`; - if (branch === parkBranch) { - console.log(` ${dim}already on ${parkBranch} — nothing to park${reset}\n`); - return; - } - - const from = branch ?? "(detached)"; - const { result, logs } = await runParkWithSpinner( - `parking ${from} → ${parkBranch}`, - worktreePath, repoPath, branch, binding.index, - ); - - if (result.ok) { - const defaultRef = result.detail?.match(/@ (\S+)/)?.[1] ?? "origin/master"; - console.log(` ${green}✓${reset} parked ${bold}${from}${reset} ${dim}→${reset} ${cyan}${parkBranch}${reset} ${dim}@ ${defaultRef}${reset}\n`); - } else { - for (const line of logs) console.log(` ${dim}${line}${reset}`); - console.log(` ${red}✗${reset} ${result.action}${result.detail ? ` — ${result.detail}` : ""}\n`); - process.exit(1); - } -} - -export async function parkPickCommand(): Promise { - // Scope to a repo: the current one when inside it, otherwise the shared - // repo picker (auto-selects when only one repo is known). - const identity = await requireRepoIdentity("park pick"); - - const repos = loadRepos(); - const repoPath = repos[identity.repoName] ?? identity.repoRoot; - - const bindings = describeRepoBindings(identity.repoName, repoPath); - - // Eligible: has a slot index and isn't already on it. Detached worktrees - // (warm-pool entries) qualify — see isParkable. - const eligible = bindings.filter(isParkable); - - if (eligible.length === 0) { - console.log(`\n ${dim}no worktrees to park — all are already parked${reset}\n`); - return; - } - - const widestWt = Math.max(...eligible.map(b => relWorktreeName(repoPath, b.path).length)); - const options = eligible.map(b => { - const wt = relWorktreeName(repoPath, b.path).padEnd(widestWt); - return { - value: b.path, - label: wt, - hint: `${b.branch ?? "(detached)"} → parking-lot/${b.index}`, - }; - }); - - const { filterableMultiselect } = await import("../lib/rt-render.tsx"); - const selected = await filterableMultiselect({ - message: `Pick worktrees to park (${identity.repoName})`, - options, - }); - - if (!selected || selected.length === 0) { - console.log(`\n ${dim}nothing selected${reset}\n`); - return; - } - - const selectedSet = new Set(selected); - const targets = eligible.filter(b => selectedSet.has(b.path)); - - console.log(""); - let okCount = 0; - let failCount = 0; - - for (const b of targets) { - const branch = b.branch; - const from = branch ?? "(detached)"; - const parkBranch = `parking-lot/${b.index}`; - const wt = relWorktreeName(repoPath, b.path); - - const { result, logs } = await runParkWithSpinner( - `parking ${wt} (${from} → ${parkBranch})`, - b.path, repoPath, branch, b.index, - ); - - if (result.ok) { - const defaultRef = result.detail?.match(/@ (\S+)/)?.[1] ?? "origin/master"; - console.log(` ${green}✓${reset} ${dim}${wt}${reset} ${bold}${from}${reset} ${dim}→${reset} ${cyan}${parkBranch}${reset} ${dim}@ ${defaultRef}${reset}`); - okCount++; - } else { - for (const line of logs) console.log(` ${dim}${line}${reset}`); - console.log(` ${red}✗${reset} ${dim}${wt}${reset} ${result.action}${result.detail ? ` — ${result.detail}` : ""}`); - failCount++; - } - } - - console.log(`\n ${dim}${okCount} parked${failCount ? `, ${failCount} failed` : ""}${reset}\n`); - if (failCount > 0) process.exit(1); -} - -export async function scanCommand(): Promise { - if (!loadParkingLotConfig().enabled) { - console.log(` ${yellow}⚠${reset} auto-park is disabled — scan is a no-op`); - console.log(` ${dim}run: rt parking-lot enable${reset}\n`); - return; - } - - const response = await daemonQuery("parking-lot:scan"); - if (!response) { - console.log(` ${red}✗${reset} daemon not reachable`); - console.log(` ${dim}run: rt daemon start${reset}\n`); - return; - } - if (!response.ok) { - console.log(` ${red}✗${reset} scan failed: ${response.error ?? "unknown error"}\n`); - return; - } - - const lines = (response.data?.lines as string[] | undefined) ?? []; - const parkingLines = lines.filter(l => l.startsWith("parking-lot:")); - - if (parkingLines.length === 0) { - console.log(` ${green}✓${reset} scan complete — nothing to park\n`); - return; - } - - for (const line of parkingLines) console.log(` ${line}`); - console.log(""); -} diff --git a/commands/worktree.ts b/commands/worktree.ts index 12c2d073..a36c6517 100644 --- a/commands/worktree.ts +++ b/commands/worktree.ts @@ -1,22 +1,26 @@ #!/usr/bin/env bun /** - * rt worktree each — run a command in each worktree of the current repo. + * rt worktree — the worktree lifecycle CLI (spec §3): provision, create, + * dispose, list, freshen, adopt, plus the bare `rt worktree` nav picker and + * `each` (run a command across worktrees). * - * rt worktree each '' interactive multi-select, then run - * rt worktree each --all '' run in every worktree (no picker) - * rt worktree each --parked '' run only in parked worktrees - * - * Sequential, continue-on-failure. Exits non-zero if any target failed. + * Every mutating verb here is a thin wrapper over the daemon's `worktree:*` + * handlers (`lib/daemon/handlers/worktree.ts`) — the daemon is the single + * writer of the registry, so there is no inline fallback when it's down + * (`daemonQuery` returning null is a hard stop, not a "do it locally" + * signal). The one exception is `worktreeEach`, which is read-only and falls + * back to enumerating worktrees straight from git. */ import { spawnSync } from "child_process"; import { existsSync, readFileSync } from "fs"; import { join } from "path"; -import { bold, dim, green, red, reset } from "../lib/tui.ts"; +import { bold, cyan, dim, green, red, reset, yellow } from "../lib/tui.ts"; import { RT_DIR } from "../lib/daemon-config.ts"; import { getRepoIdentity } from "../lib/repo.ts"; -import { describeRepoBindings, type WorktreeBinding } from "../lib/daemon/parking-lot.ts"; +import { daemonQuery, lastQueryTimedOut, type DaemonResponse } from "../lib/daemon-client.ts"; +import { listWorktrees } from "../lib/git-worktrees.ts"; import { parseEachArgs, filterTargets, @@ -24,8 +28,477 @@ import { formatSummary, hasFailures, type EachResult, + type WorktreeBinding, } from "../lib/worktree-each.ts"; +// Provision and create both do a targeted `git fetch` / cold clone (up to +// 5 min server-side per lib/daemon/handlers/worktree.ts) — give the round +// trip room beyond that. Dispose and freshen get their own generous budgets +// (freshen's fetch alone is budgeted 5min server-side, and a repo-wide sweep +// with no `tree` given can touch many trees in one call). The default 2s +// daemonQuery timeout used elsewhere is a client-not-a-daemon-op number — +// using it here would make the CLI report "daemon unavailable" while the +// daemon is still working. +const PROVISION_TIMEOUT_MS = 6 * 60_000; +const DISPOSE_TIMEOUT_MS = 2 * 60_000; +const FRESHEN_TIMEOUT_MS = 10 * 60_000; +const ADOPT_TIMEOUT_MS = 2 * 60_000; + +// ─── Arg parsing (pure — unit tested in lib/__tests__/worktree-cli-args.test.ts) ── + +/** A value token that itself looks like a flag (starts with "-") is never a value — the flag was passed bare. */ +function isFlagLike(token: string | undefined): boolean { + return token !== undefined && token.startsWith("-"); +} + +function takeFlag(args: string[], flag: string): { value: string | undefined; rest: string[] } { + const idx = args.indexOf(flag); + if (idx === -1 || args[idx + 1] === undefined || isFlagLike(args[idx + 1])) { + return { value: undefined, rest: args }; + } + return { value: args[idx + 1], rest: [...args.slice(0, idx), ...args.slice(idx + 2)] }; +} + +function takeBoolFlag(args: string[], flag: string): { present: boolean; rest: string[] } { + const idx = args.indexOf(flag); + if (idx === -1) return { present: false, rest: args }; + return { present: true, rest: [...args.slice(0, idx), ...args.slice(idx + 1)] }; +} + +export interface ProvisionArgs { + repoName?: string; + ticket?: string; + title?: string; + branch?: string; + owner?: string; + disposal?: string; + json: boolean; +} + +export function parseProvisionArgs(args: string[]): ProvisionArgs { + let rest = args; + const json = takeBoolFlag(rest, "--json"); rest = json.rest; + const repo = takeFlag(rest, "--repo"); rest = repo.rest; + const ticket = takeFlag(rest, "--ticket"); rest = ticket.rest; + const title = takeFlag(rest, "--title"); rest = title.rest; + const branch = takeFlag(rest, "--branch"); rest = branch.rest; + const owner = takeFlag(rest, "--owner"); rest = owner.rest; + const disposal = takeFlag(rest, "--disposal"); rest = disposal.rest; + return { + repoName: repo.value, + ticket: ticket.value, + title: title.value, + branch: branch.value, + owner: owner.value, + disposal: disposal.value, + json: json.present, + }; +} + +export interface CreateArgs { + repoName?: string; + onDeck: boolean; + json: boolean; +} + +export function parseCreateArgs(args: string[]): CreateArgs { + let rest = args; + const json = takeBoolFlag(rest, "--json"); rest = json.rest; + const onDeck = takeBoolFlag(rest, "--on-deck"); rest = onDeck.rest; + const repo = takeFlag(rest, "--repo"); rest = repo.rest; + return { repoName: repo.value, onDeck: onDeck.present, json: json.present }; +} + +export interface DisposeArgs { + tree?: string; + owner?: string; + repoName?: string; + force: boolean; + json: boolean; +} + +export function parseDisposeArgs(args: string[]): DisposeArgs { + let rest = args; + const json = takeBoolFlag(rest, "--json"); rest = json.rest; + const force = takeBoolFlag(rest, "--force"); rest = force.rest; + const owner = takeFlag(rest, "--owner"); rest = owner.rest; + const repo = takeFlag(rest, "--repo"); rest = repo.rest; + const tree = rest.find((a) => !a.startsWith("--")); + return { tree, owner: owner.value, repoName: repo.value, force: force.present, json: json.present }; +} + +export interface ListArgs { + repoName?: string; + json: boolean; +} + +export function parseListArgs(args: string[]): ListArgs { + let rest = args; + const json = takeBoolFlag(rest, "--json"); rest = json.rest; + const repo = takeFlag(rest, "--repo"); rest = repo.rest; + return { repoName: repo.value, json: json.present }; +} + +export interface FreshenArgs { + tree?: string; + repoName?: string; + json: boolean; +} + +export function parseFreshenArgs(args: string[]): FreshenArgs { + let rest = args; + const json = takeBoolFlag(rest, "--json"); rest = json.rest; + const repo = takeFlag(rest, "--repo"); rest = repo.rest; + const tree = rest.find((a) => !a.startsWith("--")); + return { tree, repoName: repo.value, json: json.present }; +} + +export interface AdoptArgs { + repoName?: string; + json: boolean; +} + +export function parseAdoptArgs(args: string[]): AdoptArgs { + let rest = args; + const json = takeBoolFlag(rest, "--json"); rest = json.rest; + const repo = takeFlag(rest, "--repo"); rest = repo.rest; + return { repoName: repo.value, json: json.present }; +} + +// ─── Shared IO helpers ─────────────────────────────────────────────────────── + +const DAEMON_DOWN_MESSAGE = "daemon unavailable — worktree lifecycle needs the daemon (rt daemon start)"; +const DAEMON_TIMEOUT_MESSAGE = "timed out — the daemon may still be working; check rt worktree list"; + +/** + * `daemonQuery` returned null: hard stop, no inline fallback (spec §3). Two + * very different reasons collapse to null — genuinely down, or a slow + * operation (provision's fetch, a repo-wide freshen sweep) outran its + * timeout while the daemon kept working — so `lastQueryTimedOut()` picks the + * message that doesn't lie about which one happened. + */ +function daemonUnavailable(): never { + const message = lastQueryTimedOut() ? DAEMON_TIMEOUT_MESSAGE : DAEMON_DOWN_MESSAGE; + console.log(`\n ${red}✗${reset} ${message}\n`); + process.exit(1); +} + +function failText(json: boolean, message: string): never { + if (json) console.log(JSON.stringify({ error: message })); + else console.log(`\n ${red}✗${reset} ${message}\n`); + process.exit(1); +} + +function explainError(error: string): string { + if (error === "busy") return "that worktree is locked by another operation right now — try again shortly"; + if (error === "repo-unknown") return "unknown repo — pass --repo or run from inside a registered repo"; + if (error === "branch-unresolved") return "need --branch or --ticket to name the work branch"; + if (error === "no-target") return "need a tree name or --owner to know what to dispose"; + if (error === "tree-ambiguous") return "that tree name matches worktrees in more than one repo — pass --repo to disambiguate"; + if (error === "branch-duplicated") return "that branch is already checked out in more than one worktree — run `rt worktree adopt`"; + if (error.startsWith("branch-attached:")) { + return `branch is already checked out in worktree "${error.slice("branch-attached:".length)}"`; + } + if (error.startsWith("checkout-failed:")) return `checkout failed: ${error.slice("checkout-failed:".length)}`; + if (error.startsWith("create-failed:")) return `worktree creation failed at step "${error.slice("create-failed:".length)}"`; + return error; +} + +function failResult(json: boolean, error: string): never { + if (json) console.log(JSON.stringify({ error })); + else console.log(`\n ${red}✗${reset} ${explainError(error)}\n`); + process.exit(1); +} + +function requireQueryResult(json: boolean, res: DaemonResponse | null): DaemonResponse { + if (res === null) daemonUnavailable(); + if (!res.ok) failResult(json, res.error ?? "unknown error"); + return res; +} + +function currentRepoName(): string | undefined { + return getRepoIdentity()?.repoName ?? undefined; +} + +// ─── Tree rows (worktree:list) shared by list / nav / the dispose+freshen pickers ── + +interface TreeRow { + name: string; + path: string; + kind: string; + state?: string; + branch: string | null; + owner?: string; + disposableReason?: string; + repoName: string; + mr?: { iid: number; state: string; title: string } | null; + duplicateBranch?: boolean; +} + +async function fetchTreeRows(json: boolean, repoName?: string): Promise { + const res = await daemonQuery("worktree:list", repoName ? { repoName } : undefined); + const ok = requireQueryResult(json, res); + return (ok.data?.trees ?? []) as TreeRow[]; +} + +async function pickOneTree(rows: TreeRow[], message: string): Promise { + if (rows.length === 0) return null; + const { filterableSelect } = await import("../lib/rt-render.tsx"); + const nameWidth = Math.max(...rows.map((r) => r.name.length)); + const options = rows.map((r) => { + const state = r.state ?? r.kind; + const hint = + state === "disposable" + ? r.disposableReason + ? `disposable — ${r.disposableReason}` + : "disposable" + : `${state}${r.branch ? ` ${r.branch}` : ""}${r.owner ? ` ${r.owner}` : ""}`; + return { value: r.path, label: r.name.padEnd(nameWidth), hint }; + }); + const picked = await filterableSelect({ message, options, stderr: true }); + if (!picked) return null; + return rows.find((r) => r.path === picked) ?? null; +} + +/** Disposable first (with their reason as the hint), then everything else. */ +function sortDisposableFirst(rows: TreeRow[]): TreeRow[] { + const rank = (r: TreeRow) => (r.state === "disposable" ? 0 : 1); + return [...rows].sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name)); +} + +// ─── provision ─────────────────────────────────────────────────────────────── + +export async function worktreeProvision(args: string[], _ctx: unknown): Promise { + const parsed = parseProvisionArgs(args); + const repoName = parsed.repoName ?? currentRepoName(); + if (!repoName) failText(parsed.json, "no repo — pass --repo or run from inside a registered repo"); + + const payload: Record = { repoName }; + if (parsed.owner) payload.owner = parsed.owner; + if (parsed.disposal) payload.disposal = parsed.disposal; + if (parsed.branch) { + payload.branch = parsed.branch; + } else if (parsed.ticket) { + payload.ticket = parsed.ticket; + if (parsed.title) payload.ticketTitle = parsed.title; + } + + const res = await daemonQuery("worktree:provision", payload, PROVISION_TIMEOUT_MS); + const ok = requireQueryResult(parsed.json, res); + + if (parsed.json) { console.log(JSON.stringify(ok.data, null, 2)); return; } + + const d = ok.data; + console.log(""); + console.log(` ${green}✓${reset} ${bold}${d.tree}${reset} ${dim}${d.path}${reset}`); + console.log(` branch ${cyan}${d.branch}${reset} ${dim}(${d.branchState}${d.wasOnDeck ? ", from the on-deck pool" : ""})${reset}`); + if (d.readyFailed) { + console.log(` ${yellow}⚠${reset} ready step "${d.failedStep}" failed — tree is usable but dependencies may be stale`); + } + console.log(""); +} + +// ─── create ────────────────────────────────────────────────────────────────── + +export async function worktreeCreate(args: string[], _ctx: unknown): Promise { + const parsed = parseCreateArgs(args); + const repoName = parsed.repoName ?? currentRepoName(); + if (!repoName) failText(parsed.json, "no repo — pass --repo or run from inside a registered repo"); + + const res = await daemonQuery("worktree:create", { repoName, onDeck: parsed.onDeck }, PROVISION_TIMEOUT_MS); + const ok = requireQueryResult(parsed.json, res); + + if (parsed.json) { console.log(JSON.stringify(ok.data, null, 2)); return; } + + console.log(""); + console.log(` ${green}✓${reset} ${bold}${ok.data.tree}${reset} ${dim}${ok.data.path}${reset}${parsed.onDeck ? ` ${dim}(on-deck)${reset}` : ""}`); + console.log(""); +} + +// ─── dispose ───────────────────────────────────────────────────────────────── + +export async function worktreeDispose(args: string[], _ctx: unknown): Promise { + const parsed = parseDisposeArgs(args); + let treeName = parsed.tree; + let repoName = parsed.repoName; + + if (!treeName && !parsed.owner) { + if (!process.stdin.isTTY) { + failText(parsed.json, "no target — pass a tree name or --owner (no TTY for the picker)"); + } + // Only rt-managed (ephemeral) trees are ever disposable — offering the + // main clone here would just earn every pick a pointless "kind-main" refusal. + const rows = sortDisposableFirst( + (await fetchTreeRows(parsed.json, repoName)).filter((r) => r.kind === "ephemeral"), + ); + const picked = await pickOneTree(rows, "Dispose which worktree?"); + if (!picked) { console.log(`\n ${dim}nothing selected${reset}\n`); return; } + treeName = picked.name; + repoName = picked.repoName; + } + + const payload: Record = { force: parsed.force }; + if (repoName) payload.repoName = repoName; + if (parsed.owner) payload.owner = parsed.owner; + if (treeName) payload.tree = treeName; + + const res = await daemonQuery("worktree:dispose", payload, DISPOSE_TIMEOUT_MS); + const ok = requireQueryResult(parsed.json, res); + + const { disposed, refused } = ok.data as { disposed: string[]; refused: Array<{ tree: string; reason: string }> }; + // Set before either return path — --json must not exit 0 on a partial failure. + if (refused.length > 0) process.exitCode = 1; + + if (parsed.json) { console.log(JSON.stringify(ok.data, null, 2)); return; } + + console.log(""); + for (const name of disposed) console.log(` ${green}✓${reset} ${name} disposed`); + for (const r of refused) { + const hint = r.reason === "remove-failed" ? " — transient, try again" : ""; + console.log(` ${red}✗${reset} ${r.tree} ${dim}(${r.reason}${hint})${reset}`); + } + if (disposed.length === 0 && refused.length === 0) console.log(` ${dim}nothing to dispose${reset}`); + console.log(""); +} + +// ─── list ──────────────────────────────────────────────────────────────────── + +export async function worktreeList(args: string[], _ctx: unknown): Promise { + const parsed = parseListArgs(args); + const rows = await fetchTreeRows(parsed.json, parsed.repoName); + + if (parsed.json) { console.log(JSON.stringify({ trees: rows }, null, 2)); return; } + + if (rows.length === 0) { console.log(`\n ${dim}no worktrees${reset}\n`); return; } + + console.log(""); + for (const r of rows) { + const mrPart = r.mr ? ` ${dim}!${r.mr.iid} ${r.mr.state}${reset}` : ""; + const dupPart = r.duplicateBranch ? ` ${yellow}duplicate branch${reset}` : ""; + const ownerPart = r.owner ? ` ${dim}${r.owner}${reset}` : ""; + console.log( + ` ${bold}${r.repoName}/${r.name}${reset} ${dim}${r.state ?? r.kind}${reset} ${cyan}${r.branch ?? "(detached)"}${reset}${ownerPart}${mrPart}${dupPart}`, + ); + } + console.log(""); +} + +// ─── freshen ───────────────────────────────────────────────────────────────── + +export async function worktreeFreshen(args: string[], _ctx: unknown): Promise { + const parsed = parseFreshenArgs(args); + let treeName = parsed.tree; + let repoName = parsed.repoName; + + if (!treeName && process.stdin.isTTY) { + // Mirrors freshenCandidate (lib/daemon/worktree-reconciler.ts): only + // on-deck ephemeral trees and the main clone are ever freshened — a + // claimed tree is someone's active work and always comes back ran:[]. + const rows = (await fetchTreeRows(parsed.json, repoName)) + .filter((r) => (r.kind === "ephemeral" && r.state === "on-deck") || r.kind === "main") + .sort((a, b) => a.name.localeCompare(b.name)); + const picked = await pickOneTree(rows, "Freshen which worktree?"); + if (!picked) { console.log(`\n ${dim}nothing selected${reset}\n`); return; } + treeName = picked.name; + repoName = picked.repoName; + } + + const payload: Record = {}; + if (repoName) payload.repoName = repoName; + if (treeName) payload.tree = treeName; + + const res = await daemonQuery("worktree:freshen", payload, FRESHEN_TIMEOUT_MS); + const ok = requireQueryResult(parsed.json, res); + + if (parsed.json) { console.log(JSON.stringify(ok.data, null, 2)); return; } + + const ran = (ok.data?.ran ?? []) as string[]; + console.log(""); + if (ran.length === 0) console.log(` ${dim}nothing needed freshening${reset}`); + else for (const name of ran) console.log(` ${green}✓${reset} ${name} freshened`); + console.log(""); +} + +// ─── adopt ─────────────────────────────────────────────────────────────────── + +export async function worktreeAdopt(args: string[], _ctx: unknown): Promise { + const parsed = parseAdoptArgs(args); + // Deliberately no cwd fallback: adopt rewrites the whole repo's registry in + // one sweep, so it must be pointed at explicitly rather than guessed from + // wherever the shell happens to be. + if (!parsed.repoName) failText(parsed.json, "--repo is required for adopt"); + + const res = await daemonQuery("worktree:adopt", { repoName: parsed.repoName }, ADOPT_TIMEOUT_MS); + const ok = requireQueryResult(parsed.json, res); + + if (parsed.json) { console.log(JSON.stringify(ok.data, null, 2)); return; } + + const d = ok.data as { main: string; claimed: string[]; disposed: string[]; refused: Array<{ tree: string; reason: string }> }; + console.log(""); + console.log(` ${green}✓${reset} adopted ${d.main ? `main=${d.main}, ` : ""}${d.claimed.length} claimed, ${d.disposed.length} disposed`); + for (const r of d.refused) console.log(` ${yellow}⚠${reset} ${r.tree} not disposed: ${r.reason}`); + console.log(""); +} + +// ─── bare `rt worktree` nav picker ──────────────────────────────────────────── + +/** + * Bare `rt worktree` — same contract as `rt cd`: redirect stdout while the + * picker is up, print only the selected path to stdout on success so a shell + * wrapper can `cd` into it. + */ +export async function worktreeNav(_args: string[], _ctx: unknown): Promise { + // Redirect FIRST, before any output at all — real stdout is reserved for + // the one line the shell wrapper reads (the selected path). Every early + // exit below (daemon down, refusal, empty list) goes through console.log, + // which now lands on stderr too, same as cd.ts's contract. + const realStdoutWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = process.stderr.write.bind(process.stderr) as typeof process.stdout.write; + const restore = (): void => { process.stdout.write = realStdoutWrite; }; + + let selected: string | null; + try { + const res = await daemonQuery("worktree:list"); + if (res === null) { restore(); daemonUnavailable(); } + if (!res.ok) { + restore(); + console.log(`\n ${red}✗${reset} ${explainError(res.error ?? "unknown error")}\n`); + process.exit(1); + } + const rows = (res.data?.trees ?? []) as TreeRow[]; + if (rows.length === 0) { restore(); console.log(`\n ${dim}no worktrees${reset}\n`); return; } + + const { filterableSelect } = await import("../lib/rt-render.tsx"); + const nameWidth = Math.max(...rows.map((r) => r.name.length)); + const stateWidth = Math.max(...rows.map((r) => (r.state ?? r.kind).length)); + const branchWidth = Math.max(...rows.map((r) => (r.branch ?? "(detached)").length)); + const options = rows.map((r) => ({ + value: r.path, + label: `${r.name.padEnd(nameWidth)} ${(r.state ?? r.kind).padEnd(stateWidth)} ${(r.branch ?? "(detached)").padEnd(branchWidth)} ${r.owner ?? ""}`, + })); + selected = await filterableSelect({ message: "Jump to worktree", options, stderr: true }); + } finally { + restore(); + } + + if (!selected) process.exit(0); + realStdoutWrite(selected + "\n"); +} + +// ─── park is gone ──────────────────────────────────────────────────────────── + +export async function parkDeprecated(_args: string[], _ctx: unknown): Promise { + console.log(`\n ${red}✗${reset} rt park is gone — the parking lot was replaced by rt worktree (provision/dispose/list). See RT-34.\n`); + process.exit(1); +} + +// ─── each ──────────────────────────────────────────────────────────────────── + +function fail(msg: string): never { + console.log(` ${red}✗${reset} ${msg}\n`); + process.exit(1); +} + function loadRepos(): Record { try { return JSON.parse(readFileSync(join(RT_DIR, "repos.json"), "utf8")); @@ -34,9 +507,17 @@ function loadRepos(): Record { } } -function fail(msg: string): never { - console.log(` ${red}✗${reset} ${msg}\n`); - process.exit(1); +/** Bindings from the daemon's registry-aware worktree:list, when it's up. */ +async function bindingsFromDaemon(repoName: string): Promise { + const res = await daemonQuery("worktree:list", { repoName }); + if (res === null || !res.ok) return null; + const rows = (res.data?.trees ?? []) as TreeRow[]; + return rows.map((r) => ({ path: r.path, branch: r.branch, state: r.state })); +} + +/** Read-only git fallback — each is the one lifecycle command allowed this, since it never mutates. */ +function bindingsFromGit(repoPath: string): WorktreeBinding[] { + return listWorktrees(repoPath).map((w) => ({ path: w.path, branch: w.branch || null })); } export async function worktreeEach(args: string[], _ctx: unknown): Promise { @@ -50,7 +531,7 @@ export async function worktreeEach(args: string[], _ctx: unknown): Promise const repoPath = repos[identity.repoName]; if (!repoPath) fail(`repo "${identity.repoName}" not registered in ~/.rt/repos.json`); - const bindings = describeRepoBindings(identity.repoName, repoPath); + const bindings = (await bindingsFromDaemon(identity.repoName)) ?? bindingsFromGit(repoPath); if (bindings.length === 0) { console.log(`\n ${dim}no worktrees in ${identity.repoName}${reset}\n`); return; @@ -59,7 +540,7 @@ export async function worktreeEach(args: string[], _ctx: unknown): Promise let targets: WorktreeBinding[]; if (parsed.mode === "pick") { if (!process.stdin.isTTY) { - fail("no --all/--parked flag and no TTY for the picker — pass --all or --parked"); + fail("no --all/--on-deck flag and no TTY for the picker — pass --all or --on-deck"); } const widest = Math.max(...bindings.map(b => relWorktreeName(repoPath, b.path).length)); const options = bindings.map(b => ({ @@ -81,7 +562,7 @@ export async function worktreeEach(args: string[], _ctx: unknown): Promise } else { targets = filterTargets(bindings, parsed.mode); if (targets.length === 0) { - const what = parsed.mode === "parked" ? "parked worktrees" : "worktrees"; + const what = parsed.mode === "on-deck" ? "on-deck worktrees" : "worktrees"; console.log(`\n ${dim}no ${what} to run in${reset}\n`); return; } diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 23208ba2..a7108830 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -145,6 +145,8 @@ export function ensureShellWrapper(home: string): void { ' local dir; dir="$("$rt_bin" cd "${@:2}")" && [ -n "$dir" ] && builtin cd "$dir"', ' elif [ "$1" = "nav" ]; then', ' local dir; dir="$("$rt_bin" nav "${@:2}")" && [ -n "$dir" ] && builtin cd "$dir"', + ' elif [ "$1" = "worktree" ] && [ -z "$2" ]; then', + ' local dir; dir="$("$rt_bin" worktree)" && [ -n "$dir" ] && builtin cd "$dir"', " else", ' "$rt_bin" "$@"', " fi", diff --git a/lib/__tests__/command-tree.test.ts b/lib/__tests__/command-tree.test.ts index fb6891b9..6cab6ce3 100644 --- a/lib/__tests__/command-tree.test.ts +++ b/lib/__tests__/command-tree.test.ts @@ -1,5 +1,6 @@ -import { describe, test, expect } from "bun:test"; -import { walkTree, type CommandNode } from "../command-tree.ts"; +import { describe, test, expect, afterEach, mock } from "bun:test"; +import { dispatch, walkTree, type CommandContext, type CommandNode } from "../command-tree.ts"; +import type { KnownRepo, RepoIdentity } from "../repo.ts"; const noop = async () => {}; @@ -57,3 +58,84 @@ describe("walkTree", () => { expect(walkTree(TREE, ["branch", "switch"])).toBeNull(); }); }); + +// ─── dispatch's --repo flag scoping (RT-34 fix) ────────────────────────────── +// +// command-tree.ts's global `--repo ` extraction used to run +// unconditionally for every leaf node, before checking node.context, and +// silently discarded the flag+value whenever the node didn't declare +// context:"worktree" — e.g. the worktree-lifecycle verbs' own `--repo +// ` payload flag would vanish before commands/worktree.ts +// ever saw it. The fix scopes the extraction to context:"worktree" nodes. +describe("dispatch --repo flag scoping", () => { + // Real repo resolution (getKnownRepos/getRepoIdentity) reads ~/.rt/repos.json + // via bare os.homedir(), which — unlike lib/rt-paths.ts's call-time + // `process.env.HOME ?? homedir()` — is resolved once at process start and + // can't be redirected from inside a test. So the context:"worktree" case + // mocks the three functions dispatch dynamically imports from "./repo.ts", + // restored via mock.module in afterEach so nothing else in the process sees + // the fake repo past this one test. + afterEach(async () => { + const real = await import("../repo.ts"); + mock.module("../repo.ts", () => real); + }); + + test('context:"worktree" node: --repo is stripped from args and resolved onto ctx.identity', async () => { + const real = await import("../repo.ts"); + // chdir to the CURRENT cwd (a real, always-existing directory) so + // dispatch's real process.chdir() call is a harmless no-op — no fake + // filesystem path needed, and no process-wide cwd side effect to undo. + const fakeRepo: KnownRepo = { + repoName: "acme", + worktrees: [{ path: process.cwd(), branch: "main", isBare: false }], + dataDir: "/fake/acme-data", + }; + const fakeIdentity: RepoIdentity = { + repoName: "acme", + repoRoot: process.cwd(), + dataDir: "/fake/acme-data", + remoteUrl: "", + baseUrl: "", + }; + mock.module("../repo.ts", () => ({ + ...real, + getKnownRepos: () => [fakeRepo], + pickWorktreeFromRepo: async () => null, + getRepoIdentity: () => fakeIdentity, + })); + + let capturedArgs: string[] | undefined; + let capturedCtx: CommandContext | undefined; + const tree: Record = { + cmd: { + description: "test", + context: "worktree", + handler: async (args, ctx) => { + capturedArgs = args; + capturedCtx = ctx; + }, + }, + }; + + await dispatch(tree, ["cmd", "--repo", "acme", "--flag", "x"]); + + expect(capturedArgs).toEqual(["--flag", "x"]); + expect(capturedCtx?.identity?.repoName).toBe("acme"); + }); + + test('node without context: --repo survives untouched in its own args (no repo.ts mocking needed)', async () => { + let capturedArgs: string[] | undefined; + const tree: Record = { + cmd: { + description: "test", + handler: async (args) => { + capturedArgs = args; + }, + }, + }; + + await dispatch(tree, ["cmd", "--repo", "foo", "--ticket", "bar"]); + + expect(capturedArgs).toEqual(["--repo", "foo", "--ticket", "bar"]); + }); +}); diff --git a/lib/__tests__/json-store.test.ts b/lib/__tests__/json-store.test.ts index f82e67d9..926bbdae 100644 --- a/lib/__tests__/json-store.test.ts +++ b/lib/__tests__/json-store.test.ts @@ -43,3 +43,14 @@ describe("json-store", () => { expect(readFileSync(p, "utf8")).toBe('{\n "a": 1\n}'); }); }); + +describe("writeJson atomicity", () => { + test("round-trips and leaves no tmp file behind", () => { + const dir = mkdtempSync(join(tmpdir(), "jsonstore-")); + const p = join(dir, "nested", "file.json"); + writeJson(p, { a: 1 }); + expect(readJson<{ a: number } | null>(p, null)).toEqual({ a: 1 }); + const { readdirSync } = require("fs"); + expect(readdirSync(join(dir, "nested"))).toEqual(["file.json"]); + }); +}); diff --git a/lib/__tests__/worktree-cli-args.test.ts b/lib/__tests__/worktree-cli-args.test.ts new file mode 100644 index 00000000..8bc97b66 --- /dev/null +++ b/lib/__tests__/worktree-cli-args.test.ts @@ -0,0 +1,116 @@ +/** + * Pure arg-parsing tests for the `rt worktree` CLI verbs. Daemon-path + * behavior (provision/create/dispose/list/freshen/adopt actually talking to + * the daemon) is covered by Task 13's handler tests — this file only checks + * that raw CLI args are sliced into the right payload shape. + */ +import { describe, expect, test } from "bun:test"; +import { + parseProvisionArgs, + parseCreateArgs, + parseDisposeArgs, + parseListArgs, + parseFreshenArgs, + parseAdoptArgs, +} from "../../commands/worktree.ts"; + +describe("parseProvisionArgs", () => { + test("bare --ticket", () => { + expect(parseProvisionArgs(["--ticket", "RT-40"])).toEqual({ + repoName: undefined, ticket: "RT-40", title: undefined, branch: undefined, + owner: undefined, disposal: undefined, json: false, + }); + }); + test("--ticket with --title", () => { + const r = parseProvisionArgs(["--ticket", "RT-40", "--title", "Prune verbs"]); + expect(r.ticket).toBe("RT-40"); + expect(r.title).toBe("Prune verbs"); + }); + test("--branch takes precedence path is left to the caller — both parsed", () => { + const r = parseProvisionArgs(["--branch", "feature/x", "--repo", "repo-tools"]); + expect(r.branch).toBe("feature/x"); + expect(r.repoName).toBe("repo-tools"); + }); + test("--owner, --disposal, --json all parsed", () => { + const r = parseProvisionArgs(["--ticket", "RT-1", "--owner", "matt", "--disposal", "job", "--json"]); + expect(r.owner).toBe("matt"); + expect(r.disposal).toBe("job"); + expect(r.json).toBe(true); + }); + test("no flags → all undefined, json false", () => { + expect(parseProvisionArgs([])).toEqual({ + repoName: undefined, ticket: undefined, title: undefined, branch: undefined, + owner: undefined, disposal: undefined, json: false, + }); + }); +}); + +describe("parseCreateArgs", () => { + test("defaults", () => { + expect(parseCreateArgs([])).toEqual({ repoName: undefined, onDeck: false, json: false }); + }); + test("--repo, --on-deck, --json", () => { + expect(parseCreateArgs(["--repo", "repo-tools", "--on-deck", "--json"])).toEqual({ + repoName: "repo-tools", onDeck: true, json: true, + }); + }); +}); + +describe("parseDisposeArgs", () => { + test("positional tree name", () => { + const r = parseDisposeArgs(["my-tree"]); + expect(r.tree).toBe("my-tree"); + expect(r.owner).toBeUndefined(); + expect(r.force).toBe(false); + }); + test("--owner sweep, no positional", () => { + const r = parseDisposeArgs(["--owner", "matt"]); + expect(r.tree).toBeUndefined(); + expect(r.owner).toBe("matt"); + }); + test("tree + --repo + --force + --json", () => { + const r = parseDisposeArgs(["my-tree", "--repo", "repo-tools", "--force", "--json"]); + expect(r).toEqual({ tree: "my-tree", owner: undefined, repoName: "repo-tools", force: true, json: true }); + }); + test("no args at all", () => { + const r = parseDisposeArgs([]); + expect(r.tree).toBeUndefined(); + expect(r.owner).toBeUndefined(); + expect(r.force).toBe(false); + expect(r.json).toBe(false); + }); +}); + +describe("parseListArgs", () => { + test("defaults", () => { + expect(parseListArgs([])).toEqual({ repoName: undefined, json: false }); + }); + test("--repo + --json", () => { + expect(parseListArgs(["--repo", "repo-tools", "--json"])).toEqual({ repoName: "repo-tools", json: true }); + }); +}); + +describe("parseFreshenArgs", () => { + test("positional tree only", () => { + const r = parseFreshenArgs(["my-tree"]); + expect(r.tree).toBe("my-tree"); + }); + test("no args", () => { + expect(parseFreshenArgs([])).toEqual({ tree: undefined, repoName: undefined, json: false }); + }); + test("tree + --repo + --json", () => { + expect(parseFreshenArgs(["my-tree", "--repo", "repo-tools", "--json"])).toEqual({ + tree: "my-tree", repoName: "repo-tools", json: true, + }); + }); +}); + +describe("parseAdoptArgs", () => { + test("--repo required, --json optional", () => { + expect(parseAdoptArgs(["--repo", "repo-tools"])).toEqual({ repoName: "repo-tools", json: false }); + expect(parseAdoptArgs(["--repo", "repo-tools", "--json"])).toEqual({ repoName: "repo-tools", json: true }); + }); + test("no --repo → repoName undefined (caller decides how to fail)", () => { + expect(parseAdoptArgs([])).toEqual({ repoName: undefined, json: false }); + }); +}); diff --git a/lib/__tests__/worktree-each.test.ts b/lib/__tests__/worktree-each.test.ts index 9d5a1842..3841f922 100644 --- a/lib/__tests__/worktree-each.test.ts +++ b/lib/__tests__/worktree-each.test.ts @@ -1,16 +1,16 @@ import { describe, expect, test } from "bun:test"; import { parseEachArgs, - isParked, + isOnDeck, filterTargets, relWorktreeName, formatSummary, hasFailures, + type WorktreeBinding, } from "../worktree-each.ts"; -import type { WorktreeBinding } from "../daemon/parking-lot.ts"; -const wt = (path: string, branch: string | null, index: number): WorktreeBinding => - ({ path, branch, index }); +const wt = (path: string, branch: string | null, state?: string): WorktreeBinding => + ({ path, branch, ...(state ? { state } : {}) }); describe("parseEachArgs", () => { test("bare args → pick mode, command joined", () => { @@ -19,12 +19,18 @@ describe("parseEachArgs", () => { test("--all flag → all mode, flag stripped from command", () => { expect(parseEachArgs(["--all", "pnpm", "install"])).toEqual({ mode: "all", command: "pnpm install" }); }); - test("--parked flag → parked mode", () => { - expect(parseEachArgs(["--parked", "git", "status"])).toEqual({ mode: "parked", command: "git status" }); + test("--on-deck flag → on-deck mode", () => { + expect(parseEachArgs(["--on-deck", "git", "status"])).toEqual({ mode: "on-deck", command: "git status" }); + }); + test("--parked is a hidden alias for --on-deck", () => { + expect(parseEachArgs(["--parked", "git", "status"])).toEqual({ mode: "on-deck", command: "git status" }); }); test("flag after command is still recognized", () => { expect(parseEachArgs(["pnpm", "install", "--all"]).mode).toBe("all"); }); + test("both --all and --on-deck → error", () => { + expect(parseEachArgs(["--all", "--on-deck", "ls"]).error).toMatch(/mutually exclusive/i); + }); test("both --all and --parked → error", () => { expect(parseEachArgs(["--all", "--parked", "ls"]).error).toMatch(/mutually exclusive/i); }); @@ -33,35 +39,32 @@ describe("parseEachArgs", () => { }); }); -describe("isParked", () => { - test("branch matches its slot → parked", () => { - expect(isParked(wt("/a", "parking-lot/3", 3))).toBe(true); - }); - test("branch is a different parking slot → not parked", () => { - expect(isParked(wt("/a", "parking-lot/9", 3))).toBe(false); +describe("isOnDeck", () => { + test("state on-deck → true", () => { + expect(isOnDeck(wt("/a", "on-deck/3", "on-deck"))).toBe(true); }); - test("feature branch → not parked", () => { - expect(isParked(wt("/a", "feature/x", 3))).toBe(false); + test("state claimed → false", () => { + expect(isOnDeck(wt("/a", "feature/x", "claimed"))).toBe(false); }); - test("detached (null branch) → not parked", () => { - expect(isParked(wt("/a", null, 3))).toBe(false); + test("no state (git-only fallback) → false", () => { + expect(isOnDeck(wt("/a", "feature/x"))).toBe(false); }); - test("no index (0) → not parked even if branch looks parked", () => { - expect(isParked(wt("/a", "parking-lot/0", 0))).toBe(false); + test("detached (null branch), on-deck state → still true", () => { + expect(isOnDeck(wt("/a", null, "on-deck"))).toBe(true); }); }); describe("filterTargets", () => { const bindings = [ - wt("/repo/wt0", "feature/a", 1), - wt("/repo/wt1", "parking-lot/2", 2), - wt("/repo/wt2", "parking-lot/3", 3), + wt("/repo/wt0", "feature/a", "claimed"), + wt("/repo/wt1", "on-deck/wt1", "on-deck"), + wt("/repo/wt2", "on-deck/wt2", "on-deck"), ]; test("all → every binding", () => { expect(filterTargets(bindings, "all")).toHaveLength(3); }); - test("parked → only parked", () => { - expect(filterTargets(bindings, "parked").map(b => b.path)).toEqual(["/repo/wt1", "/repo/wt2"]); + test("on-deck → only on-deck", () => { + expect(filterTargets(bindings, "on-deck").map(b => b.path)).toEqual(["/repo/wt1", "/repo/wt2"]); }); test("pick → returned unchanged (picker selects later)", () => { expect(filterTargets(bindings, "pick")).toHaveLength(3); diff --git a/lib/command-tree-def.ts b/lib/command-tree-def.ts index 5a5ec20b..344b7586 100644 --- a/lib/command-tree-def.ts +++ b/lib/command-tree-def.ts @@ -525,61 +525,91 @@ export const TREE: Record = { }, park: { - description: "Auto-park worktrees when their MR merges or closes", + description: "Deprecated — replaced by rt worktree", + module: "./commands/worktree.ts", + fn: "parkDeprecated", + args: [], + }, + + worktree: { + description: "Worktree lifecycle (provision/dispose/list) + worktree-wide operations", + module: "./commands/worktree.ts", + fn: "worktreeNav", + requiresTTY: true, + args: [], subcommands: { - status: { - description: "Show whether auto-park is enabled + worktree bindings", - module: "./commands/parking-lot.ts", - fn: "statusCommand", - args: [], + provision: { + description: "Claim a worktree for a ticket or branch (from the on-deck pool, or freshly created)", + module: "./commands/worktree.ts", + fn: "worktreeProvision", + args: [ + { name: "Repo", flag: "--repo", type: "text", placeholder: "repo-tools", hint: "Registered repo name (defaults to the current repo)" }, + { name: "Ticket", flag: "--ticket", type: "text", placeholder: "RT-40", hint: "Linear ticket id — derives the branch name" }, + { name: "Title", flag: "--title", type: "text", placeholder: "Prune the parking lot", hint: "Ticket title, used with --ticket to derive the branch slug" }, + { name: "Branch", flag: "--branch", type: "text", placeholder: "feature/my-branch", hint: "Explicit branch name (overrides --ticket)" }, + { name: "Owner", flag: "--owner", type: "text", placeholder: "matt", hint: "Who's claiming this tree" }, + { name: "Disposal", flag: "--disposal", type: "text", placeholder: "merge", hint: "Disposal mode: merge (default) or job" }, + { name: "JSON", flag: "--json", type: "boolean", default: false, hint: "Print the raw result as JSON" }, + ], }, - enable: { - description: "Turn on auto-park", - module: "./commands/parking-lot.ts", - fn: "enableCommand", - args: [], + create: { + description: "Create a fresh worktree (optionally straight into the on-deck pool)", + module: "./commands/worktree.ts", + fn: "worktreeCreate", + args: [ + { name: "Repo", flag: "--repo", type: "text", placeholder: "repo-tools", hint: "Registered repo name (defaults to the current repo)" }, + { name: "On-deck", flag: "--on-deck", type: "boolean", default: false, hint: "Put the new tree in the on-deck pool instead of claiming it" }, + { name: "JSON", flag: "--json", type: "boolean", default: false, hint: "Print the raw result as JSON" }, + ], }, - disable: { - description: "Turn off auto-park (daemon scans become no-ops)", - module: "./commands/parking-lot.ts", - fn: "disableCommand", - args: [], + dispose: { + description: "Dispose a worktree (no target + TTY → picker)", + module: "./commands/worktree.ts", + fn: "worktreeDispose", + args: [ + { name: "Tree", type: "text", placeholder: "my-tree", hint: "Tree name to dispose; omit to pick interactively" }, + { name: "Owner", flag: "--owner", type: "text", placeholder: "matt", hint: "Dispose every tree owned by this owner (can span repos)" }, + { name: "Repo", flag: "--repo", type: "text", placeholder: "repo-tools", hint: "Narrow to this registered repo" }, + { name: "Force", flag: "--force", type: "boolean", default: false, hint: "Override the dirty/unpushed guard" }, + { name: "JSON", flag: "--json", type: "boolean", default: false, hint: "Print the raw result as JSON" }, + ], }, - scan: { - description: "Run the park check immediately against the live cache", - module: "./commands/parking-lot.ts", - fn: "scanCommand", - args: [], + list: { + description: "List worktrees", + module: "./commands/worktree.ts", + fn: "worktreeList", + args: [ + { name: "Repo", flag: "--repo", type: "text", placeholder: "repo-tools", hint: "Narrow to this registered repo (default: every registered repo)" }, + { name: "JSON", flag: "--json", type: "boolean", default: false, hint: "Print the raw result as JSON" }, + ], }, - this: { - description: "Park the current worktree now (manual override; ignores enabled flag)", - module: "./commands/parking-lot.ts", - fn: "parkThisCommand", - context: "worktree", - args: [], + freshen: { + description: "Freshen worktrees (no arg + TTY → picker over freshenable trees)", + module: "./commands/worktree.ts", + fn: "worktreeFreshen", + args: [ + { name: "Tree", type: "text", placeholder: "my-tree", hint: "Tree name to freshen; omit to pick interactively (or run for every repo, headless)" }, + { name: "Repo", flag: "--repo", type: "text", placeholder: "repo-tools", hint: "Narrow to this registered repo" }, + { name: "JSON", flag: "--json", type: "boolean", default: false, hint: "Print the raw result as JSON" }, + ], }, - pick: { - description: "Pick worktrees in this repo to park (multi-select)", - module: "./commands/parking-lot.ts", - fn: "parkPickCommand", - context: "repo", - requiresTTY: true, - args: [], + adopt: { + description: "One-shot migration: adopt an unmanaged repo's worktrees into the registry", + module: "./commands/worktree.ts", + fn: "worktreeAdopt", + args: [ + { name: "Repo", flag: "--repo", type: "text", placeholder: "repo-tools", hint: "Registered repo name (required)" }, + { name: "JSON", flag: "--json", type: "boolean", default: false, hint: "Print the raw result as JSON" }, + ], }, - }, - }, - - worktree: { - description: "Worktree-wide operations", - subcommands: { each: { - description: "Run a command in each worktree (--all | --parked, else pick)", + description: "Run a command in each worktree (--all | --on-deck, else pick)", module: "./commands/worktree.ts", fn: "worktreeEach", context: "repo", args: [ - { name: "All", flag: "--all", type: "boolean", default: false, hint: "Run in every worktree (mutually exclusive with --parked)" }, - { name: "Parked", flag: "--parked", type: "boolean", default: false, hint: "Run only in parked worktrees" }, + { name: "All", flag: "--all", type: "boolean", default: false, hint: "Run in every worktree (mutually exclusive with --on-deck)" }, + { name: "On-deck", flag: "--on-deck", type: "boolean", default: false, hint: "Run only in on-deck worktrees (alias --parked)" }, { name: "Command", type: "text", placeholder: "git status", hint: "Command to run in each selected worktree; omit both flags to pick interactively" }, ], }, diff --git a/lib/command-tree.ts b/lib/command-tree.ts index 04422444..bc617e2d 100644 --- a/lib/command-tree.ts +++ b/lib/command-tree.ts @@ -222,18 +222,22 @@ export async function dispatch( const commandLabel = breadcrumb.slice(1).concat(resolvedName).join(" "); beginCommand(commandLabel, rest); - // Extract --repo flag if present (allows callers to pre-select the repo - // but still trigger the worktree picker) - let repoFlag: string | null = null; - const repoFlagIdx = rest.indexOf("--repo"); - if (repoFlagIdx !== -1 && rest[repoFlagIdx + 1]) { - repoFlag = rest[repoFlagIdx + 1]!; - rest.splice(repoFlagIdx, 2); - } - if (node.context === "worktree") { const cwdBefore = process.cwd(); + // Extract --repo flag if present (allows callers to pre-select the repo + // but still trigger the worktree picker). Scoped to context:"worktree" nodes + // only — a node without this context (e.g. the daemon-backed worktree + // lifecycle verbs, which take their own `--repo ` payload + // flag) must see `--repo` untouched in its own args, not have it silently + // consumed here. + let repoFlag: string | null = null; + const repoFlagIdx = rest.indexOf("--repo"); + if (repoFlagIdx !== -1 && rest[repoFlagIdx + 1]) { + repoFlag = rest[repoFlagIdx + 1]!; + rest.splice(repoFlagIdx, 2); + } + if (repoFlag) { // --repo provided: resolve that repo and show worktree picker (skip repo picker + cwd detection) const { getKnownRepos, pickWorktreeFromRepo, getRepoIdentity } = await import("./repo.ts"); diff --git a/lib/daemon.ts b/lib/daemon.ts index e7bce11e..bf3cb7b7 100644 --- a/lib/daemon.ts +++ b/lib/daemon.ts @@ -31,6 +31,7 @@ import { evictStaleDaemon } from "./daemon/boot-reconcile.ts"; import { resolveUserPath } from "./daemon/user-path.ts"; import { createBranchCache } from "./daemon/branch-cache.ts"; import { createCacheRefresher } from "./daemon/cache-refresh.ts"; +import { createWorktreeReconciler } from "./daemon/worktree-reconciler.ts"; import { loadRepoIndex, REPOS_JSON_PATH } from "./daemon/repo-index.ts"; import { createHooksGuard } from "./daemon/hooks-guard.ts"; import { buildRoutedHandlers } from "./daemon/command-router.ts"; @@ -90,12 +91,27 @@ const emit: typeof broadcast = (type, data) => { cron.onBroadcast(type, data); }; +// Worktree lifecycle reconciler: reconcile → merge reactor → freshen → +// replenish/shrink. Kicked detached off the tail of every cache refresh +// (see `worktreeKick` below), plus its own periodic pass isn't needed since +// the refresh timer already provides one. +const worktreeReconciler = createWorktreeReconciler({ + cache, + repoIndex: loadRepoIndex, + // `emit` (not bare `broadcast`), deliberately: reconciler events (e.g. + // "worktree:freshened") should also reach the cron trigger layer, same as + // every other broadcast frame. + emit, + log, +}); + const refreshCache = createCacheRefresher({ log, cache, loadCache, refreshStatusRef, portCacheRef, repoIndex: loadRepoIndex, broadcast: emit, statusSnapshot: () => handleCommand("tray:status", {}), reconcileSubscriptions: () => reconcileFreshness(freshnessEnv), + worktreeKick: worktreeReconciler.kick, }); // ─── Handler context + command routing ─────────────────────────────────────── @@ -115,7 +131,16 @@ const handlerCtx: HandlerContext = { /** Env bundle for the live-freshness subsystem. */ const freshnessEnv: FreshnessEnv = { ctx: handlerCtx, broadcast: emit }; -const routedHandlers = buildRoutedHandlers({ ctx: handlerCtx, broadcast: emit, systemProcessScanner }); +const routedHandlers = buildRoutedHandlers({ + ctx: handlerCtx, + broadcast: emit, + systemProcessScanner, + worktree: { + emit, + kick: worktreeReconciler.kick, + creationInFlight: worktreeReconciler.creationInFlight, + }, +}); async function handleCommand(cmd: string, payload: any): Promise { const t0 = Date.now(); diff --git a/lib/daemon/__tests__/parking-lot.test.ts b/lib/daemon/__tests__/parking-lot.test.ts deleted file mode 100644 index e7892f8a..00000000 --- a/lib/daemon/__tests__/parking-lot.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { execFileSync } from "child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; -import { findDesktopStash, getCurrentBranch, hasUncommittedChanges } from "../../git-ops.ts"; - -// The module reads RT_DIR at import time via daemon-config.ts, which pins to -// the user's home ~/.rt. To avoid writing into the real ~/.rt during tests we -// point HOME at a tmpdir BEFORE importing. -const tmpHome = mkdtempSync(join(tmpdir(), "rt-parking-")); -process.env.HOME = tmpHome; - -const { __test__, fastForwardParkedWorktrees, isParkable, park } = await import("../parking-lot.ts"); -// Imported after the HOME override for the same RT_DIR-pinning reason. -const { saveSyncConfig } = await import("../../sync-config.ts"); -const { repoDataDir } = await import("../../rt-paths.ts"); - -describe("reconcileIndexMap", () => { - const repo = "test-repo"; - - afterEach(() => { - try { rmSync(join(tmpHome, ".rt", "repos", repo), { recursive: true, force: true }); } catch { /* */ } - }); - - test("primary worktree gets index 1; later worktrees get 2,3,… in list order", () => { - const worktrees = [ - "/repo/primary", - "/repo/wktree-2", - "/repo/wktree-3", - ]; - const map = __test__.reconcileIndexMap(repo, worktrees); - expect(map).toEqual({ - "/repo/primary": 1, - "/repo/wktree-2": 2, - "/repo/wktree-3": 3, - }); - }); - - test("removing a middle worktree preserves the remaining indexes", () => { - __test__.reconcileIndexMap(repo, [ - "/repo/primary", "/repo/wktree-2", "/repo/wktree-3", "/repo/wktree-4", - ]); - // wktree-3 is gone; wktree-4 should still have its original 4 - const map = __test__.reconcileIndexMap(repo, [ - "/repo/primary", "/repo/wktree-2", "/repo/wktree-4", - ]); - expect(map["/repo/primary"]).toBe(1); - expect(map["/repo/wktree-2"]).toBe(2); - expect(map["/repo/wktree-4"]).toBe(4); - }); - - test("new worktree claims the lowest unused positive integer", () => { - __test__.reconcileIndexMap(repo, [ - "/repo/primary", "/repo/wktree-2", "/repo/wktree-3", - ]); - // wktree-2 removed; a new worktree-A appears — should claim 2, not 4 - const map = __test__.reconcileIndexMap(repo, [ - "/repo/primary", "/repo/wktree-3", "/repo/wktree-A", - ]); - expect(map["/repo/primary"]).toBe(1); - expect(map["/repo/wktree-3"]).toBe(3); - expect(map["/repo/wktree-A"]).toBe(2); - }); - - test("primary keeps 1 even if listed worktrees are empty on first call then populated", () => { - __test__.reconcileIndexMap(repo, []); - const map = __test__.reconcileIndexMap(repo, ["/repo/primary", "/repo/wktree-2"]); - expect(map["/repo/primary"]).toBe(1); - expect(map["/repo/wktree-2"]).toBe(2); - }); - - test("index map persists across reconcile calls via disk", () => { - __test__.reconcileIndexMap(repo, ["/repo/primary", "/repo/wktree-2"]); - const loaded = __test__.loadIndexMap(repo); - expect(loaded).toEqual({ - "/repo/primary": 1, - "/repo/wktree-2": 2, - }); - }); - - test("pre-existing hand-edited file is respected (primary claims 1 if free)", () => { - const dir = join(tmpHome, ".rt", "repos", repo); - try { rmSync(dir, { recursive: true, force: true }); } catch { /* */ } - // User manually assigned wktree-9 to 9. Primary should still claim 1. - __test__.saveIndexMap(repo, { "/repo/wktree-9": 9 }); - const map = __test__.reconcileIndexMap(repo, [ - "/repo/primary", "/repo/wktree-9", "/repo/wktree-new", - ]); - expect(map["/repo/primary"]).toBe(1); - expect(map["/repo/wktree-9"]).toBe(9); - expect(map["/repo/wktree-new"]).toBe(2); - }); -}); - -describe("isParkable", () => { - // Detached worktrees (e.g. herdr warm-pool entries) have no branch but still - // get a slot index — they should be parkable so the user can claim them onto - // their parking-lot/N branch manually. - test("a detached worktree with an allocated slot is parkable", () => { - expect(isParkable({ path: "/r/wt", branch: null, index: 5 })).toBe(true); - }); - - test("a worktree on a feature branch is parkable", () => { - expect(isParkable({ path: "/r/wt", branch: "feature/x", index: 2 })).toBe(true); - }); - - test("a worktree already on its parking-lot slot is not parkable", () => { - expect(isParkable({ path: "/r/wt", branch: "parking-lot/3", index: 3 })).toBe(false); - }); - - test("a worktree with no allocated slot is not parkable", () => { - expect(isParkable({ path: "/r/wt", branch: null, index: 0 })).toBe(false); - }); -}); - -describe("park (detached worktree)", () => { - let root: string; - let primary: string; - let wt: string; - - beforeEach(() => { - // Resolve symlinks (macOS /var → /private/var) so paths match git output. - root = realpathSync(mkdtempSync(join(tmpdir(), "rt-park-detached-"))); - const origin = join(root, "origin.git"); - mkdirSync(origin); - execFileSync("git", ["init", "--bare", "-b", "master", origin], { stdio: "pipe" }); - execFileSync("git", ["clone", origin, "primary"], { cwd: root, stdio: "pipe" }); - primary = join(root, "primary"); - const g = (args: string[]) => execFileSync("git", args, { cwd: primary, stdio: "pipe" }); - g(["config", "user.email", "t@example.com"]); - g(["config", "user.name", "Test"]); - writeFileSync(join(primary, "README.md"), "hi\n"); - g(["add", "."]); - g(["commit", "-m", "init"]); - g(["push", "origin", "master"]); - // A linked worktree in detached HEAD — the warm-pool shape. - wt = join(root, "wt5"); - g(["worktree", "add", "--detach", wt, "HEAD"]); - }); - - afterEach(() => { - try { rmSync(root, { recursive: true, force: true }); } catch { /* */ } - }); - - test("parks a clean detached worktree onto its slot branch", () => { - const result = park(wt, primary, null, 5); - expect(result.ok).toBe(true); - expect(getCurrentBranch(wt)).toBe("parking-lot/5"); - }); - - test("stashes a dirty detached worktree under the slot branch, not ", () => { - writeFileSync(join(wt, "scratch.txt"), "wip\n"); - const result = park(wt, primary, null, 5); - expect(result.ok).toBe(true); - expect(getCurrentBranch(wt)).toBe("parking-lot/5"); - expect(hasUncommittedChanges(wt)).toBe(false); - // The stash must be recoverable by the slot branch name. The pre-fix code - // passed sourceBranch=null straight through, labeling the stash "". - expect(findDesktopStash(wt, "parking-lot/5")).not.toBeNull(); - }); - - // Spawn a long-lived process with cwd inside the worktree, orphaned to - // ppid 1 via double-fork — the shape of a leaked dev server. A direct - // Bun.spawn child would be a descendant of the test process, which the - // kill logic deliberately protects. - function spawnOrphanInWorktree(): number { - const out = execFileSync("sh", ["-c", "sleep 300 >/dev/null 2>&1 & echo $!"], { - cwd: wt, encoding: "utf8", - }); - return parseInt(out.trim(), 10); - } - - function isAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } - } - - test("kills a workload process rooted in the worktree when killProcesses is on", async () => { - const pid = spawnOrphanInWorktree(); - try { - expect(isAlive(pid)).toBe(true); - - const result = park(wt, primary, null, 5, { killProcesses: true }); - expect(result.ok).toBe(true); - - // SIGTERM lands before park() returns, but give the exit a moment. - const deadline = Date.now() + 3000; - while (isAlive(pid) && Date.now() < deadline) { - await Bun.sleep(50); - } - expect(isAlive(pid)).toBe(false); - } finally { - try { process.kill(pid, "SIGKILL"); } catch { /* already dead */ } - } - }); - - test("leaves worktree processes alone when killProcesses is off", async () => { - const pid = spawnOrphanInWorktree(); - try { - const result = park(wt, primary, null, 5); - expect(result.ok).toBe(true); - - await Bun.sleep(300); - expect(isAlive(pid)).toBe(true); - } finally { - try { process.kill(pid, "SIGKILL"); } catch { /* already dead */ } - } - }); -}); - -describe("fastForwardParkedWorktrees (dirty-tree resolution)", () => { - const repoName = "ff-test-repo"; - const GENERATED = "src/generated/schema.graphql"; - - let root: string; - let primary: string; - let wt: string; - let origin: string; - - /** Run git in a given worktree. */ - const git = (cwd: string, args: string[]) => - execFileSync("git", args, { cwd, stdio: "pipe", encoding: "utf8" }); - - beforeEach(() => { - root = realpathSync(mkdtempSync(join(tmpdir(), "rt-park-ff-"))); - origin = join(root, "origin.git"); - mkdirSync(origin); - execFileSync("git", ["init", "--bare", "-b", "master", origin], { stdio: "pipe" }); - execFileSync("git", ["clone", origin, "primary"], { cwd: root, stdio: "pipe" }); - primary = join(root, "primary"); - git(primary, ["config", "user.email", "t@example.com"]); - git(primary, ["config", "user.name", "Test"]); - - // The generated file ends WITHOUT a trailing newline, mirroring the real - // schema.graphql that froze hogwarts. - mkdirSync(join(primary, "src", "generated"), { recursive: true }); - writeFileSync(join(primary, GENERATED), "type Query {\n id: ID!\n}"); - writeFileSync(join(primary, "README.md"), "hi\n"); - git(primary, ["add", "."]); - git(primary, ["commit", "-m", "init"]); - git(primary, ["push", "origin", "master"]); - - // A linked worktree parked on its slot branch. - wt = join(root, "wt7"); - git(primary, ["worktree", "add", "-b", "parking-lot/7", wt, "HEAD"]); - - // origin/master advances by one commit so there is something to fast-forward to. - writeFileSync(join(primary, "NEW.md"), "new\n"); - git(primary, ["add", "."]); - git(primary, ["commit", "-m", "advance"]); - git(primary, ["push", "origin", "master"]); - git(primary, ["checkout", "-q", "HEAD~1"]); // keep master off the primary's HEAD - - // Declare the generated file as auto-resolvable, exactly like assured-dev's - // sync.json does. postResolve is present to prove the sweep does NOT run it. - saveSyncConfig(repoDataDir(repoName), { - autoResolve: [ - { glob: [`**/generated/**`], strategy: "theirs", postResolve: ["exit 1"] }, - ], - }); - }); - - afterEach(() => { - try { rmSync(root, { recursive: true, force: true }); } catch { /* */ } - try { rmSync(repoDataDir(repoName), { recursive: true, force: true }); } catch { /* */ } - }); - - function behindCount(): number { - return parseInt(git(wt, ["rev-list", "--count", "HEAD..origin/master"]).trim(), 10); - } - - test("fast-forwards a clean parked worktree", () => { - expect(behindCount()).toBe(1); - fastForwardParkedWorktrees(repoName, primary, [{ path: wt, branch: "parking-lot/7" }]); - expect(behindCount()).toBe(0); - }); - - test("discards a whitespace-only drift on a declared generated file, then fast-forwards", () => { - // The exact hogwarts shape: a trailing newline appended to a generated file. - writeFileSync(join(wt, GENERATED), "type Query {\n id: ID!\n}\n"); - expect(hasUncommittedChanges(wt)).toBe(true); - - fastForwardParkedWorktrees(repoName, primary, [{ path: wt, branch: "parking-lot/7" }]); - - expect(behindCount()).toBe(0); - expect(hasUncommittedChanges(wt)).toBe(false); - }); - - test("stashes and restores undeclared dirty work across the fast-forward", () => { - writeFileSync(join(wt, "sheep.toml"), "name = 'mine'\n"); - git(wt, ["add", "sheep.toml"]); - - fastForwardParkedWorktrees(repoName, primary, [{ path: wt, branch: "parking-lot/7" }]); - - expect(behindCount()).toBe(0); - // The user's own work must survive the sweep. - expect(existsSync(join(wt, "sheep.toml"))).toBe(true); - expect(readFileSync(join(wt, "sheep.toml"), "utf8")).toBe("name = 'mine'\n"); - }); - - test("handles the mixed case: discard the generated drift, stash/restore the rest", () => { - writeFileSync(join(wt, GENERATED), "type Query {\n id: ID!\n}\n"); - writeFileSync(join(wt, "sheep.toml"), "name = 'mine'\n"); - git(wt, ["add", "sheep.toml"]); - - fastForwardParkedWorktrees(repoName, primary, [{ path: wt, branch: "parking-lot/7" }]); - - expect(behindCount()).toBe(0); - expect(existsSync(join(wt, "sheep.toml"))).toBe(true); - // The generated drift is gone (discarded, not stashed back on top). - expect(git(wt, ["status", "--porcelain", "--", GENERATED]).trim()).toBe(""); - }); - - test("a substantive change to a declared file is stashed, never discarded", () => { - // Not whitespace, a real edit. The declaration says "upstream wins", but a - // background daemon must not destroy content it didn't generate. - writeFileSync(join(wt, GENERATED), "type Query {\n id: ID!\n extra: String\n}"); - - fastForwardParkedWorktrees(repoName, primary, [{ path: wt, branch: "parking-lot/7" }]); - - expect(behindCount()).toBe(0); - expect(readFileSync(join(wt, GENERATED), "utf8")).toContain("extra: String"); - }); - - test("leaves a diverged parked branch alone rather than forcing it", () => { - writeFileSync(join(wt, "local.md"), "local\n"); - git(wt, ["add", "."]); - git(wt, ["commit", "-m", "local commit"]); - const headBefore = git(wt, ["rev-parse", "HEAD"]).trim(); - - fastForwardParkedWorktrees(repoName, primary, [{ path: wt, branch: "parking-lot/7" }]); - - expect(git(wt, ["rev-parse", "HEAD"]).trim()).toBe(headBefore); - }); -}); diff --git a/lib/daemon/__tests__/rt-client-commands.test.ts b/lib/daemon/__tests__/rt-client-commands.test.ts index 97ec4ceb..55a98147 100644 --- a/lib/daemon/__tests__/rt-client-commands.test.ts +++ b/lib/daemon/__tests__/rt-client-commands.test.ts @@ -35,6 +35,7 @@ describe("rt-client command coverage", () => { ctx: stubCtx, broadcast: () => {}, systemProcessScanner: {} as any, + worktree: { emit: () => {}, kick: () => {}, creationInFlight: () => null }, }); for (const name of COMMAND_NAMES) { expect(handlers[name]).toBeDefined(); diff --git a/lib/daemon/__tests__/worktree-handlers.test.ts b/lib/daemon/__tests__/worktree-handlers.test.ts new file mode 100644 index 00000000..ae6694f6 --- /dev/null +++ b/lib/daemon/__tests__/worktree-handlers.test.ts @@ -0,0 +1,509 @@ +/** + * Handler-level tests for the worktree IPC verbs (spec §3/§7/§11.2). + * + * Real git repos (bare-clone origins, realpath'd tmpdirs per the fixture + * rule) plus a stub HandlerContext: the handlers are exercised exactly as the + * daemon calls them, so payload/data contracts and the provision matrix are + * asserted against ground truth rather than mocks. + */ + +import { describe, test, expect, beforeEach } from "bun:test"; +import { execSync } from "child_process"; +import { existsSync, mkdtempSync, realpathSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { basename, join } from "path"; +import type { Logger } from "pino"; +import { writeJson } from "../../json-store.ts"; +import { repoDataDir, rtDir } from "../../rt-paths.ts"; +import { loadRegistry, saveRegistry, type TreeRecord } from "../../worktree/registry.ts"; +import { tryLockTree } from "../../worktree/locks.ts"; +import { branchExistsLocalAsync, currentBranchAsync, headSha } from "../../worktree/git-async.ts"; +import { createWorktreeHandlers, isClaimable } from "../handlers/worktree.ts"; +import type { HandlerContext, HandlerMap } from "../handlers/types.ts"; + +function sh(cmd: string, cwd?: string): string { + return execSync(cmd, { cwd, shell: "/bin/zsh", encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); +} + +/** A repo on `main` with one commit and a bare clone wired up as origin. */ +function makeRepo(prefix = "rtwh-"): string { + // realpathSync: git canonicalizes /var -> /private/var on macOS (fixture rule) + const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix))); + sh("git init -b main && git -c user.email=t@t -c user.name=t commit --allow-empty -m init", dir); + const bare = realpathSync(mkdtempSync(join(tmpdir(), `${prefix}bare-`))); + sh(`git clone --bare ${dir} ${bare}/o.git && git -C ${dir} remote add origin ${bare}/o.git && git -C ${dir} fetch origin`); + return dir; +} + +function fakeLog(): Logger { + return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as unknown as Logger; +} + +interface Harness { + h: HandlerMap; + events: Array<{ type: string; data: any }>; + kicks: number; +} + +function makeHandlers( + repos: Record, + entries: Record = {}, +): Harness { + const events: Array<{ type: string; data: any }> = []; + const state = { kicks: 0 }; + const ctx = { + cache: { entries }, + repoIndex: () => repos, + log: fakeLog(), + loadCache: () => {}, + flushCache: () => {}, + refreshCache: async () => {}, + } as unknown as HandlerContext; + const h = createWorktreeHandlers(ctx, { + emit: (type: string, data: unknown) => events.push({ type, data: data as any }), + kick: () => { state.kicks++; }, + creationInFlight: () => null, + }); + return { + h, + events, + get kicks() { return state.kicks; }, + } as Harness; +} + +/** Register a real on-deck worktree (git + registry) without paying createTree. */ +function seedOnDeck(repo: string, repoName: string, name: string, readyAt: string): TreeRecord { + const path = join(repo, ".worktrees", name); + sh(`git worktree add -b on-deck/${name} ${path} origin/main`, repo); + const rec: TreeRecord = { + name, path, + kind: "ephemeral", + state: "on-deck", + branch: `on-deck/${name}`, + createdAt: readyAt, + readyAt, + }; + const trees = loadRegistry(repoName); + trees.push(rec); + saveRegistry(repoName, trees); + return rec; +} + +/** Register a real claimed worktree on its own branch. */ +function seedClaimed(repo: string, repoName: string, name: string, branch: string, owner?: string): TreeRecord { + const path = join(repo, ".worktrees", name); + sh(`git worktree add -b ${branch} ${path} origin/main`, repo); + const rec: TreeRecord = { + name, path, + kind: "ephemeral", + state: "claimed", + branch, + createdAt: new Date().toISOString(), + claimedAt: new Date().toISOString(), + disposal: "merge", + ...(owner ? { owner } : {}), + }; + const trees = loadRegistry(repoName); + trees.push(rec); + saveRegistry(repoName, trees); + return rec; +} + +const repoName = "acme"; + +beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtwh-home-"))); + // killProcesses off: the process killer shells out to ps/lsof and has + // nothing to find in a fixture. + writeJson(join(rtDir(), "worktrees.json"), { enabled: true, killProcesses: false }); +}); + +describe("worktree:provision", () => { + test("claims an on-deck tree onto the derived branch and emits worktree:claimed", async () => { + const repo = makeRepo(); + const rec = seedOnDeck(repo, repoName, "alpha", new Date().toISOString()); + const { h, events } = makeHandlers({ [repoName]: repo }); + + const res: any = await h["worktree:provision"]!({ + repoName, ticket: "RT-99", ticketTitle: "Do the thing", owner: "pane-1", + }); + + expect(res.ok).toBe(true); + expect(res.data.tree).toBe("alpha"); + expect(res.data.wasOnDeck).toBe(true); + expect(res.data.branch).toBe("rt-99-do-the-thing"); + expect(res.data.branchState).toBe("new"); + expect(res.data.readyAt).toBe(rec.readyAt!); + + expect(await currentBranchAsync(rec.path)).toBe("rt-99-do-the-thing"); + expect(await branchExistsLocalAsync(repo, "on-deck/alpha")).toBe(false); + + const stored = loadRegistry(repoName).find((t) => t.name === "alpha")!; + expect(stored.state).toBe("claimed"); + expect(stored.owner).toBe("pane-1"); + expect(stored.disposal).toBe("merge"); + expect(stored.branch).toBe("rt-99-do-the-thing"); + expect(stored.claimedAt).toBeTruthy(); + + const claimed = events.find((e) => e.type === "worktree:claimed"); + expect(claimed).toBeDefined(); + expect(claimed!.data).toMatchObject({ repo: repoName, tree: "alpha", branch: "rt-99-do-the-thing", owner: "pane-1" }); + }); + + test("refuses a branch attached to another tree before touching the registry", async () => { + const repo = makeRepo(); + seedOnDeck(repo, repoName, "alpha", new Date().toISOString()); + seedClaimed(repo, repoName, "beta", "rt-1-taken"); + const before = JSON.stringify(loadRegistry(repoName)); + const { h, events } = makeHandlers({ [repoName]: repo }); + + const res: any = await h["worktree:provision"]!({ repoName, branch: "rt-1-taken" }); + + expect(res.ok).toBe(false); + expect(res.error).toBe("branch-attached:beta"); + expect(JSON.stringify(loadRegistry(repoName))).toBe(before); + expect(events.length).toBe(0); + }); + + test("refuses a branch duplicated across trees", async () => { + const repo = makeRepo(); + const trees: TreeRecord[] = [ + { name: "a", path: join(repo, ".worktrees", "a"), kind: "ephemeral", state: "claimed", branch: "dup", createdAt: new Date().toISOString() }, + { name: "b", path: join(repo, ".worktrees", "b"), kind: "ephemeral", state: "claimed", branch: "dup", createdAt: new Date().toISOString() }, + ]; + saveRegistry(repoName, trees); + const { h } = makeHandlers({ [repoName]: repo }); + + const res: any = await h["worktree:provision"]!({ repoName, branch: "dup" }); + expect(res.ok).toBe(false); + expect(res.error).toBe("branch-duplicated"); + }); + + test("unknown repo refuses", async () => { + const { h } = makeHandlers({}); + const res: any = await h["worktree:provision"]!({ repoName: "nope", branch: "x" }); + expect(res.ok).toBe(false); + expect(res.error).toBe("repo-unknown"); + }); + + test("cold-creates when the pool is empty (wasOnDeck false)", async () => { + const repo = makeRepo(); + const { h } = makeHandlers({ [repoName]: repo }); + + const res: any = await h["worktree:provision"]!({ repoName, ticket: "RT-7", ticketTitle: "Cold" }); + + expect(res.ok).toBe(true); + expect(res.data.wasOnDeck).toBe(false); + expect(res.data.branch).toBe("rt-7-cold"); + expect(existsSync(res.data.path)).toBe(true); + expect(await currentBranchAsync(res.data.path)).toBe("rt-7-cold"); + + const stored = loadRegistry(repoName).find((t) => t.name === res.data.tree)!; + expect(stored.state).toBe("claimed"); + expect(stored.kind).toBe("ephemeral"); + }); + + test("a branch that exists only on the remote is checked out tracking it", async () => { + const repo = makeRepo(); + // A teammate's branch: pushed to origin, never present locally. + sh("git -c user.email=t@t -c user.name=t checkout -q -b tmp-mate && git -c user.email=t@t -c user.name=t commit --allow-empty -m mate", repo); + const mateSha = sh("git rev-parse HEAD", repo).trim(); + sh("git push -q origin tmp-mate:refs/heads/mate-branch && git checkout -q main && git branch -qD tmp-mate", repo); + + const rec = seedOnDeck(repo, repoName, "alpha", new Date().toISOString()); + const { h } = makeHandlers({ [repoName]: repo }); + + const res: any = await h["worktree:provision"]!({ repoName, branch: "mate-branch" }); + + expect(res.ok).toBe(true); + expect(res.data.branchState).toBe("tracking-remote"); + expect(await headSha(rec.path)).toBe(mateSha); + expect(await currentBranchAsync(rec.path)).toBe("mate-branch"); + }); + + test("local branches report existing-clean, behind, or diverged and are never reconciled", async () => { + const repo = makeRepo(); + const gitId = "git -c user.email=t@t -c user.name=t"; + + // (c) local only, no upstream at all. + sh(`${gitId} branch feat-local origin/main`, repo); + + // (c) local trails its upstream: origin's copy gained a commit the local ref never saw. + sh(`${gitId} checkout -q -b feat-behind origin/main && git push -q origin feat-behind`, repo); + sh(`${gitId} commit -q --allow-empty -m ahead && git push -q origin feat-behind && ${gitId} reset -q --hard HEAD~1 && git checkout -q main`, repo); + + // (e) both sides moved independently. + sh(`${gitId} checkout -q -b tmp-div origin/main && ${gitId} commit -q --allow-empty -m theirs && git push -q origin tmp-div:refs/heads/feat-div && git checkout -q main && git branch -qD tmp-div`, repo); + sh(`${gitId} checkout -q -b feat-div origin/main && ${gitId} commit -q --allow-empty -m mine && git checkout -q main`, repo); + const mineSha = sh("git rev-parse feat-div", repo).trim(); + + seedOnDeck(repo, repoName, "one", new Date().toISOString()); + seedOnDeck(repo, repoName, "two", new Date().toISOString()); + seedOnDeck(repo, repoName, "three", new Date().toISOString()); + const { h } = makeHandlers({ [repoName]: repo }); + + const plain: any = await h["worktree:provision"]!({ repoName, branch: "feat-local" }); + expect(plain.data.branchState).toBe("existing-clean"); + + const behind: any = await h["worktree:provision"]!({ repoName, branch: "feat-behind" }); + expect(behind.data.branchState).toBe("behind"); + + const diverged: any = await h["worktree:provision"]!({ repoName, branch: "feat-div" }); + expect(diverged.data.branchState).toBe("diverged"); + // Reported, never reconciled: the local tip is checked out untouched. + expect(await headSha(diverged.data.path)).toBe(mineSha); + }); + + test("a failure after the claim rolls the tree back to on-deck", async () => { + const repo = makeRepo(); + const rec = seedOnDeck(repo, repoName, "alpha", new Date().toISOString()); + // Unreachable origin: the targeted fetch fails with something that is NOT + // git's ref-not-found signature, which must roll back rather than proceed. + sh(`git remote set-url origin ${join(tmpdir(), "rtwh-gone-nowhere.git")}`, repo); + const { h } = makeHandlers({ [repoName]: repo }); + + const res: any = await h["worktree:provision"]!({ repoName, branch: "rt-5-boom", owner: "pane-1" }); + + expect(res.ok).toBe(false); + expect(String(res.error).startsWith("checkout-failed:")).toBe(true); + + const stored = loadRegistry(repoName).find((t) => t.name === "alpha")!; + expect(stored.state).toBe("on-deck"); + expect(stored.owner).toBeUndefined(); + expect(stored.claimedAt).toBeUndefined(); + expect(stored.branch).toBe("on-deck/alpha"); + expect(await currentBranchAsync(rec.path)).toBe("on-deck/alpha"); + }); + + test("only an on-deck entry is still claimable when the lock is finally taken", () => { + const base: TreeRecord = { + name: "alpha", + path: "/tmp/alpha", + kind: "ephemeral", + state: "on-deck", + branch: "on-deck/alpha", + createdAt: new Date().toISOString(), + }; + expect(isClaimable(base)).toBe(true); + // `claimed` is precisely "another provision got here first": re-claiming + // would overwrite that caller's owner/disposal and re-checkout their tree. + expect(isClaimable({ ...base, state: "claimed" })).toBe(false); + expect(isClaimable({ ...base, state: "creating" })).toBe(false); + expect(isClaimable({ ...base, state: "disposable" })).toBe(false); + expect(isClaimable({ ...base, kind: "unmanaged", state: undefined })).toBe(false); + expect(isClaimable(undefined)).toBe(false); + }); + + test("a tree that has already left its on-deck branch flips disposable on failure", async () => { + const repo = makeRepo(); + const rec = seedOnDeck(repo, repoName, "alpha", new Date().toISOString()); + // Registry drift: the tree is off its pool branch while the registry still + // says on-deck, so the rollback has no on-deck branch to return it to. + sh("git checkout -q -b drifted", rec.path); + sh(`git remote set-url origin ${join(tmpdir(), "rtwh-gone-nowhere.git")}`, repo); + const { h, events } = makeHandlers({ [repoName]: repo }); + + const res: any = await h["worktree:provision"]!({ repoName, branch: "rt-8-boom", owner: "pane-1" }); + + expect(res.ok).toBe(false); + expect(String(res.error).startsWith("checkout-failed:")).toBe(true); + + const stored = loadRegistry(repoName).find((t) => t.name === "alpha")!; + expect(stored.state).toBe("disposable"); + expect(stored.branch).toBe("drifted"); + expect(String(stored.disposableReason).startsWith("checkout-failed:")).toBe(true); + + const flipped = events.find((e) => e.type === "worktree:disposable"); + expect(flipped).toBeDefined(); + expect(flipped!.data).toMatchObject({ repo: repoName, tree: "alpha", branch: "drifted" }); + expect(String(flipped!.data.reason).startsWith("checkout-failed:")).toBe(true); + }); + + test("a ready step that fails after the claim hands the tree over flagged", async () => { + const repo = makeRepo(); + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { ready: [{ run: "exit 3" }] }, + }); + seedOnDeck(repo, repoName, "alpha", new Date().toISOString()); + const { h } = makeHandlers({ [repoName]: repo }); + + const res: any = await h["worktree:provision"]!({ repoName, branch: "rt-9-degraded" }); + + // Non-fatal: the caller has a usable tree, but must be able to see that + // its readiness is degraded. + expect(res.ok).toBe(true); + expect(res.data.readyFailed).toBe(true); + expect(res.data.failedStep).toBe("exit 3"); + expect(loadRegistry(repoName).find((t) => t.name === "alpha")!.state).toBe("claimed"); + }); + + test("skips a locked on-deck tree and picks the next best", async () => { + const repo = makeRepo(); + const older = new Date(Date.now() - 60_000).toISOString(); + seedOnDeck(repo, repoName, "alpha", older); + const beta = seedOnDeck(repo, repoName, "beta", new Date().toISOString()); + + const release = tryLockTree(beta.path); + expect(release).not.toBeNull(); + try { + const { h } = makeHandlers({ [repoName]: repo }); + const res: any = await h["worktree:provision"]!({ repoName, branch: "rt-2-pick" }); + expect(res.ok).toBe(true); + // beta is the freshest but locked, so the claim falls to alpha. + expect(res.data.tree).toBe("alpha"); + } finally { + release!(); + } + }); +}); + +describe("worktree:create", () => { + test("--on-deck leaves the tree in the pool; without it the tree is claimed", async () => { + const repo = makeRepo(); + const { h } = makeHandlers({ [repoName]: repo }); + + const pooled: any = await h["worktree:create"]!({ repoName, onDeck: true }); + expect(pooled.ok).toBe(true); + expect(loadRegistry(repoName).find((t) => t.name === pooled.data.tree)!.state).toBe("on-deck"); + + const claimed: any = await h["worktree:create"]!({ repoName }); + expect(claimed.ok).toBe(true); + expect(existsSync(claimed.data.path)).toBe(true); + expect(loadRegistry(repoName).find((t) => t.name === claimed.data.tree)!.state).toBe("claimed"); + }); +}); + +describe("worktree:dispose", () => { + test("a locked tree gets the typed busy refusal", async () => { + const repo = makeRepo(); + const rec = seedClaimed(repo, repoName, "alpha", "rt-3-work"); + const release = tryLockTree(rec.path); + try { + const { h } = makeHandlers({ [repoName]: repo }); + const res: any = await h["worktree:dispose"]!({ repoName, tree: "alpha" }); + expect(res.ok).toBe(true); + expect(res.data.disposed).toEqual([]); + expect(res.data.refused).toEqual([{ tree: "alpha", reason: "busy" }]); + } finally { + release!(); + } + }); + + test("--owner sweeps every repo and reports the dirty tree's refusal", async () => { + const repoA = makeRepo(); + const repoB = makeRepo(); + const clean = seedClaimed(repoA, "acme", "alpha", "rt-4-clean", "job-1"); + const dirty = seedClaimed(repoB, "beta-repo", "bravo", "rt-4-dirty", "job-1"); + writeFileSync(join(dirty.path, "scratch.txt"), "uncommitted\n"); + + const { h } = makeHandlers({ acme: repoA, "beta-repo": repoB }); + const res: any = await h["worktree:dispose"]!({ owner: "job-1" }); + + expect(res.ok).toBe(true); + expect(res.data.disposed).toEqual(["alpha"]); + expect(res.data.refused).toEqual([{ tree: "bravo", reason: "dirty" }]); + expect(existsSync(clean.path)).toBe(false); + expect(existsSync(dirty.path)).toBe(true); + expect(loadRegistry("acme").find((t) => t.name === "alpha")).toBeUndefined(); + expect(loadRegistry("beta-repo").find((t) => t.name === "bravo")).toBeDefined(); + }); + + test("a bare tree name matching two repos refuses rather than guessing", async () => { + const repoA = makeRepo(); + const repoB = makeRepo(); + seedClaimed(repoA, "acme", "alpha", "rt-6-a"); + seedClaimed(repoB, "beta-repo", "alpha", "rt-6-b"); + + const { h } = makeHandlers({ acme: repoA, "beta-repo": repoB }); + const res: any = await h["worktree:dispose"]!({ tree: "alpha" }); + + expect(res.ok).toBe(false); + expect(res.error).toBe("tree-ambiguous"); + expect(loadRegistry("acme").length).toBe(1); + expect(loadRegistry("beta-repo").length).toBe(1); + }); +}); + +describe("worktree:list", () => { + test("flags duplicate branches and joins MRs on (repoName, branch)", async () => { + const repo = makeRepo(); + const now = new Date().toISOString(); + saveRegistry(repoName, [ + { name: "a", path: join(repo, ".worktrees", "a"), kind: "ephemeral", state: "claimed", branch: "dup", createdAt: now }, + { name: "b", path: join(repo, ".worktrees", "b"), kind: "ephemeral", state: "claimed", branch: "dup", createdAt: now }, + { name: "c", path: join(repo, ".worktrees", "c"), kind: "ephemeral", state: "claimed", branch: "solo", createdAt: now }, + { name: "d", path: join(repo, ".worktrees", "d"), kind: "ephemeral", state: "claimed", branch: "other-repo", createdAt: now }, + ]); + const entries = { + solo: { mr: { iid: 7, state: "opened", title: "Solo work" }, repoName }, + "other-repo": { mr: { iid: 9, state: "opened", title: "Not ours" }, repoName: "elsewhere" }, + }; + const { h } = makeHandlers({ [repoName]: repo }, entries); + + const res: any = await h["worktree:list"]!({ repoName }); + expect(res.ok).toBe(true); + const byName: Record = Object.fromEntries(res.data.trees.map((t: any) => [t.name, t])); + + expect(byName.a.duplicateBranch).toBe(true); + expect(byName.b.duplicateBranch).toBe(true); + expect(byName.c.duplicateBranch).toBeUndefined(); + expect(byName.c.mr).toMatchObject({ iid: 7, state: "opened", title: "Solo work" }); + // repoName mismatch means "no MR", never another repo's MR. + expect(byName.d.mr).toBeNull(); + }); +}); + +describe("worktree:freshen", () => { + test("runs the named tree and fast-forwards it", async () => { + const repo = makeRepo(); + const rec = seedOnDeck(repo, repoName, "alpha", new Date().toISOString()); + const before = await headSha(rec.path); + sh("git -c user.email=t@t -c user.name=t commit --allow-empty -m advance && git push -q origin main", repo); + + const { h } = makeHandlers({ [repoName]: repo }); + const res: any = await h["worktree:freshen"]!({ repoName, tree: "alpha" }); + + expect(res.ok).toBe(true); + expect(res.data.ran).toEqual(["alpha"]); + expect(await headSha(rec.path)).not.toBe(before); + }); +}); + +describe("worktree:adopt", () => { + test("registers main, disposes a clean parking-lot tree, claims the feature tree", async () => { + const repo = makeRepo(); + const parked = join(repo, ".worktrees", "parked"); + const feature = join(repo, ".worktrees", "feature"); + sh(`git worktree add -b parking-lot/1 ${parked} origin/main`, repo); + sh(`git worktree add -b cv-1-feature ${feature} origin/main`, repo); + + const repoIndexPath = join(repoDataDir(repoName), "parking-lot.json"); + const appStatePath = join(rtDir(), "parking-lot-state.json"); + writeJson(repoIndexPath, { [parked]: { branch: "parking-lot/1" } }); + writeJson(appStatePath, { transitions: [] }); + + const { h } = makeHandlers({ [repoName]: repo }); + const res: any = await h["worktree:adopt"]!({ repoName }); + + expect(res.ok).toBe(true); + expect(res.data.main).toBe(basename(repo)); + expect(res.data.disposed).toEqual(["parked"]); + expect(res.data.claimed).toEqual(["feature"]); + expect(res.data.refused).toEqual([]); + + expect(existsSync(parked)).toBe(false); + expect(await branchExistsLocalAsync(repo, "parking-lot/1")).toBe(false); + + const trees = loadRegistry(repoName); + expect(trees.find((t) => t.path === repo)!.kind).toBe("main"); + expect(trees.find((t) => t.name === "parked")).toBeUndefined(); + const feat = trees.find((t) => t.name === "feature")!; + expect(feat.kind).toBe("ephemeral"); + expect(feat.state).toBe("claimed"); + expect(feat.disposal).toBe("merge"); + expect(feat.branch).toBe("cv-1-feature"); + + expect(existsSync(repoIndexPath)).toBe(false); + expect(existsSync(appStatePath)).toBe(false); + }); +}); diff --git a/lib/daemon/__tests__/worktree-reconciler.test.ts b/lib/daemon/__tests__/worktree-reconciler.test.ts new file mode 100644 index 00000000..82df31db --- /dev/null +++ b/lib/daemon/__tests__/worktree-reconciler.test.ts @@ -0,0 +1,1157 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { execSync } from "child_process"; +import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { basename, join } from "path"; +import type { Logger } from "pino"; +import { readJson, writeJson } from "../../json-store.ts"; +import { repoDataDir, rtDir } from "../../rt-paths.ts"; +import { findByPath, loadRegistry, saveRegistry, type TreeRecord } from "../../worktree/registry.ts"; +import { + branchExistsLocalAsync, + currentBranchAsync, + findDesktopStashAsync, + headSha, + listWorktreesAsync, +} from "../../worktree/git-async.ts"; +import { createTree } from "../../worktree/create.ts"; +import type { WorktreeAppConfig } from "../../worktree/config.ts"; +import { reconcileRepoRegistry, createWorktreeReconciler, __test__ } from "../worktree-reconciler.ts"; + +function makeRepo(): string { + // realpathSync: git canonicalizes /var -> /private/var on macOS (Global Constraints) + const dir = realpathSync(mkdtempSync(join(tmpdir(), "rtrecon-"))); + execSync( + "git init -b main && git -c user.email=t@t -c user.name=t commit --allow-empty -m init", + { cwd: dir, shell: "/bin/zsh" } + ); + return dir; +} + +/** Bare-clone `repo` as its own "origin" and fetch, so remoteDefaultRef resolves origin/main. */ +function addBareOrigin(repo: string): void { + const bare = mkdtempSync(join(tmpdir(), "rtrecon-bare-")); + execSync( + `git clone --bare ${repo} ${bare}/o.git && git -C ${repo} remote add origin ${bare}/o.git && git -C ${repo} fetch origin`, + { shell: "/bin/zsh" } + ); +} + +function fakeLog(): Logger { + return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as unknown as Logger; +} + +function makeDeps(repoName: string, repoPath: string, events: Array<{ type: string; data: unknown }>) { + return { + repoName, + repoPath, + emit: (type: string, data: unknown) => events.push({ type, data }), + log: fakeLog(), + }; +} + +describe("reconcileRepoRegistry", () => { + let repo: string; + let repoName: string; + let events: Array<{ type: string; data: unknown }>; + + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtrecon-home-"))); + __test__.createBackoff.clear(); + repo = makeRepo(); + repoName = "acme"; + events = []; + }); + + test("adopts main and a manually-added worktree as unmanaged", async () => { + const manualPath = join(repo, ".worktrees", "manual"); + execSync(`git worktree add -b manual-branch ${manualPath}`, { cwd: repo, shell: "/bin/zsh" }); + + const trees = await reconcileRepoRegistry(makeDeps(repoName, repo, events)); + + const main = findByPath(trees, repo); + expect(main).toBeDefined(); + expect(main!.kind).toBe("main"); + + const manual = findByPath(trees, manualPath); + expect(manual).toBeDefined(); + expect(manual!.kind).toBe("unmanaged"); + expect(manual!.branch).toBe("manual-branch"); + + const registry = loadRegistry(repoName); + expect(registry.length).toBe(2); + }); + + test("rm -rf'd manual tree is pruned, and prune lets the name be reused by createTree", async () => { + addBareOrigin(repo); + const name = "reuseme"; + const manualPath = join(repo, ".worktrees", name); + execSync(`git worktree add -b manual-branch ${manualPath}`, { cwd: repo, shell: "/bin/zsh" }); + + await reconcileRepoRegistry(makeDeps(repoName, repo, events)); + expect(findByPath(loadRegistry(repoName), manualPath)).toBeDefined(); + + // Simulate an external `rm -rf` of the worktree dir, leaving git's own + // registration (and the registry entry) stale. + rmSync(manualPath, { recursive: true, force: true }); + + const trees = await reconcileRepoRegistry(makeDeps(repoName, repo, events)); + expect(findByPath(trees, manualPath)).toBeUndefined(); + expect(findByPath(loadRegistry(repoName), manualPath)).toBeUndefined(); + + // Reusing the same name must succeed now that `git worktree prune` ran; + // without it git still holds the stale worktree registration at manualPath. + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { namePool: [name] }, + }); + + const result = await createTree({ + repoName, + repoPath: repo, + emit: (type, data) => events.push({ type, data }), + log: { info: () => {}, warn: () => {} }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.tree.name).toBe(name); + expect(result.tree.path).toBe(manualPath); + }); + + test("branch rename updates the registry's ground-truth branch field", async () => { + const manualPath = join(repo, ".worktrees", "renametree"); + execSync(`git worktree add -b old-name ${manualPath}`, { cwd: repo, shell: "/bin/zsh" }); + + await reconcileRepoRegistry(makeDeps(repoName, repo, events)); + expect(findByPath(loadRegistry(repoName), manualPath)!.branch).toBe("old-name"); + + execSync("git branch -m old-name new-name", { cwd: manualPath, shell: "/bin/zsh" }); + + const trees = await reconcileRepoRegistry(makeDeps(repoName, repo, events)); + const rec = findByPath(trees, manualPath); + expect(rec).toBeDefined(); + expect(rec!.branch).toBe("new-name"); + expect(rec!.kind).toBe("unmanaged"); // kind/state/owner untouched by ground-truth sync + }); + + test("orphaned creating entry with no held lock is scrapped", async () => { + const ghostPath = join(repo, ".worktrees", "ghost"); + execSync(`git worktree add -b on-deck/ghost ${ghostPath}`, { cwd: repo, shell: "/bin/zsh" }); + + const ghost: TreeRecord = { + name: "ghost", + path: ghostPath, + kind: "ephemeral", + state: "creating", + branch: "on-deck/ghost", + createdAt: new Date().toISOString(), + }; + saveRegistry(repoName, [ghost]); + + const trees = await reconcileRepoRegistry(makeDeps(repoName, repo, events)); + + expect(findByPath(trees, ghostPath)).toBeUndefined(); + expect(existsSync(ghostPath)).toBe(false); + expect(await branchExistsLocalAsync(repo, "on-deck/ghost")).toBe(false); + + const worktrees = (await listWorktreesAsync(repo))!; + expect(worktrees.some((w) => w.path === ghostPath)).toBe(false); + }); + + test("a broken git dir leaves an existing registry row untouched instead of pruning it", async () => { + // Adopt the main clone into the registry on a healthy pass first. + await reconcileRepoRegistry(makeDeps(repoName, repo, events)); + const before = loadRegistry(repoName); + expect(before.length).toBe(1); + expect(before[0]!.kind).toBe("main"); + + // Break the git dir so `git worktree list --porcelain` fails. + rmSync(join(repo, ".git"), { recursive: true, force: true }); + + const trees = await reconcileRepoRegistry(makeDeps(repoName, repo, events)); + + expect(trees).toEqual(before); + expect(loadRegistry(repoName)).toEqual(before); + }); + + /** + * The concurrency contract: reconcile loads a whole-registry snapshot and + * then awaits git for a long time, so every other writer (provision's claim, + * dispose's prune, freshen's patch) runs on the same event loop inside that + * window. `onAfterLoad` is the deterministic stand-in for that writer — it + * fires right after reconcile captures its snapshot and epoch. + */ + function claimConcurrently(path: string, owner: string): void { + const cur = loadRegistry(repoName); + const rec = cur.find((t) => t.path === path); + if (!rec) throw new Error(`no registry row at ${path}`); + rec.state = "claimed"; + rec.owner = owner; + rec.claimedAt = new Date().toISOString(); + saveRegistry(repoName, cur); + } + + function onDeckRow(path: string): TreeRecord { + return { + name: basename(path), + path, + kind: "ephemeral", + state: "on-deck", + branch: `on-deck/${basename(path)}`, + createdAt: new Date().toISOString(), + }; + } + + test("a claim landing mid-pass survives; reconcile retries instead of saving its stale snapshot", async () => { + const treePath = join(repo, ".worktrees", "claimable"); + execSync(`git worktree add -b on-deck/claimable ${treePath}`, { cwd: repo, shell: "/bin/zsh" }); + // Only the ephemeral tree is registered, so reconcile's own correction this + // pass is adopting the main clone (changed -> a whole-snapshot save). + saveRegistry(repoName, [onDeckRow(treePath)]); + + let fired = false; + const trees = await reconcileRepoRegistry({ + ...makeDeps(repoName, repo, events), + onAfterLoad: () => { + if (fired) return; + fired = true; + claimConcurrently(treePath, "matt"); + }, + }); + + const final = loadRegistry(repoName); + const claim = findByPath(final, treePath); + expect(claim).toBeDefined(); + expect(claim!.state).toBe("claimed"); // NOT reverted to on-deck + expect(claim!.owner).toBe("matt"); + + // ...and reconcile's own correction still landed, on the retry pass. + expect(findByPath(final, repo)?.kind).toBe("main"); + expect(findByPath(trees, repo)?.kind).toBe("main"); + expect(findByPath(trees, treePath)?.state).toBe("claimed"); + }); + + test("a writer landing on every attempt exhausts the retries: reconcile warns and skips its save", async () => { + const treePath = join(repo, ".worktrees", "contended"); + execSync(`git worktree add -b on-deck/contended ${treePath}`, { cwd: repo, shell: "/bin/zsh" }); + saveRegistry(repoName, [onDeckRow(treePath)]); + + const warns: unknown[][] = []; + let attempts = 0; + const log = { + info: () => {}, + warn: (...args: unknown[]) => warns.push(args), + error: () => {}, + debug: () => {}, + } as unknown as Logger; + + const trees = await reconcileRepoRegistry({ + repoName, + repoPath: repo, + emit: (type: string, data: unknown) => events.push({ type, data }), + log, + onAfterLoad: () => { + attempts++; + claimConcurrently(treePath, `w${attempts}`); + }, + }); + + expect(attempts).toBe(3); // bounded: three attempts, then give up + expect(warns.length).toBe(1); + + const final = loadRegistry(repoName); + // The last competing write stands; reconcile's adoption of main is dropped + // rather than written over it. The next pass picks it up again. + expect(findByPath(final, treePath)!.owner).toBe("w3"); + expect(findByPath(final, repo)).toBeUndefined(); + expect(findByPath(trees, treePath)!.owner).toBe("w3"); + }); +}); + +describe("createWorktreeReconciler", () => { + let repo: string; + let repoName: string; + + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtrecon-home-"))); + __test__.createBackoff.clear(); + repo = makeRepo(); + repoName = "acme"; + }); + + test("runOnce reconciles only repos with registry entries or a worktrees config", async () => { + const manualPath = join(repo, ".worktrees", "manual"); + execSync(`git worktree add -b manual-branch ${manualPath}`, { cwd: repo, shell: "/bin/zsh" }); + // Opt this repo into worktree management so runOnce picks it up even + // though its registry starts empty. + writeJson(join(repoDataDir(repoName), "config.json"), { worktrees: {} }); + + const untouchedRepo = makeRepo(); + + const reconciler = createWorktreeReconciler({ + cache: { entries: {} }, + repoIndex: () => ({ [repoName]: repo, untouched: untouchedRepo }), + emit: () => {}, + log: fakeLog(), + }); + + await reconciler.runOnce(); + + expect(loadRegistry(repoName).length).toBe(2); // main + manual adopted + expect(loadRegistry("untouched").length).toBe(0); // never touched: no config, no entries + }); + + test("kick fires runOnce without awaiting and coalesces overlapping calls", async () => { + writeJson(join(repoDataDir(repoName), "config.json"), { worktrees: {} }); + + const reconciler = createWorktreeReconciler({ + cache: { entries: {} }, + repoIndex: () => ({ [repoName]: repo }), + emit: () => {}, + log: fakeLog(), + }); + + reconciler.kick(); + reconciler.kick(); // should be a no-op overlap guard, not a second pass + + // kick is fire-and-forget; poll `passInFlight()` for TRUE completion + // rather than a blind sleep or a registry-state proxy — a pass that's + // still running (even past its registry write) but hasn't returned yet + // would otherwise dangle past this test, and since every internal path + // (repoDataDir, rtDir, ...) resolves HOME dynamically at call time, that + // stale pass can read/write into a LATER test's HOME once that test's + // beforeEach repoints the (shared, global) env var. + await waitFor(() => !reconciler.passInFlight(), 5000); + + expect(loadRegistry(repoName).length).toBe(1); // just main, adopted once + }); + + test("runOnce with the app disabled still syncs the registry, but skips reactor/freshen/replenish", async () => { + // A dedicated repoName (not the shared "acme" the other tests in this + // describe use): the prior test's `kick()` is deliberately unawaited by + // design, and since every internal path resolves HOME dynamically at call + // time, a still-running background pass from that test reading a fresh + // `worktrees.json`/onDeck config off "acme" could otherwise land its own + // (stale, enabled=true-baked-in) replenish attempt into this test's + // registry. A distinct repoName makes that collision structurally + // impossible regardless of any other test's timing. + const disabledRepoName = "acme-disabled"; + addBareOrigin(repo); + writeJson(join(repoDataDir(disabledRepoName), "config.json"), { + worktrees: { onDeck: 1, root: join(repo, ".worktrees") }, + }); + writeJson(join(rtDir(), "worktrees.json"), { enabled: false, killProcesses: false }); + + // Advance origin so a freshen (if it ran) would have something to do. + const originUrl = execSync(`git -C ${repo} remote get-url origin`, { encoding: "utf8" }).trim(); + const clone = realpathSync(mkdtempSync(join(tmpdir(), "rtrecon-clone-"))); + sh(`git clone -q ${originUrl} ${clone}`); + writeFileSync(join(clone, "feature.txt"), "hi\n"); + sh(`git add -A && git ${GIT_ID} commit -m feat`, clone); + sh(`git push -q origin main`, clone); + + const beforeSha = await headSha(repo); + const events: Array<{ type: string; data: any }> = []; + const reconciler = createWorktreeReconciler({ + cache: { entries: {} }, + repoIndex: () => ({ [disabledRepoName]: repo }), + emit: (type: string, data: unknown) => events.push({ type, data }), + log: fakeLog(), + }); + + await reconciler.runOnce(); + + // Read-only reconcile still ran: main got adopted into the registry. + const trees = loadRegistry(disabledRepoName); + expect(trees.some((t) => t.path === repo && t.kind === "main")).toBe(true); + + // Freshen skipped: main never fetched/ff'd despite being idle and behind. + expect(await headSha(repo)).toBe(beforeSha); + // Replenish skipped: no on-deck tree created despite onDeck:1. + expect(trees.some((t) => t.kind === "ephemeral")).toBe(false); + // Reactor skipped: it never even opened/wrote its state file. + expect(existsSync(__test__.reactorStatePath())).toBe(false); + expect(events.length).toBe(0); + }); +}); + +// ─── Merge reactor ─────────────────────────────────────────────────────────── + +const GIT_ID = "-c user.email=t@t -c user.name=t"; + +function sh(cmd: string, cwd?: string): void { + execSync(cmd, { cwd, shell: "/bin/zsh", stdio: "pipe" }); +} + +describe("merge reactor (detectTransitions)", () => { + const repoName = "acme"; + let repo: string; + let events: Array<{ type: string; data: any }>; + + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtreact-home-"))); + __test__.createBackoff.clear(); + repo = makeRepo(); + addBareOrigin(repo); + // killProcesses off: the reactor must not go scanning this machine's + // process table during a unit test. + writeJson(join(rtDir(), "worktrees.json"), { enabled: true, killProcesses: false }); + events = []; + }); + + function detect(entries: Record, log: Logger = fakeLog()): Promise { + return __test__.detectTransitions({ + repoName, + repoPath: repo, + cacheEntries: entries as any, + emit: (type: string, data: unknown) => events.push({ type, data }), + log, + }); + } + + /** A logger that keeps its warnings, for the "give up, don't spin" paths. */ + function capturingLog(): { log: Logger; warns: string[] } { + const warns: string[] = []; + return { + warns, + log: { + info: () => {}, + error: () => {}, + debug: () => {}, + warn: (_fields: unknown, msg?: string) => warns.push(msg ?? ""), + } as unknown as Logger, + }; + } + + function mrCache(branch: string, state: string, iid = 42): Record { + return { [branch]: { repoName, mr: { iid, state }, fetchedAt: Date.now() } }; + } + + function reactorState(): { mrState: Record; fired: string[] } { + return readJson(__test__.reactorStatePath(), { mrState: {}, fired: [] }); + } + + function tracked(path: string): TreeRecord | undefined { + return loadRegistry(repoName).find((t) => t.path === path); + } + + /** Ephemeral worktree on a pushed feature branch, registered as claimed. */ + function ephemeralTree(name: string, branch: string, extra: Partial = {}): TreeRecord { + const path = join(repo, ".worktrees", name); + sh(`git -C ${repo} worktree add -b ${branch} ${path} origin/main`); + writeFileSync(join(path, `${name}.txt`), "work\n"); + sh(`git add -A && git ${GIT_ID} commit -m work`, path); + sh(`git push -q origin ${branch}`, path); + + const old = new Date(Date.now() - 3600_000).toISOString(); + const rec: TreeRecord = { + name, + path, + kind: "ephemeral", + state: "claimed", + branch, + disposal: "merge", + createdAt: old, + claimedAt: old, // outside dispose's 10-minute stale-event grace + ...extra, + }; + saveRegistry(repoName, [...loadRegistry(repoName), rec]); + return rec; + } + + /** Put the main clone itself on a pushed feature branch, registered as main. */ + function mainOnBranch(branch: string): void { + sh(`git ${GIT_ID} checkout -q -b ${branch} origin/main`, repo); + writeFileSync(join(repo, "feature.txt"), "main work\n"); + sh(`git add -A && git ${GIT_ID} commit -m mainwork`, repo); + sh(`git push -q origin ${branch}`, repo); + saveRegistry(repoName, [ + { name: basename(repo), path: repo, kind: "main", branch, createdAt: new Date().toISOString() }, + ]); + } + + test("cold boot on an already-merged cache entry deletes nothing", async () => { + const rec = ephemeralTree("cold", "feat-cold"); + + // FIRST call ever against an empty state file: the daemon has no "opened" + // snapshot to compare against, so there is no edge and nothing may happen. + await detect(mrCache("feat-cold", "merged")); + + expect(existsSync(rec.path)).toBe(true); + expect(tracked(rec.path)!.state).toBe("claimed"); + expect(tracked(rec.path)!.disposableReason).toBeUndefined(); + expect(events.length).toBe(0); + expect(reactorState().fired).toEqual([]); + // The snapshot still records what it saw, so a later reopen→merge fires. + expect(reactorState().mrState[`${repoName}:feat-cold`]).toBe("merged"); + }); + + test("merged MR on a clean claimed tree disposes it, emits, and records a fired key", async () => { + const rec = ephemeralTree("alpha", "feat-alpha"); + + await detect(mrCache("feat-alpha", "opened")); + expect(reactorState().mrState[`${repoName}:feat-alpha`]).toBe("opened"); + + await detect(mrCache("feat-alpha", "merged")); + + expect(existsSync(rec.path)).toBe(false); + expect(tracked(rec.path)).toBeUndefined(); + expect(events.filter((e) => e.type === "worktree:disposed").length).toBe(1); + expect(reactorState().fired).toContain(`disposed:${repoName}:42:merged`); + }); + + test("a dirty tree flips to disposable once and never re-notifies", async () => { + const rec = ephemeralTree("bravo", "feat-bravo"); + writeFileSync(join(rec.path, "scratch.txt"), "uncommitted\n"); + + await detect(mrCache("feat-bravo", "opened")); + await detect(mrCache("feat-bravo", "merged")); + + expect(existsSync(rec.path)).toBe(true); + expect(tracked(rec.path)!.state).toBe("disposable"); + expect(tracked(rec.path)!.disposableReason).toBe("dirty"); + expect(events.filter((e) => e.type === "worktree:disposable").length).toBe(1); + + await detect(mrCache("feat-bravo", "merged")); + expect(events.filter((e) => e.type === "worktree:disposable").length).toBe(1); + }); + + test("a closed MR flips the tree disposable and leaves the branch intact", async () => { + const rec = ephemeralTree("charlie", "feat-charlie"); + + await detect(mrCache("feat-charlie", "opened")); + await detect(mrCache("feat-charlie", "closed")); + + expect(existsSync(rec.path)).toBe(true); + expect(tracked(rec.path)!.state).toBe("disposable"); + expect(tracked(rec.path)!.disposableReason).toBe("MR closed without merge"); + expect(await branchExistsLocalAsync(repo, "feat-charlie")).toBe(true); + expect(reactorState().fired).toContain(`disposed:${repoName}:42:closed`); + }); + + test("reopening claims the tree back and prunes its fired keys; a later merge disposes", async () => { + const rec = ephemeralTree("delta", "feat-delta"); + const dirt = join(rec.path, "scratch.txt"); + writeFileSync(dirt, "uncommitted\n"); + + await detect(mrCache("feat-delta", "opened")); + await detect(mrCache("feat-delta", "merged")); + expect(tracked(rec.path)!.state).toBe("disposable"); + expect(reactorState().fired).toContain(`disposed:${repoName}:42:merged`); + + await detect(mrCache("feat-delta", "opened")); + expect(tracked(rec.path)!.state).toBe("claimed"); + expect(tracked(rec.path)!.disposableReason).toBeUndefined(); + expect(reactorState().fired).not.toContain(`disposed:${repoName}:42:merged`); + + rmSync(dirt); + await detect(mrCache("feat-delta", "merged")); + expect(existsSync(rec.path)).toBe(false); + expect(tracked(rec.path)).toBeUndefined(); + }); + + test("main holding the merged branch auto-returns to default, stashing and leaving the dirt", async () => { + mainOnBranch("feat-main"); + writeFileSync(join(repo, "dirty.txt"), "uncommitted\n"); + + await detect(mrCache("feat-main", "opened")); + await detect(mrCache("feat-main", "merged")); + + expect(await currentBranchAsync(repo)).toBe("main"); + const stash = await findDesktopStashAsync(repo, "feat-main"); + expect(stash).not.toBeNull(); + // stash-and-LEAVE: the dirt belonged to the branch that left, so it is + // never popped back onto the default branch. + expect(existsSync(join(repo, "dirty.txt"))).toBe(false); + }); + + test("a failed auto-return holds the edge armed and fires again once the repo is repaired", async () => { + mainOnBranch("feat-lock"); + const lock = join(repo, ".git", "index.lock"); + + await detect(mrCache("feat-lock", "opened")); + writeFileSync(lock, ""); + await detect(mrCache("feat-lock", "merged")); + + expect(await currentBranchAsync(repo)).toBe("feat-lock"); + // The snapshot must NOT advance to "merged", or the edge never re-arms. + expect(reactorState().mrState[`${repoName}:feat-lock`]).toBe("opened"); + expect(reactorState().fired).not.toContain(`disposed:${repoName}:42:merged`); + + rmSync(lock); + await detect(mrCache("feat-lock", "merged")); + + expect(await currentBranchAsync(repo)).toBe("main"); + expect(reactorState().mrState[`${repoName}:feat-lock`]).toBe("merged"); + }); + + test("a failed worktree removal is retried, never advertised as disposable", async () => { + const rec = ephemeralTree("hotel", "feat-hotel"); + // A locked worktree makes `git worktree remove --force` refuse (it wants + // --force twice) without touching the directory: a mechanical, transient + // removal failure, which must NOT be reported to the user as "disposable". + sh(`git -C ${repo} worktree lock ${rec.path}`); + + await detect(mrCache("feat-hotel", "opened")); + await detect(mrCache("feat-hotel", "merged")); + + expect(existsSync(rec.path)).toBe(true); + expect(tracked(rec.path)!.state).toBe("claimed"); + expect(tracked(rec.path)!.disposableReason).toBeUndefined(); + expect(events.some((e) => e.type === "worktree:disposable")).toBe(false); + expect(reactorState().mrState[`${repoName}:feat-hotel`]).toBe("opened"); + + sh(`git -C ${repo} worktree unlock ${rec.path}`); + await detect(mrCache("feat-hotel", "merged")); + + expect(existsSync(rec.path)).toBe(false); + expect(tracked(rec.path)).toBeUndefined(); + }); + + test("auto-return gives up (does not spin) when another worktree holds the default branch", async () => { + mainOnBranch("feat-elsewhere"); + // A second worktree parks on main, so `git checkout main` in the main + // clone can never succeed — a configuration, not a transient. + sh(`git -C ${repo} worktree add ${join(repo, ".worktrees", "holder")} main`); + writeFileSync(join(repo, "dirty.txt"), "uncommitted\n"); + + await detect(mrCache("feat-elsewhere", "opened")); + const { log, warns } = capturingLog(); + await detect(mrCache("feat-elsewhere", "merged"), log); + + expect(warns.length).toBe(1); + expect(warns[0]).toContain("main is checked out at"); + expect(await currentBranchAsync(repo)).toBe("feat-elsewhere"); + // The dirt is untouched: nothing may be stashed for a return that cannot happen. + expect(existsSync(join(repo, "dirty.txt"))).toBe(true); + expect(await findDesktopStashAsync(repo, "feat-elsewhere")).toBeNull(); + // Edge is spent, not re-armed — an unfixable config must not retry forever. + expect(reactorState().mrState[`${repoName}:feat-elsewhere`]).toBe("merged"); + }); + + test("auto-return gives up (does not spin) when the default branch cannot be resolved", async () => { + // A repo whose default is "develop": remoteDefaultRef falls back to an + // unverified "origin/master", so the checkout could never succeed. + const odd = realpathSync(mkdtempSync(join(tmpdir(), "rtreact-odd-"))); + sh(`git init -q -b develop ${odd}`); + sh(`git ${GIT_ID} commit -q --allow-empty -m init`, odd); + const bare = join(realpathSync(mkdtempSync(join(tmpdir(), "rtreact-oddbare-"))), "o.git"); + sh(`git clone -q --bare ${odd} ${bare} && git -C ${odd} remote add origin ${bare} && git -C ${odd} fetch -q origin`); + sh(`git ${GIT_ID} checkout -q -b feat-odd origin/develop`, odd); + writeFileSync(join(odd, "dirty.txt"), "uncommitted\n"); + saveRegistry(repoName, [ + { name: "odd", path: odd, kind: "main", branch: "feat-odd", createdAt: new Date().toISOString() }, + ]); + + const pass = (state: string, log: Logger) => + __test__.detectTransitions({ + repoName, + repoPath: odd, + cacheEntries: { "feat-odd": { repoName, mr: { iid: 42, state } } } as any, + emit: (type: string, data: unknown) => events.push({ type, data }), + log, + }); + + await pass("opened", fakeLog()); + const { log, warns } = capturingLog(); + await pass("merged", log); + + expect(warns.length).toBe(1); + expect(warns[0]).toContain("neither master nor origin/master exists"); + expect(await currentBranchAsync(odd)).toBe("feat-odd"); + expect(existsSync(join(odd, "dirty.txt"))).toBe(true); + expect(reactorState().mrState[`${repoName}:feat-odd`]).toBe("merged"); + }); + + test("a successful auto-return leaves main's registry branch on the default", async () => { + mainOnBranch("feat-ground"); + + await detect(mrCache("feat-ground", "opened")); + await detect(mrCache("feat-ground", "merged")); + + expect(tracked(repo)!.branch).toBe("main"); + }); + + test("another repo's snapshot and fired keys survive a single-repo pass", async () => { + writeJson(__test__.reactorStatePath(), { + mrState: { "otherrepo:feat-theirs": "opened" }, + fired: ["disposed:otherrepo:9:merged"], + }); + ephemeralTree("india", "feat-india"); + + await detect(mrCache("feat-india", "opened")); + + expect(reactorState().mrState["otherrepo:feat-theirs"]).toBe("opened"); + expect(reactorState().fired).toContain("disposed:otherrepo:9:merged"); + }); + + test("a cache entry with no repoName joins this repo and acts", async () => { + const rec = ephemeralTree("juliet", "feat-juliet"); + const unattributed = (state: string) => ({ "feat-juliet": { mr: { iid: 42, state } } }); + + await detect(unattributed("opened")); + await detect(unattributed("merged")); + + expect(existsSync(rec.path)).toBe(false); + expect(tracked(rec.path)).toBeUndefined(); + }); + + test('a disposal:"job" tree with a merged MR is untouched', async () => { + const rec = ephemeralTree("echo", "feat-echo", { disposal: "job" }); + + await detect(mrCache("feat-echo", "opened")); + await detect(mrCache("feat-echo", "merged")); + + expect(existsSync(rec.path)).toBe(true); + expect(tracked(rec.path)!.state).toBe("claimed"); + expect(tracked(rec.path)!.disposableReason).toBeUndefined(); + expect(events.length).toBe(0); + }); + + test("cache entries belonging to another repo never join this repo's trees", async () => { + const rec = ephemeralTree("foxtrot", "feat-foxtrot"); + const foreign = { "feat-foxtrot": { repoName: "other", mr: { iid: 42, state: "opened" } } }; + + await detect(foreign); + await detect({ "feat-foxtrot": { repoName: "other", mr: { iid: 42, state: "merged" } } }); + + expect(existsSync(rec.path)).toBe(true); + expect(tracked(rec.path)!.state).toBe("claimed"); + }); + + test("runOnce runs the reactor after the reconcile pass", async () => { + const rec = ephemeralTree("golf", "feat-golf"); + writeJson(join(repoDataDir(repoName), "config.json"), { worktrees: {} }); + + const cache = { entries: mrCache("feat-golf", "opened") as Record }; + const reconciler = createWorktreeReconciler({ + cache, + repoIndex: () => ({ [repoName]: repo }), + emit: (type: string, data: unknown) => events.push({ type, data }), + log: fakeLog(), + }); + + await reconciler.runOnce(); + cache.entries = mrCache("feat-golf", "merged") as Record; + await reconciler.runOnce(); + + expect(existsSync(rec.path)).toBe(false); + expect(events.some((e) => e.type === "worktree:disposed")).toBe(true); + }); +}); + +// ─── Freshen ───────────────────────────────────────────────────────────────── + +/** Clone `repo`'s own bare origin to a scratch dir, for pushing "upstream" advances. */ +function cloneOrigin(repo: string): string { + const originUrl = execSync(`git -C ${repo} remote get-url origin`, { encoding: "utf8" }).trim(); + const clone = realpathSync(mkdtempSync(join(tmpdir(), "rtrecon-clone-"))); + sh(`git clone -q ${originUrl} ${clone}`); + return clone; +} + +function pushFile(clone: string, relPath: string, contents: string): string { + writeFileSync(join(clone, relPath), contents); + sh(`git add -A && git ${GIT_ID} commit -m ${relPath}`, clone); + sh(`git push -q origin main`, clone); + return execSync("git rev-parse HEAD", { cwd: clone, encoding: "utf8" }).trim(); +} + +describe("freshen", () => { + const repoName = "acme"; + let repo: string; + + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtfreshen-home-"))); + __test__.createBackoff.clear(); + repo = makeRepo(); + addBareOrigin(repo); + writeJson(join(rtDir(), "worktrees.json"), { enabled: true, killProcesses: false }); + }); + + test("idle main behind origin gets ff'd; readyStamp advances only when a triggered step ran; worktree:freshened emitted", async () => { + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { ready: [{ run: "touch triggered.marker", when: "changed:*.txt" }] }, + }); + // Tracked and pushed so the ready step's own marker file never shows up as + // untracked dirt on a later pass (which would otherwise flip "idle main" + // non-idle and stall freshen on itself). + writeFileSync(join(repo, ".gitignore"), "*.marker\n"); + sh(`git add -A && git ${GIT_ID} commit -m gitignore`, repo); + sh(`git push -q origin main`, repo); + + saveRegistry(repoName, [ + { name: basename(repo), path: repo, kind: "main", branch: "main", createdAt: new Date().toISOString() }, + ]); + + const clone = cloneOrigin(repo); + const sha1 = pushFile(clone, "feature.txt", "hi\n"); + + const events: Array<{ type: string; data: any }> = []; + await __test__.freshenRepo({ + repoName, + repoPath: repo, + emit: (type: string, data: unknown) => events.push({ type, data }), + log: fakeLog(), + }); + + expect(await headSha(repo)).toBe(sha1); + expect(existsSync(join(repo, "triggered.marker"))).toBe(true); + const rec1 = loadRegistry(repoName).find((t) => t.path === repo)!; + expect(rec1.readyStamp).toBe(sha1); + expect(events.some((e) => e.type === "worktree:freshened")).toBe(true); + + // Second pass: origin advances again, but with a change the glob does not + // match. The ff still moves HEAD; readyStamp must NOT follow it, since no + // step actually validated content as of the new commit. + const sha2 = pushFile(clone, "notes.md", "hi\n"); + await __test__.freshenRepo({ repoName, repoPath: repo, emit: () => {}, log: fakeLog() }); + + expect(await headSha(repo)).toBe(sha2); + const rec2 = loadRegistry(repoName).find((t) => t.path === repo)!; + expect(rec2.readyStamp).toBe(sha1); + }); + + test("on-deck tree ff's its on-deck branch", async () => { + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { onDeck: 1, root: join(repo, ".worktrees") }, + }); + + const created = await createTree({ + repoName, + repoPath: repo, + emit: () => {}, + log: { info: () => {}, warn: () => {} }, + }); + expect(created.ok).toBe(true); + if (!created.ok) return; + const treePath = created.tree.path; + const beforeSha = await headSha(treePath); + + const clone = cloneOrigin(repo); + const sha1 = pushFile(clone, "feature.txt", "hi\n"); + + await __test__.freshenRepo({ repoName, repoPath: repo, emit: () => {}, log: fakeLog() }); + + const afterSha = await headSha(treePath); + expect(afterSha).not.toBe(beforeSha); + expect(afterSha).toBe(sha1); + }); + + test("a failing ready step sets nextRetryAt; the next immediate pass skips the tree", async () => { + const cfgPath = join(repoDataDir(repoName), "config.json"); + writeJson(cfgPath, { worktrees: { onDeck: 1, root: join(repo, ".worktrees") } }); + + const created = await createTree({ + repoName, + repoPath: repo, + emit: () => {}, + log: { info: () => {}, warn: () => {} }, + }); + expect(created.ok).toBe(true); + if (!created.ok) return; + const treePath = created.tree.path; + + // Reconfigure with a step that always fails once triggered. + writeJson(cfgPath, { + worktrees: { onDeck: 1, root: join(repo, ".worktrees"), ready: [{ run: "exit 1", when: "changed:*.txt" }] }, + }); + + const clone = cloneOrigin(repo); + pushFile(clone, "feature.txt", "hi\n"); + + await __test__.freshenRepo({ repoName, repoPath: repo, emit: () => {}, log: fakeLog() }); + + const rec1 = loadRegistry(repoName).find((t) => t.path === treePath)!; + expect(rec1.retryFailures).toBe(1); + expect(rec1.nextRetryAt).toBeDefined(); + expect(Date.parse(rec1.nextRetryAt!)).toBeGreaterThan(Date.now()); + + // Immediate second pass: nextRetryAt is in the future, so the tree must + // be skipped entirely, not retried (and re-failed) again. + await __test__.freshenRepo({ repoName, repoPath: repo, emit: () => {}, log: fakeLog() }); + + const rec2 = loadRegistry(repoName).find((t) => t.path === treePath)!; + expect(rec2.retryFailures).toBe(1); + expect(rec2.nextRetryAt).toBe(rec1.nextRetryAt); + }); + + test("dirty non-idle main is left untouched", async () => { + writeFileSync(join(repo, "dirty.txt"), "uncommitted\n"); + saveRegistry(repoName, [ + { name: basename(repo), path: repo, kind: "main", branch: "main", createdAt: new Date().toISOString() }, + ]); + + const beforeSha = await headSha(repo); + const events: Array<{ type: string; data: any }> = []; + await __test__.freshenRepo({ + repoName, + repoPath: repo, + emit: (type: string, data: unknown) => events.push({ type, data }), + log: fakeLog(), + }); + + expect(await headSha(repo)).toBe(beforeSha); + expect(existsSync(join(repo, "dirty.txt"))).toBe(true); + expect(events.length).toBe(0); + const rec = loadRegistry(repoName).find((t) => t.path === repo)!; + expect(rec.readyAt).toBeUndefined(); + }); + + test("a candidate claimed mid-pass is revalidated under the lock and skipped", async () => { + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { onDeck: 2, root: join(repo, ".worktrees"), ready: [{ run: "sleep 1", when: "changed:*.txt" }] }, + }); + + // Two on-deck trees. `freshenRepo` snapshots the whole registry once and + // processes them in order — A first (slow: its ready step triggers below), + // B only after A finishes. That gap is the window under test. + const a = await createTree({ repoName, repoPath: repo, emit: () => {}, log: { info: () => {}, warn: () => {} } }); + const b = await createTree({ repoName, repoPath: repo, emit: () => {}, log: { info: () => {}, warn: () => {} } }); + expect(a.ok).toBe(true); + expect(b.ok).toBe(true); + if (!a.ok || !b.ok) return; + const pathA = a.tree.path; + const pathB = b.tree.path; + const bBeforeSha = await headSha(pathB); + + const clone = cloneOrigin(repo); + const pushedSha = pushFile(clone, "feature.txt", "hi\n"); + + const events: Array<{ type: string; data: any }> = []; + const freshenPromise = __test__.freshenRepo({ + repoName, + repoPath: repo, + emit: (type: string, data: unknown) => events.push({ type, data }), + log: fakeLog(), + }); + + // Land well inside A's ~1s triggered ready step, long before the loop's + // (already-stale, in-memory) snapshot for B is ever acted on. This proves + // the fix, not the unmodified outer candidacy check: B's candidacy was + // already decided (true, "on-deck") against the pre-claim snapshot before + // this write lands on disk. + await new Promise((r) => setTimeout(r, 300)); + saveRegistry( + repoName, + loadRegistry(repoName).map((t) => (t.path === pathB ? { ...t, state: "claimed" as const } : t)), + ); + + await freshenPromise; + + // A freshened normally — the fix doesn't cost the happy path. + expect(await headSha(pathA)).toBe(pushedSha); + expect(events.some((e) => e.type === "worktree:freshened" && e.data.path === pathA)).toBe(true); + + // B was claimed mid-pass: freshen must not have touched it. + expect(await headSha(pathB)).toBe(bBeforeSha); + expect(events.some((e) => e.data?.path === pathB)).toBe(false); + expect(loadRegistry(repoName).find((t) => t.path === pathB)!.state).toBe("claimed"); + }, 10_000); +}); + +// ─── Replenish / shrink ─────────────────────────────────────────────────────── + +function fakeAppConfig(overrides: Partial = {}): WorktreeAppConfig { + return { enabled: true, killProcesses: false, ...overrides }; +} + +describe("replenish / shrink", () => { + const repoName = "acme"; + let repo: string; + + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtpool-home-"))); + __test__.createBackoff.clear(); + repo = makeRepo(); + addBareOrigin(repo); + }); + + test("onDeck=2 with an empty registry creates 2, serially", async () => { + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { onDeck: 2, root: join(repo, ".worktrees") }, + }); + + await __test__.replenishAndShrink( + { repoName, repoPath: repo, emit: () => {}, log: fakeLog() }, + new Map(), + fakeAppConfig(), + ); + + const trees = loadRegistry(repoName).filter((t) => t.kind === "ephemeral" && t.state === "on-deck"); + expect(trees.length).toBe(2); + expect(new Set(trees.map((t) => t.name)).size).toBe(2); + }); + + test("an all-failing pool does not overshoot the onDeck cap", async () => { + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { onDeck: 2, root: join(repo, ".worktrees"), ready: [{ run: "exit 1" }] }, + }); + + const warns: string[] = []; + const log = { + info: () => {}, + error: () => {}, + debug: () => {}, + warn: (_fields: unknown, msg?: string) => warns.push(msg ?? ""), + } as unknown as Logger; + + await __test__.replenishAndShrink( + { repoName, repoPath: repo, emit: () => {}, log }, + new Map(), + fakeAppConfig(), + ); + + const trees = loadRegistry(repoName).filter((t) => t.kind === "ephemeral"); + expect(trees.length).toBe(0); // the attempt failed and self-scrapped + + // Bounded twice over: never more than onDeck attempts, and the first + // failure's backoff ends the pass before the second is even tried. + expect(warns.filter((w) => w.includes("replenish create failed")).length).toBe(1); + }); + + test("a failed create backs off, and the next pass skips replenish for that repo", async () => { + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { onDeck: 2, root: join(repo, ".worktrees"), ready: [{ run: "exit 1" }] }, + }); + + const warns: string[] = []; + const log = { + info: () => {}, + error: () => {}, + debug: () => {}, + warn: (_fields: unknown, msg?: string) => warns.push(msg ?? ""), + } as unknown as Logger; + const deps = { repoName, repoPath: repo, emit: () => {}, log }; + + await __test__.replenishAndShrink(deps, new Map(), fakeAppConfig()); + expect(warns.filter((w) => w.includes("replenish create failed")).length).toBe(1); + + const backoff = __test__.createBackoff.get(repoName); + expect(backoff?.failures).toBe(1); + expect(Date.parse(backoff!.nextRetryAt)).toBeGreaterThan(Date.now()); + + // The very next pass (the next cache tick, seconds later) must not burn + // another multi-minute build on the same broken step. + await __test__.replenishAndShrink(deps, new Map(), fakeAppConfig()); + expect(warns.filter((w) => w.includes("replenish create failed")).length).toBe(1); + expect(__test__.createBackoff.get(repoName)?.failures).toBe(1); + }); + + test("a successful create clears the backoff", async () => { + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { onDeck: 1, root: join(repo, ".worktrees") }, + }); + // An expired backoff from earlier failures: the pass runs, and success wipes it. + __test__.createBackoff.set(repoName, { + failures: 3, + nextRetryAt: new Date(Date.now() - 1_000).toISOString(), + }); + + await __test__.replenishAndShrink( + { repoName, repoPath: repo, emit: () => {}, log: fakeLog() }, + new Map(), + fakeAppConfig(), + ); + + expect(loadRegistry(repoName).filter((t) => t.state === "on-deck").length).toBe(1); + expect(__test__.createBackoff.has(repoName)).toBe(false); + }); + + test("backoff doubles one pass at a time and caps at 30 minutes", () => { + expect(__test__.backoffDelayMs(1)).toBe(5 * 60_000); + expect(__test__.backoffDelayMs(2)).toBe(10 * 60_000); + expect(__test__.backoffDelayMs(3)).toBe(20 * 60_000); + expect(__test__.backoffDelayMs(4)).toBe(30 * 60_000); // 40 min, capped + expect(__test__.backoffDelayMs(12)).toBe(30 * 60_000); + }); + + test("lowering onDeck disposes the stalest ready entry", async () => { + const cfgPath = join(repoDataDir(repoName), "config.json"); + writeJson(cfgPath, { worktrees: { onDeck: 2, root: join(repo, ".worktrees") } }); + + await __test__.replenishAndShrink( + { repoName, repoPath: repo, emit: () => {}, log: fakeLog() }, + new Map(), + fakeAppConfig(), + ); + + let trees = loadRegistry(repoName).filter((t) => t.kind === "ephemeral" && t.state === "on-deck"); + expect(trees.length).toBe(2); + + // Force a deterministic staleness ordering rather than relying on the + // sub-millisecond gap between two serial creates. + const [older, newer] = trees; + saveRegistry( + repoName, + loadRegistry(repoName).map((t) => { + if (t.path === older!.path) return { ...t, readyAt: new Date(Date.now() - 60_000).toISOString() }; + if (t.path === newer!.path) return { ...t, readyAt: new Date().toISOString() }; + return t; + }), + ); + + writeJson(cfgPath, { worktrees: { onDeck: 1, root: join(repo, ".worktrees") } }); + await __test__.replenishAndShrink( + { repoName, repoPath: repo, emit: () => {}, log: fakeLog() }, + new Map(), + fakeAppConfig(), + ); + + trees = loadRegistry(repoName).filter((t) => t.kind === "ephemeral" && t.state === "on-deck"); + expect(trees.length).toBe(1); + expect(trees[0]!.path).toBe(newer!.path); + expect(existsSync(older!.path)).toBe(false); + }); +}); + +// ─── Detached trigger / latency ─────────────────────────────────────────────── + +async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor: timed out"); + await new Promise((r) => setTimeout(r, 25)); + } +} + +describe("detached trigger / latency", () => { + test("kick() returns synchronously, coalesces a second kick during the pass, and creationInFlight tracks it", async () => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtkick-home-"))); + __test__.createBackoff.clear(); + const repoName = "acme"; + const repo = makeRepo(); + addBareOrigin(repo); + // Non-idle main (real, unrelated dirt): keeps freshen from also picking + // up main and running its own "sleep 3" pass, which would confound the + // timing assertions below without changing what's under test here. + writeFileSync(join(repo, "wip.txt"), "not idle\n"); + + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { onDeck: 1, root: join(repo, ".worktrees"), ready: [{ run: "sleep 3" }] }, + }); + + const events: Array<{ type: string; data: any }> = []; + const reconciler = createWorktreeReconciler({ + cache: { entries: {} }, + repoIndex: () => ({ [repoName]: repo }), + emit: (type: string, data: unknown) => events.push({ type, data }), + log: fakeLog(), + }); + + const t0 = Date.now(); + reconciler.kick(); + const elapsed = Date.now() - t0; + expect(elapsed).toBeLessThan(500); // kick() itself never awaits the pass + + await waitFor(() => reconciler.creationInFlight(repoName) !== null, 2000); + expect(reconciler.creationInFlight(repoName)).not.toBeNull(); + + reconciler.kick(); // overlap guard: must not start a second concurrent pass + + await waitFor(() => reconciler.creationInFlight(repoName) === null, 6000); + expect(reconciler.creationInFlight(repoName)).toBeNull(); + + expect(events.filter((e) => e.type === "worktree:created").length).toBe(1); + }, 10_000); +}); diff --git a/lib/daemon/cache-refresh.ts b/lib/daemon/cache-refresh.ts index 50e01734..7fd1c7a2 100644 --- a/lib/daemon/cache-refresh.ts +++ b/lib/daemon/cache-refresh.ts @@ -22,7 +22,6 @@ import { loadRepoTracking, grants } from "../repo-tracking.ts"; import { syncProjectMRs } from "./project-sync.ts"; import { getProjectMRs } from "./project-mrs-store.ts"; import { pruneDiscussionsStore } from "./discussions-file-store.ts"; -import { checkAndPark } from "./parking-lot.ts"; import { reconcileForRepo } from "./doppler-sync.ts"; import { listWorktreeRoots, listWorktrees } from "../git-worktrees.ts"; @@ -38,6 +37,8 @@ export interface CacheRefresherDeps { statusSnapshot: () => Promise; /** Reconcile freshness watchers against the freshly-loaded repo index. */ reconcileSubscriptions: () => Promise; + /** Fire-and-forget kick of the worktree reconciler after each refresh. */ + worktreeKick?: () => void; } export function createCacheRefresher(deps: CacheRefresherDeps): () => Promise { @@ -82,9 +83,12 @@ export function createCacheRefresher(deps: CacheRefresherDeps): () => Promise = - listWorktrees(repoPath).filter((w) => w.branch); + // 1. Discover worktree branches (detached worktrees have no branch). + // on-deck/* branches are pool plumbing, not feature work — never + // worth MR/Linear enrichment. + const branches: Array<{ path: string; branch: string }> = listWorktrees(repoPath).filter( + (w) => w.branch && !w.branch.startsWith("on-deck/"), + ); // 2. Discover local branches (not just worktrees) const worktreeBranchSet = new Set(branches.map(b => b.branch)); @@ -96,7 +100,7 @@ export function createCacheRefresher(deps: CacheRefresherDeps): () => Promise Promise/doppler-template.yaml. Cheap (file I/O // only) and additive — never overwrites existing entries. @@ -179,6 +176,11 @@ export function createCacheRefresher(deps: CacheRefresherDeps): () => Promise { diff --git a/lib/daemon/command-router.ts b/lib/daemon/command-router.ts index 400fab63..9b9c0811 100644 --- a/lib/daemon/command-router.ts +++ b/lib/daemon/command-router.ts @@ -12,7 +12,7 @@ import { createHooksHandlers } from "./handlers/hooks.ts"; import { createStatusHandlers } from "./handlers/status.ts"; import { createWorkspaceHandlers } from "./handlers/workspace.ts"; import { createMRHandlers } from "./handlers/mr.ts"; -import { createParkingLotHandlers } from "./handlers/parking-lot.ts"; +import { createWorktreeHandlers, type WorktreeHandlerOpts } from "./handlers/worktree.ts"; import { createDiscussionHandlers } from "./handlers/discussions.ts"; import { createSystemProcessHandlers } from "./handlers/system-processes.ts"; import { createSdmHandlers } from "./handlers/sdm.ts"; @@ -30,6 +30,8 @@ export function buildRoutedHandlers(opts: { ctx: HandlerContext; broadcast: (type: string, data: any) => void; systemProcessScanner: SystemProcessScanner; + /** Reconciler seams the worktree verbs drive (spec §7): claim events, replenish kicks, in-flight creates. */ + worktree: WorktreeHandlerOpts; }): TypedHandlers & HandlerMap { const { ctx, broadcast, systemProcessScanner } = opts; return { @@ -38,7 +40,7 @@ export function buildRoutedHandlers(opts: { ...createStatusHandlers(ctx), ...createWorkspaceHandlers(ctx), ...createMRHandlers(ctx, broadcast), - ...createParkingLotHandlers(ctx), + ...createWorktreeHandlers(ctx, opts.worktree), ...createDiscussionHandlers(ctx, broadcast), ...createSystemProcessHandlers(systemProcessScanner, ctx), ...createSdmHandlers(ctx), diff --git a/lib/daemon/handlers/parking-lot.ts b/lib/daemon/handlers/parking-lot.ts deleted file mode 100644 index f43c47b1..00000000 --- a/lib/daemon/handlers/parking-lot.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Parking-lot IPC handlers. - * - * parking-lot:scan — run the auto-park check immediately. - * parking-lot:park-this — park a specific worktree on demand. The CLI - * routes manual `rt park this` through here so the - * caller can animate a spinner while awaiting the - * result (the work itself is execSync-blocking). - */ - -import { checkAndPark, park } from "../parking-lot.ts"; -import { loadParkingLotConfig } from "../../parking-lot-config.ts"; -import type { HandlerContext, HandlerMap } from "./types.ts"; - -export function createParkingLotHandlers(ctx: HandlerContext): HandlerMap { - return { - "parking-lot:scan": async () => { - try { - checkAndPark({ cache: ctx.cache, repoIndex: ctx.repoIndex }); - return { ok: true, data: { lines: [] } }; - } catch (err) { - return { ok: false, error: String(err) }; - } - }, - - "parking-lot:park-this": async (payload: any) => { - const { worktreePath, repoPath, branch, index } = payload ?? {}; - // `branch` is intentionally optional — null/undefined means the worktree - // is detached (a warm-pool entry being claimed onto its slot). - if (!worktreePath || !repoPath || typeof index !== "number") { - return { ok: false, error: "missing payload fields" }; - } - - try { - const result = park(worktreePath, repoPath, branch ?? null, index, { - killProcesses: loadParkingLotConfig().killProcesses, - }); - return { ok: true, data: { result, lines: [] } }; - } catch (err) { - return { ok: false, error: String(err) }; - } - }, - }; -} diff --git a/lib/daemon/handlers/worktree.ts b/lib/daemon/handlers/worktree.ts new file mode 100644 index 00000000..20867706 --- /dev/null +++ b/lib/daemon/handlers/worktree.ts @@ -0,0 +1,626 @@ +/** + * Worktree lifecycle IPC verbs (spec §3): provision, create, dispose, list, + * freshen, adopt. Every mutation the CLI and the skills perform goes through + * here, which is what makes the daemon the single writer of the registry. + * + * The verbs are thin: the intelligence lives in lib/worktree/* (create, + * dispose guard, locks, branch naming) and lib/daemon/worktree-reconciler.ts + * (reconcile, freshen). What this module owns is the ORDER those pieces run + * in, which is load-bearing for provision (§7): + * + * 1. resolve intent and fire every registry-decidable refusal BEFORE any + * tree is touched — an expected "resume there?" refusal must never + * strand a claimed tree; + * 2. select / create the tree; + * 3. claim it under its lock; + * 4. check the work branch out per the resolution matrix; + * 5. roll back on any failure after the claim — reverted to on-deck when the + * tree never left its `on-deck/` branch, disposable (with the + * failure as the reason) when it did. No limbo states. + * + * Handler outcomes are `{ok:true, data}` / `{ok:false, error}` with typed + * refusal strings; daemon.ts's handleCommand does the outcome logging, so + * nothing here logs request/response. + */ + +import { realpathSync, rmSync } from "fs"; +import { join } from "path"; + +import type { HandlerContext, HandlerMap } from "./types.ts"; +import { + findByBranch, + loadRegistry, + saveRegistry, + type DisposalMode, + type TreeRecord, +} from "../../worktree/registry.ts"; +import { disambiguate, slugifyTicketTitle } from "../../worktree/branch-name.ts"; +import { createTree } from "../../worktree/create.ts"; +import { classifyDirtyAsync, disposeTree, type DisposeDeps } from "../../worktree/dispose.ts"; +import { isTreeLocked, withTreeLock } from "../../worktree/locks.ts"; +import { + branchExistsLocalAsync, + currentBranchAsync, + remoteDefaultRef, + remoteRefExists, + runGit, +} from "../../worktree/git-async.ts"; +import { + loadWorktreeAppConfig, + loadWorktreeRepoConfig, + resolveReadySteps, +} from "../../worktree/config.ts"; +import { changedSince, runReadySteps, stepsToRun } from "../../worktree/ready.ts"; +import { freshenRepo, reconcileRepoRegistry } from "../worktree-reconciler.ts"; +import { repoDataDir, rtDir } from "../../rt-paths.ts"; + +const PROVISION_FETCH_TIMEOUT_MS = 5 * 60_000; + +/** git's ref-not-found signature — the ONE fetch failure that means "no such remote branch". */ +const NO_REMOTE_REF_RE = /couldn't find remote ref/i; + +const PARKING_LOT_BRANCH_RE = /^parking-lot\/\d+$/; + +/** Lines of failed-step output carried into a `create-failed:` refusal. */ +const CREATE_FAILED_TAIL_LINES = 10; + +export type BranchState = "new" | "tracking-remote" | "existing-clean" | "diverged" | "behind"; + +export interface WorktreeHandlerOpts { + /** Broadcast bus (daemon's broadcast+cron composite). */ + emit: (type: string, data: unknown) => void; + /** Ask the reconciler for a pass (replenish after a claim / disposal). */ + kick: () => void; + /** The live replenish create for a repo, or null; provision joins it rather than racing it. */ + creationInFlight: (repoName: string) => Promise | null; +} + +// ─── Small shared helpers ──────────────────────────────────────────────────── + +/** realpathSync defensively; a path that doesn't exist compares as-is. */ +function canon(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +function patchTree(repoName: string, path: string, patch: (rec: TreeRecord) => void): void { + const trees = loadRegistry(repoName); + const rec = trees.find((t) => t.path === path); + if (!rec) return; + patch(rec); + saveRegistry(repoName, trees); +} + +/** + * `create-failed:` carrying the tail of that step's output, same shape as + * `checkout-failed:`. The step name alone tells the caller which install + * died but nothing about why, and the output is not otherwise reachable from the + * CLI. + */ +function createFailedError(created: { failedStep?: string; output?: string }): string { + const step = created.failedStep ?? "unknown"; + const tail = (created.output ?? "") + .trim() + .split("\n") + .slice(-CREATE_FAILED_TAIL_LINES) + .join("\n") + .trim(); + return tail.length > 0 ? `create-failed:${step}\n${tail}` : `create-failed:${step}`; +} + +/** Every local branch name in the repo, for the sync `exists()` disambiguation predicate. */ +async function localBranchNames(repoPath: string): Promise> { + const r = await runGit(repoPath, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]); + return new Set( + r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0), + ); +} + +/** Repos this payload targets: the named one, or every repo in the index. */ +function targetRepos(ctx: HandlerContext, repoName?: string): Array<[string, string]> { + const index = ctx.repoIndex(); + if (repoName) { + const path = index[repoName]; + return path ? [[repoName, path]] : []; + } + return Object.entries(index); +} + +function disposeDeps( + ctx: HandlerContext, + opts: WorktreeHandlerOpts, + repoName: string, + repoPath: string, +): DisposeDeps { + return { + repoName, + repoPath, + cacheEntries: ctx.cache.entries as DisposeDeps["cacheEntries"], + emit: opts.emit, + log: ctx.log, + killProcesses: loadWorktreeAppConfig().killProcesses, + }; +} + +// ─── Provision (spec §7) ───────────────────────────────────────────────────── + +/** + * Best on-deck tree: freshest first (`readyAt` desc, `createdAt` desc as the + * tie-break), skipping trees another operation holds the lock on and trees + * whose last freshen failed (they are inside their retry backoff and are not + * "ready" in the sense provision needs). + */ +function selectOnDeck(repoName: string): TreeRecord | undefined { + const now = Date.now(); + const stamp = (t: TreeRecord): number => Date.parse(t.readyAt ?? "") || 0; + return loadRegistry(repoName) + .filter( + (t) => + t.kind === "ephemeral" && + t.state === "on-deck" && + !isTreeLocked(t.path) && + (!t.nextRetryAt || Date.parse(t.nextRetryAt) <= now), + ) + .sort((a, b) => stamp(b) - stamp(a) || Date.parse(b.createdAt) - Date.parse(a.createdAt))[0]; +} + +/** Ahead/behind counts of `branch` vs its `origin/` counterpart. */ +async function divergence( + treePath: string, + branch: string, +): Promise<{ ahead: number; behind: number } | null> { + const r = await runGit(treePath, [ + "rev-list", "--left-right", "--count", `${branch}...origin/${branch}`, + ]); + if (r.exitCode !== 0) return null; + const [ahead, behind] = r.stdout.trim().split(/\s+/).map((n) => Number.parseInt(n, 10)); + if (!Number.isFinite(ahead!) || !Number.isFinite(behind!)) return null; + return { ahead: ahead!, behind: behind! }; +} + +/** + * Whether the entry a provision selected may still be claimed by it. + * + * ONLY `on-deck` qualifies. `claimed` is exactly the state that means another + * provision won the race: accepting it would overwrite that caller's owner and + * disposal mode and re-checkout their tree underneath them. `creating` and + * `disposable` are equally not ours to take, and a vanished entry means the + * reconciler pruned the tree while we were selecting it. + */ +export function isClaimable(rec: TreeRecord | undefined): boolean { + return rec !== undefined && rec.kind === "ephemeral" && rec.state === "on-deck"; +} + +export function createWorktreeHandlers( + ctx: HandlerContext, + opts: WorktreeHandlerOpts, +): HandlerMap { + /** + * Undo a claim that could not be completed. Still on its `on-deck/` + * branch → the tree is untouched and goes back in the pool; already moved + * off it → it is no longer a pool tree, so it becomes disposable carrying + * the failure as its reason (never left claimed-but-unusable). + */ + async function rollbackClaim( + repoName: string, + rec: TreeRecord, + onDeckBranch: string | null, + reason: string, + ): Promise { + const current = await currentBranchAsync(rec.path); + if (onDeckBranch && current === onDeckBranch) { + patchTree(repoName, rec.path, (r) => { + r.state = "on-deck"; + r.branch = onDeckBranch; + delete r.owner; + delete r.disposal; + delete r.claimedAt; + }); + return; + } + patchTree(repoName, rec.path, (r) => { + r.state = "disposable"; + r.branch = current; + r.disposableReason = reason; + }); + opts.emit("worktree:disposable", { + repo: repoName, tree: rec.name, path: rec.path, branch: current, reason, + }); + } + + return { + "worktree:provision": async (payload: any) => { + const repoName: string | undefined = payload?.repoName; + const repoPath = repoName ? ctx.repoIndex()[repoName] : undefined; + if (!repoName || !repoPath) return { ok: false, error: "repo-unknown" }; + + const cfg = loadWorktreeRepoConfig(repoName, repoPath); + const trees = loadRegistry(repoName); + + // ── 1. Intent + every registry-decidable refusal, before any tree moves. + let branch: string; + if (typeof payload.branch === "string" && payload.branch.length > 0) { + branch = payload.branch; + } else if (typeof payload.ticket === "string" && payload.ticket.length > 0) { + const base = slugifyTicketTitle( + payload.ticket, + typeof payload.ticketTitle === "string" ? payload.ticketTitle : "", + cfg.branchFormat, + ).replace(/-+$/, ""); + const local = await localBranchNames(repoPath); + const registered = new Set( + trees.map((t) => t.branch).filter((b): b is string => typeof b === "string"), + ); + branch = disambiguate(base, (candidate) => local.has(candidate) || registered.has(candidate)); + } else { + return { ok: false, error: "branch-unresolved" }; + } + + const attached = findByBranch(trees, branch); + if (attached.length > 1) return { ok: false, error: "branch-duplicated" }; + if (attached.length === 1) return { ok: false, error: `branch-attached:${attached[0]!.name}` }; + + // ── 2. Selection, then the pool's own in-flight create, then cold create. + let rec = selectOnDeck(repoName); + let wasOnDeck = true; + if (!rec) { + const inFlight = opts.creationInFlight(repoName); + if (inFlight) { + await inFlight; + rec = selectOnDeck(repoName); + // Joining a replenish build is still a build the caller waited for, + // so it is reported as a cold create, not as a warm pool hit. + if (rec) wasOnDeck = false; + } + } + if (!rec) { + const created = await createTree({ + repoName, repoPath, emit: opts.emit, log: ctx.log, + }); + if (!created.ok) { + if (created.error === "busy") return { ok: false, error: "busy" }; + return { ok: false, error: createFailedError(created) }; + } + rec = created.tree; + wasOnDeck = false; + } + + const tree = rec; + const onDeckBranch = tree.branch; + + const outcome = await withTreeLock(tree.path, async (): Promise< + { ok: true; data: any } | { ok: false; error: string } + > => { + // The registry may have moved between selection and the lock. + const fresh = loadRegistry(repoName).find((t) => t.path === tree.path); + if (!isClaimable(fresh)) return { ok: false, error: "busy" }; + + // ── 3. Claim. `branch` is deliberately NOT written here: reconcile + // step (c) owns that field as git ground truth and would reset it to + // `on-deck/` on its next pass anyway, so recording the work + // branch before the checkout only invents a fact git disagrees with. + // The window that leaves open (another provision naming the same + // branch between claim and checkout) is closed by git itself — the + // second checkout fails with "already checked out", which rolls that + // caller back rather than handing two trees the same branch. + const disposal: DisposalMode = payload.disposal === "job" ? "job" : "merge"; + patchTree(repoName, tree.path, (r) => { + r.state = "claimed"; + r.disposal = disposal; + r.claimedAt = new Date().toISOString(); + if (typeof payload.owner === "string" && payload.owner.length > 0) r.owner = payload.owner; + }); + opts.emit("worktree:claimed", { + repo: repoName, tree: tree.name, branch, owner: payload.owner ?? null, + }); + + // ── 4. Branch resolution matrix, against a fresh targeted fetch. + const fetch = await runGit(tree.path, ["fetch", "origin", branch], { + timeoutMs: PROVISION_FETCH_TIMEOUT_MS, + }); + let remoteHasBranch = true; + if (fetch.exitCode !== 0) { + const detail = (fetch.stderr + fetch.stdout).trim(); + // The two non-zero outcomes mean opposite things: only git's + // ref-not-found signature means "no such branch upstream". An + // unreachable/auth-failed origin must NOT be read as absence — that + // would silently shadow a teammate's branch with an empty one. + if (!NO_REMOTE_REF_RE.test(detail)) { + await rollbackClaim(repoName, tree, onDeckBranch, `checkout-failed:${detail}`); + return { ok: false, error: `checkout-failed:${detail}` }; + } + remoteHasBranch = false; + } + + const localExists = await branchExistsLocalAsync(tree.path, branch); + let branchState: BranchState; + let checkout; + + if (localExists) { + // (c)/(e) local branch wins, checked out untouched — never auto-ff, + // never reset; divergence is reported, not reconciled. + checkout = await runGit(tree.path, ["checkout", branch]); + branchState = "existing-clean"; + if (checkout.exitCode === 0 && (await remoteRefExists(tree.path, branch))) { + const counts = await divergence(tree.path, branch); + if (counts && counts.ahead > 0 && counts.behind > 0) branchState = "diverged"; + else if (counts && counts.behind > 0) branchState = "behind"; + } + } else if (remoteHasBranch) { + // (b) remote-only: base on the remote tip so a teammate's commits + // survive. `origin/` normally exists after the targeted + // fetch (standard refspec); FETCH_HEAD is the fallback when it does not. + const startPoint = (await remoteRefExists(tree.path, branch)) + ? `origin/${branch}` + : "FETCH_HEAD"; + checkout = await runGit(tree.path, ["checkout", "-b", branch, startPoint]); + branchState = "tracking-remote"; + } else { + // (a) nowhere: cut it from the default branch. + const defaultRef = await remoteDefaultRef(tree.path); + checkout = await runGit(tree.path, ["checkout", "-b", branch, defaultRef]); + branchState = "new"; + } + + if (checkout.exitCode !== 0) { + const detail = (checkout.stderr + checkout.stdout).trim(); + await rollbackClaim(repoName, tree, onDeckBranch, `checkout-failed:${detail}`); + return { ok: false, error: `checkout-failed:${detail}` }; + } + + // The pool branch has served its purpose; the tree now carries the work branch. + if (onDeckBranch && onDeckBranch.startsWith("on-deck/")) { + await runGit(tree.path, ["branch", "-D", onDeckBranch]); + } + patchTree(repoName, tree.path, (r) => { r.branch = branch; }); + + // Re-verify readiness: normally every `when` trigger no-ops, but a + // default branch that moved (or a teammate branch just checked out) + // runs the delta. A failing step does NOT destroy the claimed tree — + // the caller has it and can fix it; readyAt simply doesn't advance. + const readySteps = resolveReadySteps(cfg, repoPath); + const stamp = loadRegistry(repoName).find((t) => t.path === tree.path)?.readyStamp; + const changed = stamp ? await changedSince(tree.path, stamp) : null; + const toRun = stepsToRun(readySteps, changed); + let readyFailure: string | null = null; + if (toRun.length > 0) { + const ready = await runReadySteps(tree.path, toRun); + if (!ready.ok) { + readyFailure = ready.failedStep; + ctx.log.warn( + { repo: repoName, tree: tree.name, failedStep: ready.failedStep }, + "provision: ready step failed after claim", + ); + } else { + patchTree(repoName, tree.path, (r) => { r.readyAt = new Date().toISOString(); }); + } + } + + const final = loadRegistry(repoName).find((t) => t.path === tree.path); + return { + ok: true, + data: { + tree: tree.name, + path: tree.path, + branch, + wasOnDeck, + readyAt: final?.readyAt ?? null, + branchState, + // Additive, and only present when it happened: the tree is usable + // and handed over, but its dependencies may be stale. + ...(readyFailure ? { readyFailed: true as const, failedStep: readyFailure } : {}), + }, + }; + }); + + if (outcome === "busy") return { ok: false, error: "busy" }; + // Claiming shrinks the pool: ask for a replenish pass now rather than + // waiting for the next tick. + if (outcome.ok) opts.kick(); + return outcome; + }, + + "worktree:create": async (payload: any) => { + const repoName: string | undefined = payload?.repoName; + const repoPath = repoName ? ctx.repoIndex()[repoName] : undefined; + if (!repoName || !repoPath) return { ok: false, error: "repo-unknown" }; + + const created = await createTree({ repoName, repoPath, emit: opts.emit, log: ctx.log }); + if (!created.ok) { + if (created.error === "busy") return { ok: false, error: "busy" }; + return { ok: false, error: createFailedError(created) }; + } + + // Default is a tree for the caller to use; `--on-deck` puts it in the + // pool instead. A claimed create keeps its `on-deck/` branch (no + // work branch was named) but is no longer claimable by provision. + if (payload?.onDeck !== true) { + patchTree(repoName, created.tree.path, (r) => { + r.state = "claimed"; + r.claimedAt = new Date().toISOString(); + }); + } + + return { ok: true, data: { tree: created.tree.name, path: created.tree.path } }; + }, + + "worktree:dispose": async (payload: any) => { + const owner: string | undefined = typeof payload?.owner === "string" ? payload.owner : undefined; + const treeName: string | undefined = typeof payload?.tree === "string" ? payload.tree : undefined; + const force = payload?.force === true; + + // `--owner` sweeps globally by default (a run may span repos); `--repo` + // narrows it. A named tree always needs its repo. + const repos = targetRepos(ctx, payload?.repoName); + if (repos.length === 0) return { ok: false, error: "repo-unknown" }; + if (!owner && !treeName) return { ok: false, error: "no-target" }; + + const targets: Array<{ repoName: string; repoPath: string; rec: TreeRecord }> = []; + for (const [name, path] of repos) { + for (const rec of loadRegistry(name).filter((t) => (owner ? t.owner === owner : t.name === treeName))) { + targets.push({ repoName: name, repoPath: path, rec }); + } + } + + // A bare tree name that two repos both answer to is not a target rt gets + // to guess at — disposal is the destructive verb (honesty over magic). + if (!owner && targets.length > 1) return { ok: false, error: "tree-ambiguous" }; + + const disposed: string[] = []; + const refused: Array<{ tree: string; reason: string }> = []; + + for (const { repoName, repoPath, rec } of targets) { + const deps = disposeDeps(ctx, opts, repoName, repoPath); + const outcome = await withTreeLock(rec.path, () => + disposeTree(deps, rec, { force, auto: false }), + ); + if (outcome === "busy") refused.push({ tree: rec.name, reason: "busy" }); + else if (outcome.disposed) disposed.push(rec.name); + else refused.push({ tree: rec.name, reason: outcome.refusal }); + } + + if (targets.length === 0 && treeName) refused.push({ tree: treeName, reason: "unknown" }); + if (disposed.length > 0) opts.kick(); + + return { ok: true, data: { disposed, refused } }; + }, + + "worktree:list": async (payload: any) => { + const repos = targetRepos(ctx, payload?.repoName); + if (repos.length === 0 && payload?.repoName) return { ok: false, error: "repo-unknown" }; + + const entries = ctx.cache.entries; + const rows: Array> = []; + + for (const [repoName] of repos) { + const trees = loadRegistry(repoName); + const branchCounts = new Map(); + for (const t of trees) { + if (t.branch) branchCounts.set(t.branch, (branchCounts.get(t.branch) ?? 0) + 1); + } + + for (const t of trees) { + // The join key is (repoName, branch): a bare-branch join would hand + // a tree another repo's MR when both repos use the same name. + const entry = t.branch ? entries[t.branch] : undefined; + const mr = + entry?.mr && (!entry.repoName || entry.repoName === repoName) + ? { iid: entry.mr.iid, state: entry.mr.state, title: entry.mr.title } + : null; + rows.push({ + ...t, + repoName, + mr, + ...(t.branch && (branchCounts.get(t.branch) ?? 0) > 1 ? { duplicateBranch: true as const } : {}), + }); + } + } + + return { ok: true, data: { trees: rows } }; + }, + + "worktree:freshen": async (payload: any) => { + const treeName: string | undefined = typeof payload?.tree === "string" ? payload.tree : undefined; + const repos = targetRepos(ctx, payload?.repoName); + if (repos.length === 0) return { ok: false, error: "repo-unknown" }; + + const ran: string[] = []; + for (const [repoName, repoPath] of repos) { + const names = await freshenRepo( + { repoName, repoPath, emit: opts.emit, log: ctx.log }, + treeName ? { only: treeName } : {}, + ); + ran.push(...names); + } + return { ok: true, data: { ran } }; + }, + + /** + * One-shot migration sweep (spec §11.2): whatever the repo already has + * becomes registry truth. Reconcile first so every worktree on disk has an + * entry, then classify: the main clone stays main, clean `parking-lot/N` + * trees are disposed through the normal guard (no-MR anchor) with their + * branches, and every other tree is an occupied ephemeral — claimed on the + * branch it is already sitting on. + */ + "worktree:adopt": async (payload: any) => { + const repoName: string | undefined = payload?.repoName; + const repoPath = repoName ? ctx.repoIndex()[repoName] : undefined; + if (!repoName || !repoPath) return { ok: false, error: "repo-unknown" }; + + // Repo-wide lock: adopt rewrites every entry, so no per-tree operation + // may interleave with it. Synthetic key (no tree lives at this path). + const result = await withTreeLock(`${repoPath}#adopt`, async () => { + const trees = await reconcileRepoRegistry({ + repoName, repoPath, emit: opts.emit, log: ctx.log, + }); + + let main = ""; + const claimed: string[] = []; + const disposed: string[] = []; + const refused: Array<{ tree: string; reason: string }> = []; + + for (const rec of trees) { + if (rec.kind === "main" || canon(rec.path) === canon(repoPath)) { + if (rec.kind !== "main") patchTree(repoName, rec.path, (r) => { r.kind = "main"; }); + main = rec.name; + continue; + } + // Trees rt already manages are left exactly as they are. + if (rec.kind === "ephemeral") continue; + + const parked = + rec.branch !== null && + PARKING_LOT_BRANCH_RE.test(rec.branch) && + (await classifyDirtyAsync(rec.path, repoName)).blockers.length === 0; + + if (parked) { + // Ephemeral+claimed first: the guard only ever deletes rt's own + // trees, so the entry has to say so before disposal is even legal. + patchTree(repoName, rec.path, (r) => { + r.kind = "ephemeral"; + r.state = "claimed"; + // Same shape as the else-branch below, so a tree the guard + // refuses is left a plain adopted claimed tree, not a hybrid. + r.disposal = "merge"; + r.claimedAt = new Date().toISOString(); + }); + const deps = disposeDeps(ctx, opts, repoName, repoPath); + const outcome = await withTreeLock(rec.path, () => + disposeTree(deps, { ...rec, kind: "ephemeral", state: "claimed" }, { auto: false }), + ); + if (outcome === "busy") refused.push({ tree: rec.name, reason: "busy" }); + else if (outcome.disposed) disposed.push(rec.name); + else refused.push({ tree: rec.name, reason: outcome.refusal }); + continue; + } + + patchTree(repoName, rec.path, (r) => { + r.kind = "ephemeral"; + r.state = "claimed"; + r.disposal = "merge"; + r.claimedAt = new Date().toISOString(); + }); + claimed.push(rec.name); + } + + return { ok: true as const, data: { main, claimed, disposed, refused } }; + }); + + if (result === "busy") return { ok: false, error: "busy" }; + if (result.ok) { + // Adopt supersedes the parking lot: its per-repo index and app-level + // transition state are dead once every tree is registry-tracked. The + // app CONFIG file (~/.rt/parking-lot.json, no "-state" suffix) is left + // alone — loadWorktreeAppConfig still compat-reads it once to seed + // worktrees.json. + rmSync(join(repoDataDir(repoName), "parking-lot.json"), { force: true }); + rmSync(join(rtDir(), "parking-lot-state.json"), { force: true }); + } + return result; + }, + }; +} diff --git a/lib/daemon/parking-lot.ts b/lib/daemon/parking-lot.ts deleted file mode 100644 index b8b22094..00000000 --- a/lib/daemon/parking-lot.ts +++ /dev/null @@ -1,634 +0,0 @@ -/** - * Parking-lot subsystem for the rt daemon. - * - * When a worktree's branch has an MR that transitions `opened → merged|closed`, - * this module "parks" the worktree: stash any dirty tree, check out the - * worktree's bound `parking-lot/` branch (creating it from origin/master if - * absent), then fast-forward that branch to the remote default branch. - * - * Worktree → parking-lot index mapping is per-repo, 1-based, primary worktree - * first, persisted at `~/.rt/repos//parking-lot.json` so indexes stay - * stable across worktree adds/removes. New worktrees claim the next unused - * positive integer. - * - * Transition detection piggybacks on the cache refresh (same `mr.state` - * signals the notifier uses). We keep our own state file so we only act once - * per MR and never park on a cold-boot `merged` cache entry. - */ - -import { execFileSync, execSync } from "child_process"; -import { existsSync } from "fs"; -import { join } from "path"; - -import { getDaemonLogger } from "../daemon-logger.ts"; -import { RT_DIR } from "../daemon-config.ts"; -import { repoDataDir } from "../rt-paths.ts"; -import { readJson, writeJson } from "../json-store.ts"; -import { - findDesktopStash, - getCurrentBranch, - getRemoteDefaultBranch, - hasUncommittedChanges, - popStash, - stashChanges, -} from "../git-ops.ts"; -import { loadParkingLotConfig } from "../parking-lot-config.ts"; -import { loadSyncConfig, matchRule } from "../sync-config.ts"; -// Namespace import so tests can spy on the kill step. -import * as wtKill from "./worktree-process-kill.ts"; -import type { CacheEntry, RepoIndex } from "./handlers/types.ts"; - -const log = (await getDaemonLogger()).childLogger("parking-lot"); - -// ─── Persistence ───────────────────────────────────────────────────────────── - -const STATE_PATH = join(RT_DIR, "parking-lot-state.json"); - -interface ParkingLotState { - /** Last-seen MR state per branch (keyed exactly like the cache). */ - mrState: Record; - /** Keys we've already parked on, to avoid re-running if cache churns. */ - fired: string[]; -} - -function loadState(): ParkingLotState { - const raw = readJson>(STATE_PATH, {}); - return { - mrState: raw?.mrState ?? {}, - fired: Array.isArray(raw?.fired) ? raw.fired : [], - }; -} - -function saveState(state: ParkingLotState): void { - try { - writeJson(STATE_PATH, state); - } catch (err) { - log.debug({ err }, "failed to persist parking-lot state (best-effort)"); - } -} - -// ─── Worktree → index mapping (per repo) ───────────────────────────────────── - -interface IndexMap { [worktreePath: string]: number; } - -function indexFilePath(repoName: string): string { - return join(repoDataDir(repoName), "parking-lot.json"); -} - -function loadIndexMap(repoName: string): IndexMap { - const raw = readJson<{ indexes?: IndexMap }>(indexFilePath(repoName), {}); - return raw?.indexes ?? {}; -} - -function saveIndexMap(repoName: string, indexes: IndexMap): void { - try { - writeJson(indexFilePath(repoName), { indexes }); - } catch (err) { - log.debug({ err }, "failed to persist parking-lot index map (best-effort)"); - } -} - -/** - * Reconcile the on-disk index map with the current `git worktree list`. - * Primary (listed first by git) gets 1 if unassigned; others claim the next - * unused positive integer in listing order. Existing entries are preserved - * so indexes stay stable if a middle worktree is removed. - */ -function reconcileIndexMap(repoName: string, worktreePaths: string[]): IndexMap { - const map = loadIndexMap(repoName); - - // Drop entries whose worktree no longer exists on disk — keeps the file - // from growing forever, but we still preserve the numbers of live - // worktrees. - for (const p of Object.keys(map)) { - if (!worktreePaths.includes(p) && !existsSync(p)) delete map[p]; - } - - const used = new Set(Object.values(map)); - let mutated = false; - - for (let i = 0; i < worktreePaths.length; i++) { - const path = worktreePaths[i]!; - if (map[path]) continue; - - // Primary worktree (index 0 in git's output) gets 1 by preference. - let claim = i === 0 && !used.has(1) ? 1 : 0; - if (!claim) { - let n = 1; - while (used.has(n)) n++; - claim = n; - } - map[path] = claim; - used.add(claim); - mutated = true; - } - - if (mutated) saveIndexMap(repoName, map); - return map; -} - -// ─── git helpers (local — narrow-purpose, no execSync wrapper lib) ─────────── - -// rt drives git inside target repos but must never fire their hooks (repo -// stealth). A broken husky post-checkout/post-merge hook otherwise makes the -// checkout exit non-zero *after* it already succeeded, surfacing as a spurious -// "checkout-failed" even though the branch switched fine. Disable hooks on every -// mutating command by pointing hooksPath at a nonexistent dir. -const NO_HOOKS = "-c core.hooksPath=/dev/null"; - -interface WorktreeInfo { path: string; branch: string | null; } - -function listWorktrees(repoPath: string): WorktreeInfo[] { - try { - const out = execSync("git worktree list --porcelain", { - cwd: repoPath, encoding: "utf8", stdio: "pipe", - }); - const results: WorktreeInfo[] = []; - let curPath = ""; - let curBranch: string | null = null; - for (const line of out.split("\n")) { - if (line.startsWith("worktree ")) { - if (curPath) results.push({ path: curPath, branch: curBranch }); - curPath = line.slice("worktree ".length).trim(); - curBranch = null; - } else if (line.startsWith("branch ")) { - curBranch = line.slice("branch refs/heads/".length).trim(); - } else if (line.startsWith("detached")) { - curBranch = null; - } - } - if (curPath) results.push({ path: curPath, branch: curBranch }); - return results; - } catch (err) { - log.debug({ err }, "git worktree list failed"); - return []; - } -} - -function branchExistsLocal(cwd: string, branch: string): boolean { - try { - execSync(`git rev-parse --verify "refs/heads/${branch}"`, { - cwd, stdio: "pipe", - }); - return true; - } catch { - return false; - } -} - -function branchCheckedOutElsewhere(repoPath: string, branch: string, selfPath: string): string | null { - for (const wt of listWorktrees(repoPath)) { - if (wt.path === selfPath) continue; - if (wt.branch === branch) return wt.path; - } - return null; -} - -// ─── Parking action ────────────────────────────────────────────────────────── - -export interface ParkResult { - ok: boolean; - action: string; - detail?: string; -} - -export interface ParkOptions { - /** Kill workload processes rooted in the worktree before parking. - * Callers pass loadParkingLotConfig().killProcesses. */ - killProcesses?: boolean; -} - -export function park( - worktreePath: string, - repoPath: string, - sourceBranch: string | null, - index: number, - opts: ParkOptions = {}, -): ParkResult { - const parkBranch = `parking-lot/${index}`; - - // 1. Confirm the worktree is still in the state we expect. `sourceBranch` is - // null when parking a detached worktree (e.g. a herdr warm-pool entry) — - // in that case we expect it to still be detached. Either way, if the user - // has switched away, bail rather than clobber their current state. - const current = getCurrentBranch(worktreePath); - if (current !== sourceBranch) { - const detail = sourceBranch - ? `worktree is on "${current}", not "${sourceBranch}"` - : `worktree is no longer detached (on "${current}")`; - return { ok: false, action: "skip", detail }; - } - - // 2. Refuse to touch the parking-lot branch if another worktree already - // has it checked out — git would reject the checkout, but erroring out - // cleanly gives a better log. - const holder = branchCheckedOutElsewhere(repoPath, parkBranch, worktreePath); - if (holder) { - return { ok: false, action: "skip", detail: `${parkBranch} is checked out at ${holder}` }; - } - - // 2.5. The feature is done — stop its workload (dev servers, watchers) - // before touching the tree. Failure here never blocks the park. - if (opts.killProcesses) { - try { - wtKill.killWorktreeProcesses(worktreePath); - } catch (err) { - log.warn({ err, worktreePath }, "worktree process kill failed; parking anyway"); - } - } - - // 3. Stash if dirty, using the GitHub Desktop-compatible marker so the - // existing rt / GitHub Desktop flows can find it later. A detached - // worktree has no source branch to key the stash to, so fall back to the - // parking-lot slot — that's where the worktree is headed and keeps the - // stash recoverable (rather than labeling it ""). - const stashLabel = sourceBranch ?? parkBranch; - try { - if (hasUncommittedChanges(worktreePath)) { - stashChanges(worktreePath, stashLabel); - log.info({ stashLabel }, `stashed uncommitted changes on "${stashLabel}"`); - } - } catch (err) { - return { ok: false, action: "stash-failed", detail: String(err) }; - } - - // 4. Fetch the default branch so the fast-forward can actually advance. - const defaultRef = getRemoteDefaultBranch(worktreePath) ?? "origin/master"; - const defaultBranch = defaultRef.replace(/^origin\//, ""); - try { - execSync(`git fetch origin "${defaultBranch}"`, { cwd: worktreePath, stdio: "pipe" }); - } catch (err) { - return { ok: false, action: "fetch-failed", detail: String(err) }; - } - - // 5. Check out parking-lot/N, creating it off the default branch if missing. - try { - if (branchExistsLocal(worktreePath, parkBranch)) { - execSync(`git ${NO_HOOKS} checkout "${parkBranch}"`, { cwd: worktreePath, stdio: "pipe" }); - } else { - execSync(`git ${NO_HOOKS} checkout -b "${parkBranch}" "${defaultRef}"`, { cwd: worktreePath, stdio: "pipe" }); - log.info({ parkBranch, defaultRef }, `created ${parkBranch} from ${defaultRef}`); - } - } catch (err) { - return { ok: false, action: "checkout-failed", detail: String(err) }; - } - - // 6. Fast-forward. If parking-lot/N was just created off defaultRef this is - // a no-op; if it existed already we advance it. - try { - execSync(`git ${NO_HOOKS} merge --ff-only "${defaultRef}"`, { cwd: worktreePath, stdio: "pipe" }); - } catch (err) { - return { ok: false, action: "ff-failed", detail: String(err) }; - } - - return { ok: true, action: "parked", detail: `${sourceBranch ?? "(detached)"} → ${parkBranch} @ ${defaultRef}` }; -} - -/** - * Whether a worktree binding can be manually parked onto its slot. - * - * Parkable = it has an allocated slot index and isn't already sitting on that - * `parking-lot/` branch. Both feature-branch worktrees and detached - * worktrees (branch === null, e.g. herdr warm-pool entries) qualify — parking a - * detached worktree claims it onto a clean slot branch off origin/master. - * - * Note this is the manual-park predicate. Auto-park (checkAndPark) is - * deliberately narrower: it only fires on MR open→terminal transitions, which - * detached worktrees never have, so they are never auto-parked. - */ -export function isParkable(binding: WorktreeBinding): boolean { - if (!binding.index) return false; - if (binding.branch === `parking-lot/${binding.index}`) return false; - return true; -} - -// ─── Transition detection (called after each cache refresh) ────────────────── - -export interface ParkingEnv { - cache: { entries: Record }; - repoIndex: () => RepoIndex; -} - -const TERMINAL_STATES = new Set(["merged", "closed"]); - -// ─── Fast-forward already-parked worktrees ──────────────────────────────────── - -function isParkedBranch(branch: string): boolean { - return /^parking-lot\/\d+$/.test(branch); -} - -/** One entry from `git status --porcelain`. */ -interface DirtyEntry { status: string; path: string; } - -function listDirtyEntries(cwd: string): DirtyEntry[] { - let out: string; - try { - out = execSync("git status --porcelain", { cwd, encoding: "utf8", stdio: "pipe" }); - } catch (err) { - log.warn({ err, cwd }, "git status failed during ff-sweep"); - return []; - } - - const entries: DirtyEntry[] = []; - for (const line of out.split("\n")) { - if (line.length < 4) continue; - const status = line.slice(0, 2); - let path = line.slice(3); - // Rename/copy entries read "old -> new"; the destination is what's dirty. - const arrow = path.indexOf(" -> "); - if (arrow !== -1) path = path.slice(arrow + 4); - // git quotes paths containing specials (C-style escapes). - if (path.startsWith('"') && path.endsWith('"')) { - try { path = JSON.parse(path) as string; } catch { path = path.slice(1, -1); } - } - entries.push({ status, path }); - } - return entries; -} - -/** - * Whether a tracked file's local change is pure whitespace relative to HEAD. - * Compared against HEAD (not the index) so a staged whitespace-only edit counts. - */ -function isWhitespaceOnlyChange(cwd: string, path: string): boolean { - try { - execFileSync("git", ["diff", "HEAD", "--ignore-all-space", "--exit-code", "--", path], { - cwd, stdio: "pipe", - }); - return true; // exit 0 → nothing left once whitespace is ignored - } catch { - return false; - } -} - -/** - * Decide what to do with each dirty path before a fast-forward. - * - * `discard` covers tracked modifications to files the repo's sync.json - * declares auto-resolvable with `strategy: "theirs"`, whose local diff is pure - * whitespace. These are generated artifacts that drift by a trailing newline - * and are rebuilt by the next build; the declaration says upstream wins. - * - * Everything else is stashed and restored, never discarded... a background - * sweep must not destroy content it didn't generate. That deliberately - * includes substantive changes to declared files: the declaration is about - * regenerable drift, not about giving the daemon licence to delete real edits. - */ -function classifyDirtyForFastForward( - worktreePath: string, - repoName: string, - entries: DirtyEntry[], -): { discard: string[]; stashRest: boolean } { - const rules = loadSyncConfig(repoDataDir(repoName)).autoResolve; - const discard: string[] = []; - let stashRest = false; - - for (const e of entries) { - const untracked = e.status === "??"; - const modified = !untracked && e.status.includes("M"); - if ( - modified && - matchRule(e.path, rules)?.strategy === "theirs" && - isWhitespaceOnlyChange(worktreePath, e.path) - ) { - discard.push(e.path); - } else { - stashRest = true; - } - } - - return { discard, stashRest }; -} - -export function fastForwardParkedWorktrees( - repoName: string, - repoPath: string, - worktrees: WorktreeInfo[], -): void { - const parked = worktrees.filter(w => w.branch && isParkedBranch(w.branch)); - if (parked.length === 0) return; - - const defaultRef = getRemoteDefaultBranch(repoPath) ?? "origin/master"; - const defaultBranch = defaultRef.replace(/^origin\//, ""); - - try { - execSync(`git fetch origin "${defaultBranch}"`, { cwd: repoPath, stdio: "pipe" }); - } catch (err) { - log.debug({ err, repoPath }, "fetch failed during ff-sweep, skipping"); - return; - } - - for (const wt of parked) { - const entries = listDirtyEntries(wt.path); - let discarded: string[] = []; - let stashName: string | null = null; - - if (entries.length > 0) { - const { discard, stashRest } = classifyDirtyForFastForward(wt.path, repoName, entries); - - // Reset declared generated drift to HEAD (covers staged + unstaged). - for (const path of discard) { - try { - execFileSync("git", ["checkout", "HEAD", "--", path], { cwd: wt.path, stdio: "pipe" }); - discarded.push(path); - } catch (err) { - log.warn({ err, worktree: wt.path, path }, "failed to reset declared generated file; will stash instead"); - } - } - - // Anything left gets stashed under the slot's Desktop-compatible marker - // so it is recoverable by hand even if the restore below fails. - if (stashRest || hasUncommittedChanges(wt.path)) { - try { - stashChanges(wt.path, wt.branch!); - stashName = findDesktopStash(wt.path, wt.branch!)?.name ?? "stash@{0}"; - } catch (err) { - log.warn({ err, branch: wt.branch, worktree: wt.path }, `ff-sweep: could not stash ${wt.branch}, leaving it behind`); - continue; - } - } - } - - let advanced = false; - try { - execSync(`git ${NO_HOOKS} merge --ff-only "${defaultRef}"`, { cwd: wt.path, stdio: "pipe" }); - advanced = true; - } catch (err) { - // Branch has diverged or is already up to date... expected, not a failure. - log.debug({ err, branch: wt.branch, worktree: wt.path }, "ff-only skipped (diverged or already up to date)"); - } - - // Always attempt the restore, including when the ff was a no-op: we took - // the user's changes, so we owe them back regardless of the merge outcome. - if (stashName) { - try { - popStash(wt.path, stashName); - } catch (err) { - log.warn( - { err, branch: wt.branch, worktree: wt.path, stashName }, - `ff-sweep: stash ${stashName} did not reapply cleanly in ${wt.path}... it is preserved, restore it with: git stash pop ${stashName}`, - ); - continue; - } - } - - if (advanced) { - log.info( - { branch: wt.branch, worktree: wt.path, defaultRef, discarded, stashed: Boolean(stashName) }, - `fast-forwarded ${wt.branch} → ${defaultRef}` - + (discarded.length ? ` (reset ${discarded.length} generated file${discarded.length > 1 ? "s" : ""})` : "") - + (stashName ? " (stashed and restored local changes)" : ""), - ); - } - } -} - -export function checkAndPark(env: ParkingEnv): void { - const config = loadParkingLotConfig(); - if (!config.enabled) return; - - const state = loadState(); - const fired = new Set(state.fired); - const nextMrState: Record = {}; - - const repoIndex = env.repoIndex(); - - // Build a quick lookup of (repoPath → worktree-path → branch) from git. - // We do this lazily, only for repos that actually have a live cache entry, - // so we don't shell out to every repo on every tick. - const worktreeByRepo = new Map(); - const indexMapByRepo = new Map(); - - for (const [branch, entry] of Object.entries(env.cache.entries)) { - const mrState = entry.mr?.state ?? null; - nextMrState[branch] = mrState; - - if (!entry.repoName) continue; - const repoPath = repoIndex[entry.repoName]; - if (!repoPath || !existsSync(repoPath)) continue; - - const prev = state.mrState[branch] ?? null; - if (prev !== "opened") continue; - if (!mrState || !TERMINAL_STATES.has(mrState)) continue; - - const fireKey = `parked:${entry.repoName}:${branch}:${mrState}`; - if (fired.has(fireKey)) continue; - - // Lazily discover worktrees + indexes for this repo. - if (!worktreeByRepo.has(repoPath)) { - const worktrees = listWorktrees(repoPath); - worktreeByRepo.set(repoPath, worktrees); - indexMapByRepo.set(entry.repoName, reconcileIndexMap(entry.repoName, worktrees.map(w => w.path))); - } - - const worktrees = worktreeByRepo.get(repoPath)!; - const indexes = indexMapByRepo.get(entry.repoName)!; - - // Find the worktree currently (or most recently per git) bound to this branch. - const wt = worktrees.find(w => w.branch === branch); - if (!wt) { - log.info({ repoName: entry.repoName, branch, mrState }, `${entry.repoName}/${branch} ${mrState} — no matching worktree, skipping`); - fired.add(fireKey); // don't re-check forever - continue; - } - - const idx = indexes[wt.path]; - if (!idx) { - log.info({ repoName: entry.repoName, branch, mrState, worktree: wt.path }, `${entry.repoName}/${branch} ${mrState} — no index for ${wt.path}, skipping`); - fired.add(fireKey); - continue; - } - - log.info({ repoName: entry.repoName, branch, mrState, worktree: wt.path, idx }, `${entry.repoName}/${branch} ${mrState} → parking at ${wt.path} (space ${idx})`); - const result = park(wt.path, repoPath, branch, idx, { killProcesses: config.killProcesses }); - if (result.ok) { - log.info({ result }, `parked: ${result.detail}`); - fired.add(fireKey); - } else { - log.warn({ result }, `park failed: ${result.action}${result.detail ? ` — ${result.detail}` : ""}`); - // Don't mark fired on failure — we'll retry next tick. - } - } - - // Persist fresh MR state snapshot so the next tick has something to compare - // against. Absent branches (stale cache entries removed) are dropped. - saveState({ mrState: nextMrState, fired: [...fired] }); - - // Fast-forward any worktree already sitting on a parking-lot branch. - // We do this for every known repo regardless of whether a park transition - // fired this tick — these branches substitute for master and must stay current. - for (const [repoName, repoPath] of Object.entries(repoIndex)) { - if (!existsSync(repoPath)) continue; - const worktrees = worktreeByRepo.get(repoPath) ?? listWorktrees(repoPath); - try { - fastForwardParkedWorktrees(repoName, repoPath, worktrees); - } catch (err) { - log.warn({ err, repoName }, `ff-sweep failed for ${repoName}`); - } - } -} - -// ─── CLI introspection ─────────────────────────────────────────────────────── - -export interface WorktreeBinding { - path: string; - branch: string | null; - index: number; - /** - * Commits the worktree's `parking-lot/` branch trails the local - * `origin/master` ref by, or null when the slot branch doesn't exist yet or - * staleness wasn't requested. Reads the already-fetched remote ref rather - * than fetching, so it costs one rev-list per slot and no network. - */ - slotBehind?: number | null; -} - -/** How far `ref` trails `defaultRef`, or null if either ref is unresolvable. */ -function countBehind(repoPath: string, ref: string, defaultRef: string): number | null { - try { - const out = execFileSync("git", ["rev-list", "--count", `${ref}..${defaultRef}`], { - cwd: repoPath, encoding: "utf8", stdio: "pipe", - }); - const n = parseInt(out.trim(), 10); - return Number.isNaN(n) ? null : n; - } catch { - return null; - } -} - -/** - * Current worktree → parking-lot-index bindings for a single repo, reconciling - * against `git worktree list` on the fly. Used by `rt parking-lot status`. - * - * `withStaleness` adds a rev-list per slot; the park pickers skip it so their - * hot path stays a single `git worktree list`. - */ -export function describeRepoBindings( - repoName: string, - repoPath: string, - opts: { withStaleness?: boolean } = {}, -): WorktreeBinding[] { - const worktrees = listWorktrees(repoPath); - const indexes = reconcileIndexMap(repoName, worktrees.map(w => w.path)); - const defaultRef = opts.withStaleness - ? (getRemoteDefaultBranch(repoPath) ?? "origin/master") - : null; - - return worktrees.map(w => { - const index = indexes[w.path] ?? 0; - const binding: WorktreeBinding = { path: w.path, branch: w.branch, index }; - if (defaultRef && index) { - binding.slotBehind = countBehind(repoPath, `parking-lot/${index}`, defaultRef); - } - return binding; - }); -} - -// ─── Exposed for tests ─────────────────────────────────────────────────────── - -export const __test__ = { - reconcileIndexMap, - loadIndexMap, - saveIndexMap, - STATE_PATH, -}; diff --git a/lib/daemon/worktree-reconciler.ts b/lib/daemon/worktree-reconciler.ts new file mode 100644 index 00000000..b70fa38e --- /dev/null +++ b/lib/daemon/worktree-reconciler.ts @@ -0,0 +1,1067 @@ +/** + * Worktree reconciler — brings the on-disk registry back in line with git + * ground truth, then reacts to the MR transitions that end a tree's life. + * Task 12 extends `runOnce` in place with the freshen and replenish/shrink + * passes, so structure here is deliberately left open for that: each duty is + * a standalone step `runOnce` calls per repo, and `createWorktreeReconciler`'s + * returned object is the single surface later tasks add to (e.g. + * `creationInFlight`). + */ + +import { basename, join } from "path"; +import { realpathSync } from "fs"; +import type { Logger } from "pino"; +import { readJson, writeJson } from "../json-store.ts"; +import { repoDataDir, rtDir } from "../rt-paths.ts"; +import { + findByBranch, + findByPath, + loadRegistry, + registryEpoch, + saveRegistry, + type TreeKind, + type TreeRecord, +} from "../worktree/registry.ts"; +import { + branchExistsLocalAsync, + currentBranchAsync, + findDesktopStashAsync, + gitOk, + headSha, + listWorktreesAsync, + remoteDefaultRef, + runGit, + stashChangesAsync, + type WorktreeEntry, +} from "../worktree/git-async.ts"; +import { isTreeLocked, withTreeLock } from "../worktree/locks.ts"; +import { createTree, scrapTree, type CreateDeps } from "../worktree/create.ts"; +import { classifyDirtyAsync, disposeTree } from "../worktree/dispose.ts"; +import { changedSince, stepsToRun, runReadySteps } from "../worktree/ready.ts"; +import { + loadWorktreeAppConfig, + loadWorktreeRepoConfig, + resolveReadySteps, + type WorktreeAppConfig, +} from "../worktree/config.ts"; +import { killWorktreeProcesses } from "./worktree-process-kill.ts"; + +export interface ReconcilerDeps { + cache: { entries: Record }; + repoIndex: () => Record; + emit: (type: string, data: unknown) => void; + log: Logger; +} + +/** realpathSync defensively; a path that doesn't (yet) exist compares as-is. */ +function canon(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +export interface ReconcileDeps { + repoName: string; + repoPath: string; + emit: (type: string, data: unknown) => void; + log: Logger; + /** + * Test-only seam, invoked right after an attempt captures its registry + * snapshot and epoch — the exact window a competing writer (a provision + * claim, a dispose prune) lands in on the shared event loop. Production + * callers never pass it. + */ + onAfterLoad?: (attempt: number) => void; +} + +/** One attempt's outcome: its trees, or "a concurrent write invalidated me". */ +type PassResult = { trees: TreeRecord[] } | { conflict: true }; + +/** Attempts before a contended pass gives up and leaves the work to the next tick. */ +const RECONCILE_MAX_ATTEMPTS = 3; + +/** + * Reconcile one repo's worktree registry against git ground truth (spec §4). + * + * Order matters: + * 1. `git worktree prune` FIRST — an `rm -rf`'d tree otherwise leaves git's + * stale worktree registration holding the path/branch, which blocks a + * later create from reusing the same name. + * 2. (d) orphaned `creating` entries (no held lock) are scrapped before (a) + * evaluates existence, since an in-flight (locked) `creating` entry has + * no git worktree yet and must not be pruned out from under the create. + * 3. (a) registry entries with no matching git/disk worktree are pruned. + * 4. (b) git worktrees unknown to the registry are adopted (main/unmanaged). + * 5. (c) every remaining registered tree's `branch` is set to git ground + * truth; kind/state/owner are left untouched. + * 6. (e) duplicate branches across registered trees are left as-is — + * surfaced elsewhere (findByBranch / T13's list handler). + * + * Concurrency: this is the one registry writer that saves a WHOLE snapshot + * taken before a long run of git awaits. Every other writer is a synchronous + * fresh-load → mutate → save of one row, so per-tree locks are enough for them; + * they are not enough here, because reconcile holds no lock on the trees it + * rewrites (and taking a repo-wide one would reintroduce the coarse locking + * this design avoids). Instead each attempt captures `registryEpoch` with its + * snapshot and re-checks it in the same synchronous block as its save: if + * anyone else wrote in between, the snapshot is stale and the whole pass is + * retried against fresh state rather than overwriting them. Retries are bounded + * — a pass that keeps losing simply skips its save and lets the next tick redo + * it, since every correction here is derived from ground truth and idempotent. + */ +export async function reconcileRepoRegistry(deps: ReconcileDeps): Promise { + for (let attempt = 1; attempt <= RECONCILE_MAX_ATTEMPTS; attempt++) { + const result = await reconcilePass(deps, attempt); + if (!("conflict" in result)) return result.trees; + deps.log.debug?.( + { repo: deps.repoName, attempt }, + "reconcile: registry changed mid-pass; retrying against a fresh snapshot", + ); + } + deps.log.warn( + { repo: deps.repoName, attempts: RECONCILE_MAX_ATTEMPTS }, + "reconcile: registry kept changing mid-pass; skipping this pass's save", + ); + return loadRegistry(deps.repoName); +} + +async function reconcilePass(deps: ReconcileDeps, attempt: number): Promise { + const { repoName, repoPath, emit, log } = deps; + + await runGit(repoPath, ["worktree", "prune"]); + + let trees = loadRegistry(repoName); + let epoch = registryEpoch(repoName); + deps.onAfterLoad?.(attempt); + let changed = false; + const createDeps: CreateDeps = { repoName, repoPath, emit, log }; + + // (d) creating entries with no held lock -> scrap, no recreate. Entries + // still locked (genuinely in-flight) pass through untouched. Scrapping + // mutates git state (worktree remove + branch -D), so the git listing used + // by (a)-(c) below is captured AFTER this loop, not before. + // + // This is the one mutating step in an otherwise read-only reconcile, so + // (unlike (a)-(c)/(e), which only ever sync the registry file to ground + // truth) it is gated on the app-level enabled flag same as freshen/replenish. + const appConfig = loadWorktreeAppConfig(); + const afterScrap: TreeRecord[] = []; + let scrapped = false; + for (const rec of trees) { + if (appConfig.enabled && rec.state === "creating" && !isTreeLocked(rec.path)) { + log.info({ repo: repoName, tree: rec.name, path: rec.path }, "reconcile: scrapping orphaned creating tree"); + await scrapTree(createDeps, rec); + scrapped = true; + continue; + } + afterScrap.push(rec); + } + trees = afterScrap; + + if (scrapped) { + // scrapTree persists its own removal (fresh-load → filter → save), so the + // scrap is already on disk and has already bumped the epoch. Re-read from + // that write instead of carrying the pre-scrap snapshot forward: anything + // another writer landed during the scrap's git awaits is in the file now, + // and re-capturing the epoch here is what keeps our own intentional write + // from reading as somebody else's. + trees = loadRegistry(repoName); + epoch = registryEpoch(repoName); + } + + const gitEntries = await listWorktreesAsync(repoPath); + if (gitEntries === null) { + // Nothing to save: the scrap above already persisted itself, and writing + // this snapshot back would be exactly the stale-snapshot clobber. + log.warn({ repo: repoName, repoPath }, "reconcile: git worktree list failed; skipping this repo's pass"); + return { trees }; + } + const gitByCanon = new Map(); + for (const entry of gitEntries) { + gitByCanon.set(canon(entry.path), entry); + } + + // (a) registry paths missing from git/disk -> prune entry. `creating` + // entries are exempt: they legitimately have no git worktree yet. + const afterPrune: TreeRecord[] = []; + for (const rec of trees) { + if (rec.state === "creating") { + afterPrune.push(rec); + continue; + } + if (gitByCanon.has(canon(rec.path))) { + afterPrune.push(rec); + } else { + log.info({ repo: repoName, tree: rec.name, path: rec.path }, "reconcile: pruning registry entry with no matching worktree"); + changed = true; + } + } + trees = afterPrune; + + // (b) git paths unknown to registry -> adopt. The first porcelain entry is + // always the main clone. + const known = new Set(trees.map((t) => canon(t.path))); + let mainRegistered = trees.some((t) => t.kind === "main"); + for (const entry of gitEntries) { + const c = canon(entry.path); + if (known.has(c)) continue; + + const isMain = entry === gitEntries[0] && !mainRegistered; + const kind: TreeKind = isMain ? "main" : "unmanaged"; + if (isMain) mainRegistered = true; + + const rec: TreeRecord = { + name: basename(entry.path), + path: entry.path, + kind, + branch: entry.branch, + createdAt: new Date().toISOString(), + }; + trees.push(rec); + known.add(c); + changed = true; + log.info({ repo: repoName, tree: rec.name, kind, path: rec.path }, "reconcile: adopted worktree into registry"); + } + + // (c) ground-truth branch sync for every registered tree git still knows + // about. kind/state/owner are never touched here. + for (const rec of trees) { + const entry = gitByCanon.get(canon(rec.path)); + if (entry && rec.branch !== entry.branch) { + rec.branch = entry.branch; + changed = true; + } + } + + // (e) duplicate branches across registered trees: leave records as-is. + + if (changed) { + // Check and save in one synchronous block — an await between them would + // reopen the very window this closes. + if (registryEpoch(repoName) !== epoch) return { conflict: true }; + saveRegistry(repoName, trees); + } + + return { trees }; +} + +// ─── Merge reactor (spec §6.2) ─────────────────────────────────────────────── + +/** + * The reactor's own memory, at `~/.rt/worktree-reactor-state.json`. + * + * `mrState` is the last-seen MR state per `:`, compared against + * the live cache to find `opened → merged|closed` edges. A branch the file has + * never seen fails the `prev === "opened"` gate, which is what makes a cold + * boot on an already-merged cache entry a no-op rather than a mass disposal. + * + * `fired` is keyed by MR, not branch: `disposed:::`. + * Branch keys are wrong here because this design derives branch names from + * tickets, so a recut MR reuses the branch and a branch-keyed fire would + * silently never act a second time. The MR's keys are pruned when it returns + * to `opened`. + */ +interface ReactorState { + mrState: Record; + fired: string[]; +} + +export function reactorStatePath(): string { + return join(rtDir(), "worktree-reactor-state.json"); +} + +function loadReactorState(): ReactorState { + const raw = readJson>(reactorStatePath(), {}); + return { + mrState: raw?.mrState ?? {}, + fired: Array.isArray(raw?.fired) ? raw.fired : [], + }; +} + +function saveReactorState(state: ReactorState, log: Logger): void { + try { + writeJson(reactorStatePath(), state); + } catch (err) { + log.warn({ err }, "worktree reactor: could not persist state"); + } +} + +/** Branch-keyed MR cache entry, as the daemon holds it (`ctx.cache.entries`). */ +interface ReactorCacheEntry { + mr?: { iid?: number; state?: string | null } | null; + repoName?: string; +} + +export interface ReactorDeps { + repoName: string; + repoPath: string; + /** Branch-keyed MR cache (daemon `ctx.cache.entries`). */ + cacheEntries: Record; + emit: (type: string, data: unknown) => void; + log: Logger; +} + +const TERMINAL_STATES = new Set(["merged", "closed"]); + +/** + * What one tree's reaction did, which is also what the snapshot is allowed to + * do afterwards: + * - `done` — nothing to react to (wrong kind, job disposal, main already + * moved on). The edge is spent; advance the snapshot. + * - `fired` — the reaction happened (disposed / flipped disposable / + * auto-returned). Advance the snapshot AND record the fired key + * so a cache churn can't re-notify. + * - `retry` — the reaction failed for a mechanical, transient reason. The + * snapshot must stay at "opened" or the edge never re-arms; this + * is the correctness fix over the harvested parking-lot version, + * which advanced the snapshot unconditionally and so silently + * defeated its own retry. + */ +type Reaction = "done" | "fired" | "retry"; + +/** Load-mutate-save one registry row without clobbering concurrent edits. */ +function patchTree(repoName: string, path: string, patch: (rec: TreeRecord) => void): void { + const trees = loadRegistry(repoName); + const rec = trees.find((t) => t.path === path); + if (!rec) return; + patch(rec); + saveRegistry(repoName, trees); +} + +function markDisposable(deps: ReactorDeps, rec: TreeRecord, reason: string): void { + patchTree(deps.repoName, rec.path, (r) => { + r.state = "disposable"; + r.disposableReason = reason; + }); + deps.emit("worktree:disposable", { + repo: deps.repoName, + tree: rec.name, + path: rec.path, + branch: rec.branch, + reason, + }); + deps.log.info( + { repo: deps.repoName, tree: rec.name, reason }, + `worktree ${rec.name} is disposable: ${reason}`, + ); +} + +/** + * Return the main clone to its default branch after its branch merged. + * + * Harvested from `park()` minus the parking-slot branch: verify main is still + * on the merged branch, stop its workload, stash-and-LEAVE any dirt under the + * GitHub Desktop-compatible marker keyed to the branch that left, check out the + * default branch, fast-forward it. The stash is deliberately never popped — it + * belongs to the merged branch, not to the default branch main now sits on. + * + * Any mechanical failure returns "retry" so the snapshot holds and the next + * pass tries again; main has no disposable-equivalent state to park a failure + * in, so without the retry a transient failure would strand main on a dead + * branch forever. + */ +async function autoReturnMain( + deps: ReactorDeps, + rec: TreeRecord, + mergedBranch: string, + appConfig: WorktreeAppConfig, +): Promise { + const { repoName, log } = deps; + const fields = { repo: repoName, tree: rec.name, path: rec.path, branch: mergedBranch }; + + const current = await currentBranchAsync(rec.path); + if (current !== mergedBranch) { + log.debug?.({ ...fields, current }, "auto-return skipped: main is no longer on the merged branch"); + return "done"; + } + + // Resolve and vet the destination BEFORE touching anything. Every step below + // is destructive-ish (kill, stash) and every failure after them re-arms the + // edge, so a destination that can never work would stash the user's dirt and + // then spin on it forever. Both of these are configurations, not transients: + // they return "done" (edge spent) with one warn, not "retry". + const defaultRef = await remoteDefaultRef(rec.path); + const defaultBranch = defaultRef.replace(/^origin\//, ""); + + // (a) remoteDefaultRef falls back to "origin/master" unverified, so a repo + // whose default is develop/trunk yields a ref that resolves nowhere and a + // checkout that can never succeed. + const haveLocal = await branchExistsLocalAsync(rec.path, defaultBranch); + const haveRemote = await gitOk(rec.path, ["rev-parse", "--verify", defaultRef]); + if (!haveLocal && !haveRemote) { + log.warn( + { ...fields, defaultRef }, + `auto-return skipped: neither ${defaultBranch} nor ${defaultRef} exists — set the repo's default branch`, + ); + return "done"; + } + + // (b) git refuses to check out a branch another worktree holds. park() + // refused up front for exactly this; without the check the checkout + // fails after the stash and retries every pass. + const gitEntries = await listWorktreesAsync(deps.repoPath); + if (gitEntries === null) { + log.warn({ ...fields }, "auto-return: git worktree list failed; retrying next pass"); + return "retry"; + } + const holder = gitEntries.find( + (w) => w.branch === defaultBranch && canon(w.path) !== canon(rec.path), + ); + if (holder) { + log.warn( + { ...fields, defaultBranch, holder: holder.path }, + `auto-return skipped: ${defaultBranch} is checked out at ${holder.path}`, + ); + return "done"; + } + + if (appConfig.killProcesses) { + // The ruled execSync exception (the process killer is sync by design); + // a failure here never blocks the return. + try { + const { terminated } = killWorktreeProcesses(rec.path); + if (terminated.length > 0) log.info({ ...fields, count: terminated.length }, "worktree processes terminated"); + } catch (err) { + log.warn({ err, ...fields }, "auto-return: process kill failed; returning anyway"); + } + } + + const status = await runGit(rec.path, ["status", "--porcelain"]); + if (status.exitCode !== 0) { + log.warn({ ...fields, output: status.stderr.trim() }, "auto-return: git status failed"); + return "retry"; + } + if (status.stdout.trim().length > 0) { + await stashChangesAsync(rec.path, mergedBranch); + const after = await runGit(rec.path, ["status", "--porcelain"]); + if (after.exitCode !== 0 || after.stdout.trim().length > 0) { + log.warn({ ...fields }, "auto-return: stash did not clear the worktree"); + return "retry"; + } + log.info({ ...fields }, `stashed uncommitted changes on "${mergedBranch}"`); + } + + const checkout = await runGit(rec.path, ["checkout", defaultBranch]); + if (checkout.exitCode !== 0) { + log.warn({ ...fields, defaultBranch, output: checkout.stderr.trim() }, "auto-return: checkout failed"); + return "retry"; + } + + const ff = await runGit(rec.path, ["merge", "--ff-only", defaultRef]); + if (ff.exitCode !== 0) { + log.warn({ ...fields, defaultRef, output: ff.stderr.trim() }, "auto-return: fast-forward failed"); + return "retry"; + } + + // Ground truth now, not next reconcile: `rt worktree list` must not show + // main sitting on a branch it already left. + patchTree(repoName, rec.path, (r) => { + r.branch = defaultBranch; + }); + + log.info({ ...fields, defaultRef }, `returned ${rec.name} to ${defaultBranch} after ${mergedBranch} merged`); + return "fired"; +} + +/** React to one terminal MR state on one registered tree. Caller holds the tree lock. */ +async function actOnTree( + deps: ReactorDeps, + rec: TreeRecord, + branch: string, + mrState: string, + appConfig: WorktreeAppConfig, +): Promise { + if (rec.kind === "main") { + // Closed-without-merge leaves main alone: the branch's commits are still + // only on that branch, and a closed MR often means recut. + return mrState === "merged" ? autoReturnMain(deps, rec, branch, appConfig) : "done"; + } + if (rec.kind !== "ephemeral") return "done"; + // Job trees are the caller's to end, MR or no MR. + if (rec.disposal === "job") return "done"; + // claimed AND disposable both react, so a reopened-then-merged MR still + // disposes; on-deck/creating trees never carry MR branches. + if (rec.state !== "claimed" && rec.state !== "disposable") return "done"; + + if (mrState === "closed") { + markDisposable(deps, rec, "MR closed without merge"); + return "fired"; + } + + const outcome = await disposeTree( + { + repoName: deps.repoName, + repoPath: deps.repoPath, + cacheEntries: deps.cacheEntries as Record, + emit: deps.emit, + log: deps.log, + killProcesses: appConfig.killProcesses, + }, + rec, + { auto: true }, + ); + if (outcome.disposed) return "fired"; + + // "remove-failed" is mechanical and transient (a locked file, a busy + // directory) — the tree is still perfectly claimable, so it must NOT be + // advertised as disposable. Hold the edge and try again next pass. + if (outcome.refusal === "remove-failed") { + deps.log.warn( + { repo: deps.repoName, tree: rec.name, path: rec.path }, + "auto-dispose: worktree removal failed; retrying next pass", + ); + return "retry"; + } + + markDisposable(deps, rec, outcome.refusal); + return "fired"; +} + +/** Worst outcome wins: any retry re-arms the edge, otherwise any fire records it. */ +function worse(a: Reaction, b: Reaction): Reaction { + if (a === "retry" || b === "retry") return "retry"; + if (a === "fired" || b === "fired") return "fired"; + return "done"; +} + +/** An MR back to `opened` un-disposables the trees on its branch: work resumed. */ +async function resumeTrees(deps: ReactorDeps, branch: string): Promise { + for (const rec of findByBranch(loadRegistry(deps.repoName), branch)) { + if (rec.kind !== "ephemeral" || rec.state !== "disposable") continue; + await withTreeLock(rec.path, async () => { + patchTree(deps.repoName, rec.path, (r) => { + r.state = "claimed"; + delete r.disposableReason; + }); + deps.log.info( + { repo: deps.repoName, tree: rec.name, branch }, + `MR reopened — ${rec.name} is claimed again`, + ); + }); + } +} + +/** + * Detect `opened → merged|closed` MR transitions for one repo and react. + * + * Port of `parking-lot.ts` checkAndPark's detector with three deliberate + * changes: the retry fix (see `Reaction`), MR-keyed fired keys with reopen + * pruning, and a merged/closed/reopened dispatch that branches on tree kind + * and disposal mode instead of parking everything onto a slot branch. + */ +export async function detectTransitions(deps: ReactorDeps): Promise { + const { repoName, cacheEntries, log } = deps; + const appConfig = loadWorktreeAppConfig(); + if (!appConfig.enabled) return; + + const state = loadReactorState(); + const fired = new Set(state.fired); + + // Snapshots are per repo; other repos' keys ride through untouched so a + // single-repo pass can't erase their memory, while this repo's stale + // branches drop out by being rebuilt from the live cache. + const prefix = `${repoName}:`; + const nextMrState: Record = {}; + for (const [key, value] of Object.entries(state.mrState)) { + if (!key.startsWith(prefix)) nextMrState[key] = value; + } + + for (const [branch, entry] of Object.entries(cacheEntries)) { + // Unattributed entries (older caches predate repoName) may join any repo; + // an entry attributed elsewhere never does. + if (entry.repoName && entry.repoName !== repoName) continue; + if (!entry.mr) continue; + + const cur = entry.mr.state ?? null; + const key = prefix + branch; + const prev = state.mrState[key] ?? null; + const iid = typeof entry.mr.iid === "number" ? String(entry.mr.iid) : branch; + + if (cur === "opened") { + nextMrState[key] = "opened"; + // Reopen: forget this MR's fires so a later merge acts again, and hand + // any disposable tree back to its owner. + for (const fireKey of [...fired]) { + if (fireKey.startsWith(`disposed:${repoName}:${iid}:`)) fired.delete(fireKey); + } + await resumeTrees(deps, branch); + continue; + } + + nextMrState[key] = cur; + if (prev !== "opened") continue; // cold-boot safety: unknown prev never fires + if (!cur || !TERMINAL_STATES.has(cur)) continue; + + const fireKey = `disposed:${repoName}:${iid}:${cur}`; + if (fired.has(fireKey)) continue; + + const trees = findByBranch(loadRegistry(repoName), branch); + if (trees.length === 0) { + log.debug?.({ repo: repoName, branch, mrState: cur }, "reactor: no registered tree on the branch"); + continue; + } + + let reaction: Reaction = "done"; + for (const rec of trees) { + const result = await withTreeLock(rec.path, () => actOnTree(deps, rec, branch, cur, appConfig)); + // A locked tree is someone else's in-flight work; come back next pass. + reaction = worse(reaction, result === "busy" ? "retry" : result); + } + + if (reaction === "retry") nextMrState[key] = "opened"; + else if (reaction === "fired") fired.add(fireKey); + } + + saveReactorState({ mrState: nextMrState, fired: [...fired] }, log); +} + +// ─── Freshen (spec §6.3) ───────────────────────────────────────────────────── + +const FRESHEN_FETCH_TIMEOUT_MS = 5 * 60_000; +/** The backoff "pass" unit: failure N waits pass * 2^(N-1), capped below. */ +const FRESHEN_PASS_MS = 5 * 60_000; +const FRESHEN_MAX_BACKOFF_MS = 30 * 60_000; + +/** + * Delay after the Nth consecutive failure: one pass, doubled N-1 times, capped. + * Shared by the freshen retry stamp and the per-repo create backoff (spec §6.4) + * — both count in passes and both cap at 30 minutes. + */ +function backoffDelayMs(failures: number): number { + return Math.min(FRESHEN_PASS_MS * 2 ** (failures - 1), FRESHEN_MAX_BACKOFF_MS); +} + +export interface FreshenDeps { + repoName: string; + repoPath: string; + emit: (type: string, data: unknown) => void; + log: Logger; +} + +/** + * Whether a registered tree is a freshen candidate: any on-deck ephemeral + * tree, or "idle main" — sitting on the default branch with no blocking dirt. + * A main clone on a feature branch is the merge reactor's concern (auto-return + * on merge); a main clone with real uncommitted work, even on the default + * branch, is the user's and must be left alone. + * + * `rec.branch` is trusted as ground truth here rather than re-reading git: + * `reconcileRepoRegistry` (T10) already ran earlier in the same `runOnce` pass + * and synced it. + */ +async function freshenCandidate(deps: FreshenDeps, rec: TreeRecord): Promise { + if (rec.kind === "ephemeral") return rec.state === "on-deck"; + if (rec.kind !== "main") return false; + + const defaultRef = await remoteDefaultRef(rec.path); + const defaultBranchName = defaultRef.replace(/^origin\//, ""); + if (rec.branch !== defaultBranchName) return false; + + const { blockers } = await classifyDirtyAsync(rec.path, deps.repoName); + return blockers.length === 0; +} + +/** + * Freshen one tree: fetch the default branch, ff-only merge it in, then run + * whatever ready steps that advance triggers. Caller holds the tree lock and + * has already verified `freshenCandidate` and that any `nextRetryAt` has + * passed. + * + * `readyStamp` (and therefore future `changedSince` diffs) only advances when + * a ready step actually ran and succeeded — a ff that triggers nothing hasn't + * validated anything new, so claiming otherwise would let a later real change + * hide behind a stamp nothing ever checked. + */ +async function freshenOne(deps: FreshenDeps, rec: TreeRecord): Promise { + const { repoName, log, emit } = deps; + const fields = { repo: repoName, tree: rec.name, path: rec.path }; + + const fail = (): void => { + const failures = (rec.retryFailures ?? 0) + 1; + const backoffMs = backoffDelayMs(failures); + patchTree(repoName, rec.path, (r) => { + r.retryFailures = failures; + r.nextRetryAt = new Date(Date.now() + backoffMs).toISOString(); + }); + }; + + const defaultRef = await remoteDefaultRef(rec.path); + const defaultBranchName = defaultRef.replace(/^origin\//, ""); + + const fetchResult = await runGit(rec.path, ["fetch", "origin", defaultBranchName], { + timeoutMs: FRESHEN_FETCH_TIMEOUT_MS, + }); + if (fetchResult.exitCode !== 0) { + log.warn({ ...fields, output: fetchResult.stderr.trim() }, "freshen: fetch failed"); + fail(); + return false; + } + + const classify = await classifyDirtyAsync(rec.path, repoName); + if (classify.discard.length > 0) { + await runGit(rec.path, ["checkout", "--", ...classify.discard]); + } + + // Blockers stashed under the tree's own branch name (Desktop-compatible + // marker), harvested from parking-lot.ts's ff-sweep. On-deck trees are + // expected to be clean by construction; the idle-main case can legitimately + // have generated-only dirt left after the discard reset above. + let stashName: string | null = null; + const label = rec.branch ?? rec.name; + if (classify.blockers.length > 0) { + await stashChangesAsync(rec.path, label); + // A pushed stash whose name we then fail to resolve must still get a + // restore attempt, not be silently abandoned — "stash@{0}" is the entry + // we just pushed absent a race with something else stashing concurrently + // (harvested fallback from parking-lot.ts's ff-sweep). + stashName = (await findDesktopStashAsync(rec.path, label))?.name ?? "stash@{0}"; + } + + const popStash = async (): Promise => { + if (!stashName) return; + const pop = await runGit(rec.path, ["stash", "pop", stashName]); + if (pop.exitCode !== 0) { + log.warn( + { ...fields, stashName }, + `freshen: stash ${stashName} did not reapply cleanly in ${rec.path}... it is preserved, restore it with: git stash pop ${stashName}`, + ); + } + }; + + const ff = await runGit(rec.path, ["merge", "--ff-only", defaultRef]); + if (ff.exitCode !== 0) { + log.warn({ ...fields, defaultRef, output: ff.stderr.trim() }, "freshen: fast-forward failed"); + await popStash(); + fail(); + return false; + } + await popStash(); + + const cfg = loadWorktreeRepoConfig(repoName, deps.repoPath); + const readySteps = resolveReadySteps(cfg, deps.repoPath); + const changed = rec.readyStamp ? await changedSince(rec.path, rec.readyStamp) : null; + const toRun = stepsToRun(readySteps, changed); + + const readyResult = await runReadySteps(rec.path, toRun); + if (!readyResult.ok) { + log.warn({ ...fields, failedStep: readyResult.failedStep }, "freshen: ready step failed"); + fail(); + return false; + } + + const newStamp = toRun.length > 0 ? await headSha(rec.path) : null; + patchTree(repoName, rec.path, (r) => { + r.readyAt = new Date().toISOString(); + r.retryFailures = 0; + delete r.nextRetryAt; + if (newStamp) r.readyStamp = newStamp; + }); + + emit("worktree:freshened", { repo: repoName, tree: rec.name, path: rec.path }); + log.debug?.(fields, `worktree ${rec.name} freshened`); + return true; +} + +/** + * Freshen every eligible tree in one repo, each under its own tree lock. + * + * `trees` is one snapshot for the whole pass, but candidacy for tree N+1 + * isn't evaluated until tree N's (potentially slow — real fetches, ready + * steps) freshen finishes, so by the time a later tree's lock is acquired its + * snapshot `rec` can be minutes stale: a provision claim (T13, same event + * loop) could have landed in between. Re-reading the registry as the first + * thing inside the lock and bailing on any state/branch drift closes that + * window — the alternative is running a ff + ready steps inside a tree a + * human just claimed. + */ +export async function freshenRepo( + deps: FreshenDeps, + opts: { only?: string } = {}, +): Promise { + const { repoName, log } = deps; + const now = Date.now(); + const trees = loadRegistry(repoName); + const ran: string[] = []; + for (const rec of trees) { + if (opts.only && rec.name !== opts.only) continue; + // Backoff is a shield for the unattended pass, not for a human who just + // asked for this one tree by name: an explicit `only` retries now. + if (!opts.only && rec.nextRetryAt && Date.parse(rec.nextRetryAt) > now) continue; + if (!(await freshenCandidate(deps, rec))) continue; + const outcome = await withTreeLock(rec.path, async () => { + const fresh = findByPath(loadRegistry(repoName), rec.path); + if (!fresh || fresh.state !== rec.state || fresh.branch !== rec.branch) { + log.debug?.( + { repo: repoName, tree: rec.name, path: rec.path }, + "freshen: skipping — tree changed since candidacy was decided", + ); + return false; + } + return await freshenOne(deps, fresh); + }); + if (outcome === true) ran.push(rec.name); + } + return ran; +} + +// ─── Replenish / shrink (spec §6.4) ────────────────────────────────────────── + +/** + * Per-repo create backoff (spec §6.4): failure N waits one pass doubled N-1 + * times, capped at 30 minutes. + * + * A failed `createTree` scraps its own registry row, so there is no on-disk row + * left to hang retry bookkeeping off — without this, a persistently failing + * ready step (a broken install costing minutes per attempt) is retried on every + * cache tick, forever. The state is deliberately in-memory, same as the + * in-flight creation map above it: a daemon restart clearing the backoff costs + * one wasted attempt, which is cheaper than persisting a transient. + */ +const createBackoff = new Map(); + +/** The active backoff deadline for a repo, or null when creates may run now. */ +function createBlockedUntil(repoName: string): string | null { + const entry = createBackoff.get(repoName); + if (!entry) return null; + return Date.parse(entry.nextRetryAt) > Date.now() ? entry.nextRetryAt : null; +} + +function noteCreateFailure(repoName: string): { failures: number; nextRetryAt: string } { + const failures = (createBackoff.get(repoName)?.failures ?? 0) + 1; + const entry = { + failures, + nextRetryAt: new Date(Date.now() + backoffDelayMs(failures)).toISOString(), + }; + createBackoff.set(repoName, entry); + return entry; +} + +/** On-deck / creating counts used to decide whether to grow or shrink the pool. */ +function poolCounts(repoName: string): { + ready: number; + totalUnclaimed: number; + onDeckEntries: TreeRecord[]; +} { + const trees = loadRegistry(repoName); + const now = Date.now(); + const onDeckEntries = trees.filter((t) => t.kind === "ephemeral" && t.state === "on-deck"); + const creatingEntries = trees.filter((t) => t.kind === "ephemeral" && t.state === "creating"); + const ready = onDeckEntries.filter((t) => !t.nextRetryAt || Date.parse(t.nextRetryAt) <= now).length; + return { ready, totalUnclaimed: onDeckEntries.length + creatingEntries.length, onDeckEntries }; +} + +/** + * Grow the on-deck pool toward `onDeck` (serially — one `createTree` in + * flight at a time, which `runOnce` awaiting each pass makes natural), then + * shrink it back down by disposing the stalest ready entries when it's over. + * + * Replenish is bounded on both axes. Within a pass it is bounded to the deficit + * measured once at the start, not re-derived from live state on every + * iteration: `createTree` scraps its own registry row on failure, so an + * always-failing config would otherwise re-read "still short" forever and spin + * this pass indefinitely. Across passes it is bounded by `createBackoff` — the + * first failure of a pass ends replenish for that repo and holds it off for the + * doubling backoff window, so a broken ready step costs one multi-minute + * attempt per window instead of `onDeck` attempts per cache tick. + */ +async function replenishAndShrink( + deps: FreshenDeps, + creationPromises: Map>, + appConfig: WorktreeAppConfig, +): Promise { + const { repoName, repoPath, emit, log } = deps; + const cfg = loadWorktreeRepoConfig(repoName, repoPath); + if (cfg.onDeck <= 0) return; + + let { ready, totalUnclaimed } = poolCounts(repoName); + let budget = Math.max(0, cfg.onDeck - totalUnclaimed); + while (budget > 0 && ready < cfg.onDeck && totalUnclaimed < cfg.onDeck) { + // Checked per iteration, not once per pass: a failure recorded by the + // attempt above must end this pass's replenish too, or the pass still + // burns `onDeck` full builds on the same broken step. + const blockedUntil = createBlockedUntil(repoName); + if (blockedUntil) { + log.debug?.( + { repo: repoName, nextRetryAt: blockedUntil }, + "replenish: skipped — create backoff in effect", + ); + break; + } + budget--; + const p: Promise = createTree({ repoName, repoPath, emit, log }) + .then((result) => { + if (result.ok) { + createBackoff.delete(repoName); + return; + } + // "busy" is another holder of the tree lock, not a failing build: it + // neither earns a backoff nor clears one. + if (result.error === "busy") return; + const { failures, nextRetryAt } = noteCreateFailure(repoName); + log.warn( + { repo: repoName, error: result.error, failedStep: result.failedStep, failures, nextRetryAt }, + "worktree reconciler: replenish create failed", + ); + }) + .catch((err) => { + const { failures, nextRetryAt } = noteCreateFailure(repoName); + log.warn( + { err, repo: repoName, failures, nextRetryAt }, + "worktree reconciler: replenish create threw", + ); + }) + .finally(() => { + if (creationPromises.get(repoName) === p) creationPromises.delete(repoName); + }); + creationPromises.set(repoName, p); + await p; + ({ ready, totalUnclaimed } = poolCounts(repoName)); + } + + // `attempted` guards against spinning forever on an entry disposeTree keeps + // refusing (e.g. a guard failure) — each path gets one shrink attempt per + // pass; a refusal just leaves it for the next pass rather than looping here. + let counts = poolCounts(repoName); + const attempted = new Set(); + while (counts.ready > cfg.onDeck) { + const now = Date.now(); + const eligible = counts.onDeckEntries.filter( + (t) => !attempted.has(t.path) && (!t.nextRetryAt || Date.parse(t.nextRetryAt) <= now), + ); + if (eligible.length === 0) break; + const stalest = eligible.reduce((a, b) => + Date.parse(a.readyAt ?? a.createdAt) <= Date.parse(b.readyAt ?? b.createdAt) ? a : b, + ); + attempted.add(stalest.path); + await withTreeLock(stalest.path, async () => { + // Same revalidation as freshen: `stalest` is a snapshot from this + // iteration's `poolCounts()` read; re-check it under the lock before + // disposing so a claim that landed since (no grace guard applies here — + // auto is false) can't get its tree deleted out from under it. + const fresh = findByPath(loadRegistry(repoName), stalest.path); + if (!fresh || fresh.kind !== "ephemeral" || fresh.state !== "on-deck") { + log.debug?.( + { repo: repoName, tree: stalest.name, path: stalest.path }, + "shrink: skipping — tree changed since candidacy was decided", + ); + return; + } + await disposeTree( + { repoName, repoPath, cacheEntries: {}, emit, log, killProcesses: appConfig.killProcesses }, + fresh, + { auto: false }, + ); + }); + counts = poolCounts(repoName); + } +} + +/** Whether a repo has any worktree state worth reconciling: registry entries or a declared "worktrees" config. */ +function repoHasWorktreeActivity(repoName: string): boolean { + if (loadRegistry(repoName).length > 0) return true; + const configPath = join(repoDataDir(repoName), "config.json"); + const raw = readJson<{ worktrees?: unknown }>(configPath, {}); + return raw.worktrees !== undefined; +} + +/** + * Assembles the worktree reconciler. `runOnce` runs reconcile then the merge + * reactor per qualifying repo; Task 12 extends it in place with the freshen + * and replenish/shrink passes. + */ +export function createWorktreeReconciler(deps: ReconcilerDeps): { + kick: () => void; + runOnce: () => Promise; + /** The live `createTree` promise replenish kicked off for `repoName`, or + * null when nothing is in flight. Task 13's provision handler awaits this + * instead of racing its own create against replenish's. */ + creationInFlight: (repoName: string) => Promise | null; + /** Whether a `kick()`-triggered pass is currently running. Test-only: lets a + * test that calls `kick()` (deliberately not awaited — that's the point of + * `kick`) poll for true completion instead of guessing at a sleep, so no + * background pass survives into a later test's HOME once its own + * `beforeEach` repoints that (shared, global) env var. */ + passInFlight: () => boolean; +} { + let inFlight: Promise | null = null; + const creationPromises = new Map>(); + + async function runOnce(): Promise { + const repos = deps.repoIndex(); + // One read for the whole pass: every repo shares the same app-level file. + const appConfig = loadWorktreeAppConfig(); + + for (const [repoName, repoPath] of Object.entries(repos)) { + if (!repoHasWorktreeActivity(repoName)) continue; + try { + await reconcileRepoRegistry({ repoName, repoPath, emit: deps.emit, log: deps.log }); + } catch (err) { + deps.log.warn({ err, repo: repoName }, "worktree reconciler: reconcile pass failed"); + } + // Separate catches throughout: any one duty throwing must not cost the + // next repo (or the next duty) its own pass. + try { + await detectTransitions({ + repoName, + repoPath, + cacheEntries: deps.cache.entries, + emit: deps.emit, + log: deps.log, + }); + } catch (err) { + deps.log.warn({ err, repo: repoName }, "worktree reconciler: merge reactor pass failed"); + } + + if (!appConfig.enabled) continue; + + try { + await freshenRepo({ repoName, repoPath, emit: deps.emit, log: deps.log }); + } catch (err) { + deps.log.warn({ err, repo: repoName }, "worktree reconciler: freshen pass failed"); + } + try { + await replenishAndShrink( + { repoName, repoPath, emit: deps.emit, log: deps.log }, + creationPromises, + appConfig, + ); + } catch (err) { + deps.log.warn({ err, repo: repoName }, "worktree reconciler: replenish/shrink pass failed"); + } + } + } + + function kick(): void { + if (inFlight) return; + const p = runOnce() + .catch((err) => { + deps.log.warn({ err }, "worktree reconciler: kick failed"); + }) + .finally(() => { + if (inFlight === p) inFlight = null; + }); + inFlight = p; + } + + function creationInFlight(repoName: string): Promise | null { + return creationPromises.get(repoName) ?? null; + } + + function passInFlight(): boolean { + return inFlight !== null; + } + + return { kick, runOnce, creationInFlight, passInFlight }; +} + +export const __test__ = { + detectTransitions, + reactorStatePath, + freshenRepo, + replenishAndShrink, + poolCounts, + backoffDelayMs, + createBackoff, +}; diff --git a/lib/json-store.ts b/lib/json-store.ts index 0d63f803..1cc1a984 100644 --- a/lib/json-store.ts +++ b/lib/json-store.ts @@ -3,14 +3,17 @@ * hand-rolled the same "read JSON or return a default" and "write JSON, creating * the parent dir" boilerplate. * + * Writes are write-temp-then-rename atomic — stores never tear (spec §4). + * * Scope note: adopted in the per-repo store modules touched by the ~/.rt/repos * move (repo-index, workspace-sync, parking-lot). Other hand-rolled callsites * are intentionally left alone — converting all of them is a separate, app-wide * sweep, not part of the path refactor. */ -import { mkdirSync, readFileSync, writeFileSync } from "fs"; +import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs"; import { dirname } from "path"; +import { randomBytes } from "crypto"; /** * Read and parse a JSON file. Returns `fallback` on any failure — missing file, @@ -32,5 +35,16 @@ export function readJson(path: string, fallback: T): T { */ export function writeJson(path: string, value: unknown): void { mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, JSON.stringify(value, null, 2)); + const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; + try { + writeFileSync(tmp, JSON.stringify(value, null, 2)); + renameSync(tmp, path); + } catch (err) { + try { + unlinkSync(tmp); + } catch { + // tmp file never got created, or was already cleaned up... nothing to do + } + throw err; + } } diff --git a/lib/module-registry.ts b/lib/module-registry.ts index 318e08ad..1a405894 100644 --- a/lib/module-registry.ts +++ b/lib/module-registry.ts @@ -34,7 +34,6 @@ import * as verify from "../commands/verify.ts"; import * as update from "../commands/update.ts"; import * as doppler from "../commands/doppler.ts"; import * as nav from "../commands/nav.ts"; -import * as parkingLot from "../commands/parking-lot.ts"; import * as sdm from "../commands/sdm.ts"; import * as plugin from "../commands/plugin.ts"; import * as worktree from "../commands/worktree.ts"; @@ -68,7 +67,6 @@ export const MODULE_REGISTRY: Record = { "./commands/update.ts": update, "./commands/doppler.ts": doppler, "./commands/nav.ts": nav, - "./commands/parking-lot.ts": parkingLot, "./commands/sdm.ts": sdm, "./commands/plugin.ts": plugin, "./commands/worktree.ts": worktree, diff --git a/lib/parking-lot-config.ts b/lib/parking-lot-config.ts deleted file mode 100644 index b216d466..00000000 --- a/lib/parking-lot-config.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Parking-lot user config — whether the auto-park scan runs on each cache - * refresh. Separate from `parking-lot-state.json`, which tracks transition - * dedup internally. - * - * Defaults to enabled so behavior matches the shipped feature; `rt parking-lot - * disable` is the escape hatch. - */ - -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; -import { join } from "path"; -import { RT_DIR } from "./daemon-config.ts"; - -export const PARKING_LOT_CONFIG_PATH = join(RT_DIR, "parking-lot.json"); - -export interface ParkingLotConfig { - enabled: boolean; - /** Kill workload processes (dev servers, watchers) in a worktree when it parks. */ - killProcesses: boolean; -} - -export function loadParkingLotConfig(): ParkingLotConfig { - try { - const raw = JSON.parse(readFileSync(PARKING_LOT_CONFIG_PATH, "utf8")); - return { - enabled: raw?.enabled !== false, - killProcesses: raw?.killProcesses !== false, - }; - } catch { - return { enabled: true, killProcesses: true }; - } -} - -export function saveParkingLotConfig(config: ParkingLotConfig): void { - try { - mkdirSync(RT_DIR, { recursive: true }); - writeFileSync(PARKING_LOT_CONFIG_PATH, JSON.stringify(config, null, 2)); - } catch { /* best-effort */ } -} diff --git a/lib/subprocess.ts b/lib/subprocess.ts index 6a8c70f4..8142f1cf 100644 --- a/lib/subprocess.ts +++ b/lib/subprocess.ts @@ -8,27 +8,32 @@ export interface RunResult { stdout: string; + stderr: string; exitCode: number; } /** * Run argv and capture stdout. Never throws: spawn failures and timeouts * surface as a non-zero exitCode with whatever stdout was collected. + * + * stderr is discarded by default (`opts.stderr` defaults to `"ignore"`); pass + * `"pipe"` to capture it for callers that need failure detail (e.g. git). */ export async function runCapture( argv: [string, ...string[]], - opts: { cwd?: string; timeoutMs?: number } = {}, + opts: { cwd?: string; timeoutMs?: number; stderr?: "ignore" | "pipe" } = {}, ): Promise { + const captureStderr = opts.stderr === "pipe"; let proc: ReturnType; try { proc = Bun.spawn(argv, { cwd: opts.cwd, stdin: "ignore", stdout: "pipe", - stderr: "ignore", + stderr: captureStderr ? "pipe" : "ignore", }); } catch { - return { stdout: "", exitCode: -1 }; + return { stdout: "", stderr: "", exitCode: -1 }; } const timer = setTimeout(() => { @@ -36,11 +41,15 @@ export async function runCapture( }, opts.timeoutMs ?? 10_000); try { - const stdout = await new Response(proc.stdout as ReadableStream).text(); + const stdoutPromise = new Response(proc.stdout as ReadableStream).text(); + const stderrPromise = captureStderr + ? new Response(proc.stderr as ReadableStream).text() + : Promise.resolve(""); + const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]); const exitCode = await proc.exited; - return { stdout, exitCode }; + return { stdout, stderr, exitCode }; } catch { - return { stdout: "", exitCode: -1 }; + return { stdout: "", stderr: "", exitCode: -1 }; } finally { clearTimeout(timer); } diff --git a/lib/worktree-each.ts b/lib/worktree-each.ts index 728b1efb..6e41ca1b 100644 --- a/lib/worktree-each.ts +++ b/lib/worktree-each.ts @@ -2,12 +2,11 @@ * Pure logic for `rt worktree each` — arg parsing, target selection, name * formatting, and result summary. No IO: everything here is deterministic and * unit-tested. The command module (commands/worktree.ts) supplies the real - * worktree list, picker, and process execution. + * worktree list (sourced from the daemon's `worktree:list`, or the read-only + * git fallback when the daemon is down), the picker, and process execution. */ -import type { WorktreeBinding } from "./daemon/parking-lot.ts"; - -export type SelectionMode = "all" | "parked" | "pick"; +export type SelectionMode = "all" | "on-deck" | "pick"; export interface ParsedEachArgs { mode: SelectionMode; @@ -26,27 +25,47 @@ export interface EachResult { } /** - * Split raw CLI args into a selection mode and the command string. `--all` and - * `--parked` are recognized anywhere in the args; every other token is part of - * the command, joined with spaces. Neither flag → interactive pick mode. + * A worktree binding as `worktree each` needs it. `{path, branch}` always + * comes from either the daemon's `worktree:list` (registry-aware — `state` + * present) or the read-only git fallback (`lib/git-worktrees.ts`, no `state`) + * used when the daemon is unreachable — `each` is the one lifecycle command + * allowed that fallback, since it's read-only. + */ +export interface WorktreeBinding { + path: string; + branch: string | null; + /** Registry state ("on-deck", "claimed", ...); absent from the git-only fallback. */ + state?: string; +} + +/** + * Split raw CLI args into a selection mode and the command string. `--all` + * and `--on-deck` are recognized anywhere in the args; every other token is + * part of the command, joined with spaces. `--parked` is a hidden alias for + * `--on-deck`, kept for one release so muscle memory doesn't break mid- + * migration off the old parking-lot terminology. Neither flag → interactive + * pick mode. */ export function parseEachArgs(args: string[]): ParsedEachArgs { const all = args.includes("--all"); - const parked = args.includes("--parked"); - if (all && parked) { - return { mode: "all", command: "", error: "--all and --parked are mutually exclusive" }; + const onDeck = args.includes("--on-deck") || args.includes("--parked"); + if (all && onDeck) { + return { mode: "all", command: "", error: "--all and --on-deck are mutually exclusive" }; } - const mode: SelectionMode = all ? "all" : parked ? "parked" : "pick"; - const command = args.filter(a => a !== "--all" && a !== "--parked").join(" ").trim(); + const mode: SelectionMode = all ? "all" : onDeck ? "on-deck" : "pick"; + const command = args + .filter((a) => a !== "--all" && a !== "--on-deck" && a !== "--parked") + .join(" ") + .trim(); if (!command) { return { mode, command: "", error: "no command given — usage: rt worktree each ''" }; } return { mode, command }; } -/** A worktree is parked when it sits on its own parking-lot/ slot. */ -export function isParked(b: WorktreeBinding): boolean { - return b.index > 0 && b.branch === `parking-lot/${b.index}`; +/** A binding is on-deck when the registry says so (never true for the git-only fallback). */ +export function isOnDeck(b: WorktreeBinding): boolean { + return b.state === "on-deck"; } /** @@ -54,7 +73,7 @@ export function isParked(b: WorktreeBinding): boolean { * unchanged — the caller runs the picker over the full list. */ export function filterTargets(bindings: WorktreeBinding[], mode: SelectionMode): WorktreeBinding[] { - if (mode === "parked") return bindings.filter(isParked); + if (mode === "on-deck") return bindings.filter(isOnDeck); return bindings; // "all" and "pick" both start from the full list } diff --git a/lib/worktree/__tests__/config.test.ts b/lib/worktree/__tests__/config.test.ts new file mode 100644 index 00000000..ee0fd019 --- /dev/null +++ b/lib/worktree/__tests__/config.test.ts @@ -0,0 +1,241 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { mkdtempSync, realpathSync, writeFileSync, mkdirSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { writeJson } from "../../json-store.ts"; +import { repoDataDir, rtDir } from "../../rt-paths.ts"; +import { + loadWorktreeRepoConfig, + resolveImplicitInstall, + resolveReadySteps, + loadWorktreeAppConfig, + type WorktreeRepoConfig, +} from "../config.ts"; + +function tmpRepoPath(prefix: string): string { + return realpathSync(mkdtempSync(join(tmpdir(), prefix))); +} + +describe("worktree config", () => { + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtcfg-home-"))); + }); + + describe("loadWorktreeRepoConfig", () => { + test("defaults when config.json is missing", () => { + const repoPath = tmpRepoPath("rtcfg-repo-"); + const cfg = loadWorktreeRepoConfig("myrepo", repoPath); + expect(cfg).toEqual({ + onDeck: 0, + root: join(repoPath, ".worktrees"), + branchFormat: "-", + ready: [], + }); + }); + + test("defaults when config.json exists but has no 'worktrees' key", () => { + const repoPath = tmpRepoPath("rtcfg-repo-"); + writeJson(join(repoDataDir("myrepo"), "config.json"), { + setup: [], + clean: [], + startScript: "start", + open: { base: "" }, + }); + const cfg = loadWorktreeRepoConfig("myrepo", repoPath); + expect(cfg).toEqual({ + onDeck: 0, + root: join(repoPath, ".worktrees"), + branchFormat: "-", + ready: [], + }); + }); + + test("declared block round-trips", () => { + const repoPath = tmpRepoPath("rtcfg-repo-"); + const declared = { + onDeck: 2, + namePool: ["hogwarts", "bellatrix"], + root: "/absolute/path/to/assured", + branchFormat: "", + ready: [ + { run: "pnpm genTypes", when: "changed:db/schema/**" }, + ], + }; + writeJson(join(repoDataDir("myrepo"), "config.json"), { + setup: [{ label: "x", command: "y" }], + worktrees: declared, + }); + const cfg = loadWorktreeRepoConfig("myrepo", repoPath); + expect(cfg).toEqual(declared); + }); + + test("expands a leading ~/ in root against call-time HOME", () => { + const repoPath = tmpRepoPath("rtcfg-repo-"); + writeJson(join(repoDataDir("myrepo"), "config.json"), { + worktrees: { root: "~/wt-root" }, + }); + const cfg = loadWorktreeRepoConfig("myrepo", repoPath); + expect(cfg.root).toBe(join(process.env.HOME!, "wt-root")); + }); + + test("leaves an absolute root unchanged", () => { + const repoPath = tmpRepoPath("rtcfg-repo-"); + writeJson(join(repoDataDir("myrepo"), "config.json"), { + worktrees: { root: "/absolute/wt-root" }, + }); + const cfg = loadWorktreeRepoConfig("myrepo", repoPath); + expect(cfg.root).toBe("/absolute/wt-root"); + }); + }); + + describe("resolveImplicitInstall", () => { + test("no package.json -> null", () => { + const repoPath = tmpRepoPath("rtcfg-noinstall-"); + expect(resolveImplicitInstall(repoPath)).toBeNull(); + }); + + test("packageManager field prefix -> matching manager step", () => { + const repoPath = tmpRepoPath("rtcfg-pm-"); + writeFileSync( + join(repoPath, "package.json"), + JSON.stringify({ name: "x", packageManager: "pnpm@9.1.0" }) + ); + expect(resolveImplicitInstall(repoPath)).toEqual({ + run: "pnpm install --side-effects-cache", + when: "changed:pnpm-lock.yaml", + }); + }); + + test("lockfile sniff: only bun.lockb present -> bun step", () => { + const repoPath = tmpRepoPath("rtcfg-bunlock-"); + writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "x" })); + writeFileSync(join(repoPath, "bun.lockb"), ""); + expect(resolveImplicitInstall(repoPath)).toEqual({ + run: "bun install", + when: "changed:bun.lock*", + }); + }); + + test("lockfile sniff: bun.lock (text lockfile) -> bun step", () => { + const repoPath = tmpRepoPath("rtcfg-bunlock2-"); + writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "x" })); + writeFileSync(join(repoPath, "bun.lock"), ""); + expect(resolveImplicitInstall(repoPath)).toEqual({ + run: "bun install", + when: "changed:bun.lock*", + }); + }); + + test("lockfile sniff: pnpm-lock.yaml -> pnpm step", () => { + const repoPath = tmpRepoPath("rtcfg-pnpmlock-"); + writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "x" })); + writeFileSync(join(repoPath, "pnpm-lock.yaml"), ""); + expect(resolveImplicitInstall(repoPath)).toEqual({ + run: "pnpm install --side-effects-cache", + when: "changed:pnpm-lock.yaml", + }); + }); + + test("lockfile sniff: yarn.lock -> yarn step", () => { + const repoPath = tmpRepoPath("rtcfg-yarnlock-"); + writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "x" })); + writeFileSync(join(repoPath, "yarn.lock"), ""); + expect(resolveImplicitInstall(repoPath)).toEqual({ + run: "yarn install", + when: "changed:yarn.lock", + }); + }); + + test("package.json alone (no packageManager, no lockfile) -> npm step", () => { + const repoPath = tmpRepoPath("rtcfg-npm-"); + writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "x" })); + expect(resolveImplicitInstall(repoPath)).toEqual({ + run: "npm install", + when: "changed:package-lock.json", + }); + }); + }); + + describe("resolveReadySteps", () => { + test("prepends implicit install when not declared", () => { + const repoPath = tmpRepoPath("rtcfg-resolve1-"); + writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "x" })); + writeFileSync(join(repoPath, "pnpm-lock.yaml"), ""); + const cfg: WorktreeRepoConfig = { + onDeck: 0, + root: join(repoPath, ".worktrees"), + branchFormat: "-", + ready: [{ run: "node scripts/gen-types.js", when: "changed:db/schema/**" }], + }; + expect(resolveReadySteps(cfg, repoPath)).toEqual([ + { run: "pnpm install --side-effects-cache", when: "changed:pnpm-lock.yaml" }, + { run: "node scripts/gen-types.js", when: "changed:db/schema/**" }, + ]); + }); + + test("does not double the implicit install when config declares its own pnpm line", () => { + const repoPath = tmpRepoPath("rtcfg-resolve2-"); + writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "x" })); + writeFileSync(join(repoPath, "pnpm-lock.yaml"), ""); + const cfg: WorktreeRepoConfig = { + onDeck: 0, + root: join(repoPath, ".worktrees"), + branchFormat: "-", + ready: [ + { run: "pnpm install --side-effects-cache", when: "changed:pnpm-lock.yaml" }, + { run: "pnpm genTypes", when: "changed:db/schema/**" }, + ], + }; + expect(resolveReadySteps(cfg, repoPath)).toEqual(cfg.ready); + }); + + test("a declared non-install step for the manager does not suppress the implicit install", () => { + const repoPath = tmpRepoPath("rtcfg-resolve4-"); + writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "x" })); + writeFileSync(join(repoPath, "pnpm-lock.yaml"), ""); + const cfg: WorktreeRepoConfig = { + onDeck: 0, + root: join(repoPath, ".worktrees"), + branchFormat: "-", + ready: [{ run: "pnpm lint" }], + }; + expect(resolveReadySteps(cfg, repoPath)).toEqual([ + { run: "pnpm install --side-effects-cache", when: "changed:pnpm-lock.yaml" }, + { run: "pnpm lint" }, + ]); + }); + + test("no implicit install (no package.json) -> just declared steps", () => { + const repoPath = tmpRepoPath("rtcfg-resolve3-"); + const cfg: WorktreeRepoConfig = { + onDeck: 0, + root: join(repoPath, ".worktrees"), + branchFormat: "-", + ready: [{ run: "echo hi" }], + }; + expect(resolveReadySteps(cfg, repoPath)).toEqual(cfg.ready); + }); + }); + + describe("loadWorktreeAppConfig", () => { + test("defaults when neither worktrees.json nor parking-lot.json exists", () => { + expect(loadWorktreeAppConfig()).toEqual({ enabled: true, killProcesses: true }); + }); + + test("seeds from parking-lot.json once, then reads the new file thereafter", () => { + mkdirSync(rtDir(), { recursive: true }); + writeFileSync( + join(rtDir(), "parking-lot.json"), + JSON.stringify({ enabled: false }) + ); + + const first = loadWorktreeAppConfig(); + expect(first).toEqual({ enabled: false, killProcesses: true }); + + // Prove it's now reading the new file, not re-seeding from the legacy one. + writeJson(join(rtDir(), "worktrees.json"), { enabled: true, killProcesses: false }); + const second = loadWorktreeAppConfig(); + expect(second).toEqual({ enabled: true, killProcesses: false }); + }); + }); +}); diff --git a/lib/worktree/__tests__/create.test.ts b/lib/worktree/__tests__/create.test.ts new file mode 100644 index 00000000..0f880de2 --- /dev/null +++ b/lib/worktree/__tests__/create.test.ts @@ -0,0 +1,117 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { execSync } from "child_process"; +import { existsSync, mkdtempSync, readFileSync, realpathSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { writeJson } from "../../json-store.ts"; +import { repoDataDir } from "../../rt-paths.ts"; +import { loadRegistry } from "../registry.ts"; +import { branchExistsLocalAsync, listWorktreesAsync } from "../git-async.ts"; +import { createTree, type CreateDeps } from "../create.ts"; + +function makeRepo(): string { + // realpathSync: git canonicalizes /var -> /private/var on macOS (Global Constraints) + const dir = realpathSync(mkdtempSync(join(tmpdir(), "rtcreate-"))); + execSync( + "git init -b main && git -c user.email=t@t -c user.name=t commit --allow-empty -m init", + { cwd: dir, shell: "/bin/zsh" } + ); + return dir; +} + +/** Bare-clone `repo` as its own "origin" and fetch, so remoteDefaultRef resolves origin/main. */ +function addBareOrigin(repo: string): void { + const bare = mkdtempSync(join(tmpdir(), "rtcreate-bare-")); + execSync( + `git clone --bare ${repo} ${bare}/o.git && git -C ${repo} remote add origin ${bare}/o.git && git -C ${repo} fetch origin`, + { shell: "/bin/zsh" } + ); +} + +function makeDeps(repoName: string, repoPath: string, events: Array<{ type: string; data: unknown }>): CreateDeps { + return { + repoName, + repoPath, + emit: (type, data) => events.push({ type, data }), + log: { info: () => {}, warn: () => {} }, + }; +} + +describe("createTree", () => { + let repo: string; + let repoName: string; + let events: Array<{ type: string; data: unknown }>; + + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtcreate-home-"))); + repo = makeRepo(); + addBareOrigin(repo); + repoName = "acme"; + events = []; + }); + + test("happy path: registry ends with one on-deck record on origin default sha", async () => { + const expectedSha = execSync("git rev-parse HEAD", { cwd: repo, encoding: "utf8" }).trim(); + + const deps = makeDeps(repoName, repo, events); + const result = await createTree(deps); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.tree.state).toBe("on-deck"); + expect(result.tree.branch).toBe(`on-deck/${result.tree.name}`); + expect(result.tree.readyStamp).toBe(expectedSha); + + const registry = loadRegistry(repoName); + expect(registry.length).toBe(1); + expect(registry[0]!.state).toBe("on-deck"); + expect(registry[0]!.name).toBe(result.tree.name); + + const worktrees = (await listWorktreesAsync(repo))!; + const entry = worktrees.find((w) => w.path === result.tree.path); + expect(entry).toBeDefined(); + expect(entry!.branch).toBe(`on-deck/${result.tree.name}`); + + expect(events.some((e) => e.type === "worktree:created")).toBe(true); + }); + + test("in-repo default root writes .git/info/exclude", async () => { + const deps = makeDeps(repoName, repo, events); + const result = await createTree(deps); + + expect(result.ok).toBe(true); + const excludePath = join(repo, ".git", "info", "exclude"); + expect(existsSync(excludePath)).toBe(true); + const content = readFileSync(excludePath, "utf8"); + expect(content).toContain(".worktrees/"); + }); + + test("failing ready step scraps the worktree, branch, and registry entry", async () => { + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { + namePool: ["failtree"], + ready: [{ run: "exit 1" }], + }, + }); + + const deps = makeDeps(repoName, repo, events); + const result = await createTree(deps); + + expect(result.ok).toBe(false); + if (result.ok || result.error !== "create-failed") throw new Error("expected create-failed"); + expect(result.error).toBe("create-failed"); + expect(result.failedStep).toBe("exit 1"); + + const path = join(repo, ".worktrees", "failtree"); + expect(existsSync(path)).toBe(false); + + const worktrees = (await listWorktreesAsync(repo))!; + expect(worktrees.some((w) => w.path === path)).toBe(false); + + expect(await branchExistsLocalAsync(repo, "on-deck/failtree")).toBe(false); + + const registry = loadRegistry(repoName); + expect(registry.length).toBe(0); + }); +}); diff --git a/lib/worktree/__tests__/dispose.test.ts b/lib/worktree/__tests__/dispose.test.ts new file mode 100644 index 00000000..761030af --- /dev/null +++ b/lib/worktree/__tests__/dispose.test.ts @@ -0,0 +1,642 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { execSync } from "child_process"; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync, realpathSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { repoDataDir } from "../../rt-paths.ts"; +import { saveSyncConfig } from "../../sync-config.ts"; +import { loadRegistry, saveRegistry, type TreeRecord } from "../registry.ts"; +import { branchExistsLocalAsync, listWorktreesAsync, remoteRefExists } from "../git-async.ts"; +import { hasFreshAttendantLease } from "../lease.ts"; +import { + classifyDirtyAsync, + disposeTree, + STATUS_FAILED_BLOCKER, + type DisposeDeps, +} from "../dispose.ts"; + +const GIT_ID = "-c user.email=t@t -c user.name=t"; + +/** A repo whose initial commit carries a tracked `gen.txt` (the "generated" file). */ +function makeRepo(): string { + // realpathSync: git canonicalizes /var -> /private/var on macOS (Global Constraints) + const dir = realpathSync(mkdtempSync(join(tmpdir(), "rtdispose-"))); + writeFileSync(join(dir, "gen.txt"), "alpha\nbeta\n"); + execSync(`git init -b main && git add gen.txt && git ${GIT_ID} commit -m init`, { + cwd: dir, + shell: "/bin/zsh", + stdio: "pipe", + }); + return dir; +} + +/** Bare-clone `repo` as its own "origin" and fetch. Returns the bare repo path. */ +function addBareOrigin(repo: string): string { + const bare = join(realpathSync(mkdtempSync(join(tmpdir(), "rtdispose-bare-"))), "o.git"); + execSync( + `git clone --bare ${repo} ${bare} && git -C ${repo} remote add origin ${bare} && git -C ${repo} fetch origin`, + { shell: "/bin/zsh", stdio: "pipe" }, + ); + return bare; +} + +/** Add a worktree on a fresh branch cut from `base`, and return its (canonical) path. */ +function addTree(repo: string, name: string, branch: string, base = "origin/main"): string { + const path = join(repo, ".worktrees", name); + execSync(`git -C ${repo} worktree add -b ${branch} ${path} ${base}`, { + shell: "/bin/zsh", + stdio: "pipe", + }); + return path; +} + +function commitIn(worktree: string, file: string, content: string): void { + writeFileSync(join(worktree, file), content); + execSync(`git add ${file} && git ${GIT_ID} commit -m change`, { + cwd: worktree, + shell: "/bin/zsh", + stdio: "pipe", + }); +} + +function register(repoName: string, rec: TreeRecord): TreeRecord { + saveRegistry(repoName, [...loadRegistry(repoName), rec]); + return rec; +} + +function ephemeral(name: string, path: string, branch: string, extra: Partial = {}): TreeRecord { + return { + name, + path, + kind: "ephemeral", + state: "claimed", + branch, + createdAt: new Date(Date.now() - 60 * 60_000).toISOString(), + ...extra, + }; +} + +function writeLease(filename: string, body: unknown): void { + const dir = join(process.env.HOME!, ".mattstack", "ci-attendants"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, filename), typeof body === "string" ? body : JSON.stringify(body)); +} + +describe("hasFreshAttendantLease", () => { + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtlease-home-"))); + }); + + test("no lease directory at all → false", () => { + expect(hasFreshAttendantLease(42)).toBe(false); + }); + + test("fresh lease (real BOARD-10 shape) matches by filename suffix", () => { + writeLease("assured-dev-42.json", { + mr: "https://gitlab.com/acme/assured-dev/-/merge_requests/42", + heartbeatAt: Date.now(), + ttlSeconds: 300, + }); + expect(hasFreshAttendantLease(42)).toBe(true); + expect(hasFreshAttendantLease(43)).toBe(false); + }); + + test("lease whose filename carries no iid still matches by the mr URL's trailing iid", () => { + writeLease("attendant.json", { + mr: "https://gitlab.com/acme/assured-dev/-/merge_requests/77", + heartbeatAt: Date.now(), + ttlSeconds: 300, + }); + expect(hasFreshAttendantLease(77)).toBe(true); + expect(hasFreshAttendantLease(7)).toBe(false); + }); + + test("heartbeat older than ttlSeconds → stale", () => { + writeLease("assured-dev-42.json", { + mr: "https://gitlab.com/acme/assured-dev/-/merge_requests/42", + heartbeatAt: Date.now() - 400_000, + ttlSeconds: 300, + }); + expect(hasFreshAttendantLease(42)).toBe(false); + }); + + test("missing ttlSeconds falls back to 300s", () => { + writeLease("assured-dev-9.json", { + mr: "https://gitlab.com/acme/assured-dev/-/merge_requests/9", + heartbeatAt: Date.now() - 100_000, + }); + expect(hasFreshAttendantLease(9)).toBe(true); + + writeLease("assured-dev-9.json", { + mr: "https://gitlab.com/acme/assured-dev/-/merge_requests/9", + heartbeatAt: Date.now() - 400_000, + }); + expect(hasFreshAttendantLease(9)).toBe(false); + }); + + test("ISO-string heartbeatAt is tolerated", () => { + writeLease("assured-dev-5.json", { + mr: "https://gitlab.com/acme/assured-dev/-/merge_requests/5", + heartbeatAt: new Date().toISOString(), + ttlSeconds: 300, + }); + expect(hasFreshAttendantLease(5)).toBe(true); + }); + + test("garbage files never block disposal", () => { + writeLease("assured-dev-42.json", "{ not json at all"); + writeLease("assured-dev-43.json", { mr: 12345, heartbeatAt: "nonsense" }); + expect(hasFreshAttendantLease(42)).toBe(false); + expect(hasFreshAttendantLease(43)).toBe(false); + }); + + test("`now` is injectable", () => { + const heartbeatAt = 1_786_998_298_000; + writeLease("assured-dev-42.json", { + mr: "https://gitlab.com/acme/assured-dev/-/merge_requests/42", + heartbeatAt, + ttlSeconds: 300, + }); + expect(hasFreshAttendantLease(42, heartbeatAt + 1_000)).toBe(true); + expect(hasFreshAttendantLease(42, heartbeatAt + 300_001)).toBe(false); + }); +}); + +describe("classifyDirtyAsync", () => { + let repo: string; + let tree: string; + const repoName = "acme"; + + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtdispose-home-"))); + repo = makeRepo(); + addBareOrigin(repo); + tree = addTree(repo, "tree-a", "feature-a"); + }); + + test("clean tree classifies as nothing at all", async () => { + const result = await classifyDirtyAsync(tree, repoName); + expect(result.discard).toEqual([]); + expect(result.blockers).toEqual([]); + }); + + test("untracked file is a blocker", async () => { + writeFileSync(join(tree, "scratch.txt"), "hi\n"); + const result = await classifyDirtyAsync(tree, repoName); + expect(result.blockers).toEqual(["scratch.txt"]); + expect(result.discard).toEqual([]); + }); + + test("declared generated file with whitespace-only drift is discardable", async () => { + saveSyncConfig(repoDataDir(repoName), { + autoResolve: [{ glob: "gen.txt", strategy: "theirs" }], + }); + writeFileSync(join(tree, "gen.txt"), "alpha \nbeta\n"); + + const result = await classifyDirtyAsync(tree, repoName); + expect(result.discard).toEqual(["gen.txt"]); + expect(result.blockers).toEqual([]); + }); + + test("declared generated file with a substantive edit is a blocker", async () => { + saveSyncConfig(repoDataDir(repoName), { + autoResolve: [{ glob: "gen.txt", strategy: "theirs" }], + }); + writeFileSync(join(tree, "gen.txt"), "alpha\nbeta\ngamma\n"); + + const result = await classifyDirtyAsync(tree, repoName); + expect(result.discard).toEqual([]); + expect(result.blockers).toEqual(["gen.txt"]); + }); + + test("undeclared modified file is a blocker even when whitespace-only", async () => { + writeFileSync(join(tree, "gen.txt"), "alpha \nbeta\n"); + const result = await classifyDirtyAsync(tree, repoName); + expect(result.blockers).toEqual(["gen.txt"]); + }); + + test("a failing git status fails CLOSED, never clean", async () => { + const gone = join(tree, "nope", "not-a-worktree"); + const result = await classifyDirtyAsync(gone, repoName); + expect(result.blockers).toEqual([STATUS_FAILED_BLOCKER]); + expect(result.discard).toEqual([]); + + // Same for a directory git refuses to read as a repo. + const notARepo = realpathSync(mkdtempSync(join(tmpdir(), "rtdispose-bare-dir-"))); + const outside = await classifyDirtyAsync(notARepo, repoName); + expect(outside.blockers).toEqual([STATUS_FAILED_BLOCKER]); + }); +}); + +describe("disposeTree", () => { + const repoName = "acme"; + let repo: string; + let events: Array<{ type: string; data: unknown }>; + + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtdispose-home-"))); + repo = makeRepo(); + addBareOrigin(repo); + events = []; + }); + + function makeDeps(overrides: Partial = {}): DisposeDeps { + return { + repoName, + repoPath: repo, + cacheEntries: {}, + emit: (type, data) => events.push({ type, data }), + log: { info: () => {}, warn: () => {} }, + killProcesses: false, + ...overrides, + }; + } + + test("kind=main refuses even under force", async () => { + const rec = register(repoName, { + name: "main", + path: repo, + kind: "main", + branch: "main", + createdAt: new Date().toISOString(), + }); + + const result = await disposeTree(makeDeps(), rec, { force: true }); + expect(result).toEqual({ disposed: false, refusal: "kind-main" }); + expect(existsSync(repo)).toBe(true); + expect(loadRegistry(repoName).length).toBe(1); + }); + + test("kind=unmanaged refuses even under force", async () => { + const path = addTree(repo, "theirs", "someone-else"); + const rec = register(repoName, { + name: "theirs", + path, + kind: "unmanaged", + branch: "someone-else", + createdAt: new Date().toISOString(), + }); + + const result = await disposeTree(makeDeps(), rec, { force: true }); + expect(result).toEqual({ disposed: false, refusal: "kind-unmanaged" }); + expect(existsSync(path)).toBe(true); + }); + + test("dirty tracked file refuses with \"dirty\"", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + writeFileSync(join(path, "gen.txt"), "alpha\nbeta\ngamma\n"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + + const result = await disposeTree(makeDeps(), rec, {}); + expect(result).toEqual({ disposed: false, refusal: "dirty" }); + expect(existsSync(path)).toBe(true); + }); + + test("--force disposes a dirty tree", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + writeFileSync(join(path, "gen.txt"), "alpha\nbeta\ngamma\n"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + + const result = await disposeTree(makeDeps(), rec, { force: true }); + expect(result).toEqual({ disposed: true }); + expect(existsSync(path)).toBe(false); + }); + + test("whitespace-only drift in a declared generated file does not refuse", async () => { + saveSyncConfig(repoDataDir(repoName), { + autoResolve: [{ glob: "gen.txt", strategy: "theirs" }], + }); + const path = addTree(repo, "tree-a", "feature-a"); + writeFileSync(join(path, "gen.txt"), "alpha \nbeta\n"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + + const result = await disposeTree(makeDeps(), rec, {}); + expect(result).toEqual({ disposed: true }); + expect(existsSync(path)).toBe(false); + const disposed = events.find((e) => e.type === "worktree:disposed"); + expect(disposed).toBeDefined(); + expect((disposed!.data as { discarded: string[] }).discarded).toEqual(["gen.txt"]); + }); + + test("unpushed commit refuses with \"unpushed\"", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + commitIn(path, "new.txt", "local only\n"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + + const result = await disposeTree(makeDeps(), rec, {}); + expect(result).toEqual({ disposed: false, refusal: "unpushed" }); + expect(existsSync(path)).toBe(true); + + const forced = await disposeTree(makeDeps(), rec, { force: true }); + expect(forced).toEqual({ disposed: true }); + }); + + test("pushed branch with no MR disposes via the origin/ anchor", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + commitIn(path, "new.txt", "pushed\n"); + execSync(`git -C ${path} push origin feature-a && git -C ${repo} fetch origin`, { + shell: "/bin/zsh", + stdio: "pipe", + }); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + + const result = await disposeTree(makeDeps(), rec, {}); + expect(result).toEqual({ disposed: true }); + + // worktree, branch, and registry entry all gone + expect(existsSync(path)).toBe(false); + expect((await listWorktreesAsync(repo))!.some((w) => w.path === path)).toBe(false); + expect(await branchExistsLocalAsync(repo, "feature-a")).toBe(false); + expect(loadRegistry(repoName).length).toBe(0); + + const disposed = events.find((e) => e.type === "worktree:disposed"); + expect(disposed).toBeDefined(); + expect((disposed!.data as { tree: string }).tree).toBe("tree-a"); + }); + + test("auto disposal anchors on the MR head sha, not the default branch", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + commitIn(path, "new.txt", "squash-merged upstream\n"); + const sha = execSync(`git -C ${path} rev-parse HEAD`, { encoding: "utf8" }).trim(); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a", { + claimedAt: new Date(Date.now() - 60 * 60_000).toISOString(), + })); + + const deps = makeDeps({ + cacheEntries: { "feature-a": { mr: { iid: 42, sha, state: "merged" }, repoName } }, + }); + const result = await disposeTree(deps, rec, { auto: true }); + expect(result).toEqual({ disposed: true }); + expect(existsSync(path)).toBe(false); + }); + + test("a present-but-unresolvable MR sha refuses with \"mr-sha-unresolvable\"", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a", { + claimedAt: new Date(Date.now() - 60 * 60_000).toISOString(), + })); + + const deps = makeDeps({ + cacheEntries: { + "feature-a": { mr: { iid: 42, sha: "0".repeat(40), state: "merged" }, repoName }, + }, + }); + const result = await disposeTree(deps, rec, { auto: true }); + expect(result).toEqual({ disposed: false, refusal: "mr-sha-unresolvable" }); + expect(existsSync(path)).toBe(true); + }); + + test("an MR with NO sha falls back to the remote anchor instead of refusing", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a", { + claimedAt: new Date(Date.now() - 60 * 60_000).toISOString(), + })); + + // sha absent entirely (the projection drops it for many merged MRs) + const deps = makeDeps({ cacheEntries: { "feature-a": { mr: { iid: 42, state: "merged" }, repoName } } }); + const result = await disposeTree(deps, rec, { auto: true }); + expect(result).toEqual({ disposed: true }); + expect(existsSync(path)).toBe(false); + }); + + test("an MR with a null sha still gets a real containment check", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + commitIn(path, "new.txt", "local only\n"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a", { + claimedAt: new Date(Date.now() - 60 * 60_000).toISOString(), + })); + + const deps = makeDeps({ + cacheEntries: { "feature-a": { mr: { iid: 42, sha: null, state: "merged" }, repoName } }, + }); + const result = await disposeTree(deps, rec, { auto: true }); + expect(result).toEqual({ disposed: false, refusal: "unpushed" }); + }); + + test("auto disposal refuses \"unpushed\" when HEAD is ahead of the MR head sha", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + commitIn(path, "new.txt", "in the MR\n"); + const sha = execSync(`git -C ${path} rev-parse HEAD`, { encoding: "utf8" }).trim(); + commitIn(path, "later.txt", "never pushed\n"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a", { + claimedAt: new Date(Date.now() - 60 * 60_000).toISOString(), + })); + + const deps = makeDeps({ + cacheEntries: { "feature-a": { mr: { iid: 42, sha, state: "merged" }, repoName } }, + }); + const result = await disposeTree(deps, rec, { auto: true }); + expect(result).toEqual({ disposed: false, refusal: "unpushed" }); + }); + + test("MANUAL disposal of a squash-merged tree uses the MR sha anchor", async () => { + // The shape that broke: squash-merged upstream, source branch deleted, so + // the tip is an ancestor of nothing the remote still has. Before the fix a + // human got "unpushed" here and was pushed toward --force, which also + // strips the dirty guard. + const path = addTree(repo, "tree-a", "feature-a"); + commitIn(path, "new.txt", "squash-merged upstream\n"); + const sha = execSync(`git -C ${path} rev-parse HEAD`, { encoding: "utf8" }).trim(); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + expect(await remoteRefExists(path, "feature-a")).toBe(false); + + const deps = makeDeps({ + cacheEntries: { "feature-a": { mr: { iid: 42, sha, state: "merged" }, repoName } }, + }); + const result = await disposeTree(deps, rec, { auto: false }); + expect(result).toEqual({ disposed: true }); + expect(existsSync(path)).toBe(false); + }); + + test("manual disposal with an OPEN MR still uses the remote anchor", async () => { + // An open MR's head sha would happily contain HEAD; the tree is still the + // author's live work, so it must be judged on what is actually pushed. + const path = addTree(repo, "tree-a", "feature-a"); + commitIn(path, "new.txt", "not pushed yet\n"); + const sha = execSync(`git -C ${path} rev-parse HEAD`, { encoding: "utf8" }).trim(); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + + const deps = makeDeps({ + cacheEntries: { "feature-a": { mr: { iid: 42, sha, state: "opened" }, repoName } }, + }); + const result = await disposeTree(deps, rec, { auto: false }); + expect(result).toEqual({ disposed: false, refusal: "unpushed" }); + expect(existsSync(path)).toBe(true); + }); + + test("a fresh attendant lease on the joined MR refuses with \"attended\"", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + writeLease("acme-42.json", { + mr: "https://gitlab.com/acme/acme/-/merge_requests/42", + heartbeatAt: Date.now(), + ttlSeconds: 300, + }); + + const deps = makeDeps({ + cacheEntries: { "feature-a": { mr: { iid: 42, sha: null }, repoName } }, + }); + const result = await disposeTree(deps, rec, {}); + expect(result).toEqual({ disposed: false, refusal: "attended" }); + expect(existsSync(path)).toBe(true); + + const forced = await disposeTree(deps, rec, { force: true }); + expect(forced).toEqual({ disposed: true }); + }); + + test("a stale attendant lease does not refuse", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + writeLease("acme-42.json", { + mr: "https://gitlab.com/acme/acme/-/merge_requests/42", + heartbeatAt: Date.now() - 400_000, + ttlSeconds: 300, + }); + + const deps = makeDeps({ + cacheEntries: { "feature-a": { mr: { iid: 42, sha: null }, repoName } }, + }); + const result = await disposeTree(deps, rec, {}); + expect(result).toEqual({ disposed: true }); + }); + + test("auto disposal of a just-claimed tree refuses with \"grace\"; explicit disposal proceeds", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a", { + claimedAt: new Date().toISOString(), + })); + + const auto = await disposeTree(makeDeps(), rec, { auto: true }); + expect(auto).toEqual({ disposed: false, refusal: "grace" }); + expect(existsSync(path)).toBe(true); + + const explicit = await disposeTree(makeDeps(), rec, { auto: false }); + expect(explicit).toEqual({ disposed: true }); + expect(existsSync(path)).toBe(false); + }); + + test("a claim older than the 10-minute grace window auto-disposes", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a", { + claimedAt: new Date(Date.now() - 11 * 60_000).toISOString(), + })); + + const result = await disposeTree(makeDeps(), rec, { auto: true }); + expect(result).toEqual({ disposed: true }); + }); + + test("a cache entry with no repoName still joins its MR", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + writeLease("acme-42.json", { + mr: "https://gitlab.com/acme/acme/-/merge_requests/42", + heartbeatAt: Date.now(), + ttlSeconds: 300, + }); + + const deps = makeDeps({ cacheEntries: { "feature-a": { mr: { iid: 42, sha: null } } } }); + expect(await disposeTree(deps, rec, {})).toEqual({ disposed: false, refusal: "attended" }); + }); + + test("a cache entry attributed to another repo does not join", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + writeLease("other-42.json", { + mr: "https://gitlab.com/other/other/-/merge_requests/42", + heartbeatAt: Date.now(), + ttlSeconds: 300, + }); + + const deps = makeDeps({ + cacheEntries: { "feature-a": { mr: { iid: 42, sha: null }, repoName: "other-repo" } }, + }); + expect(await disposeTree(deps, rec, {})).toEqual({ disposed: true }); + }); + + test("a failing git status refuses \"dirty\" rather than disposing", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, { + ...ephemeral("tree-a", path, "feature-a"), + path: join(path, "gone", "missing"), + }); + + const result = await disposeTree(makeDeps(), rec, {}); + expect(result).toEqual({ disposed: false, refusal: "dirty" }); + expect(existsSync(path)).toBe(true); + expect(loadRegistry(repoName).length).toBe(1); + }); + + test("a worktree git refuses to remove refuses \"remove-failed\" and keeps the registry row", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + // A locked worktree needs --force twice; one --force fails and leaves the tree. + execSync(`git -C ${repo} worktree lock ${path}`, { shell: "/bin/zsh", stdio: "pipe" }); + + const result = await disposeTree(makeDeps(), rec, { force: true }); + expect(result).toEqual({ disposed: false, refusal: "remove-failed" }); + expect(existsSync(path)).toBe(true); + expect(loadRegistry(repoName).length).toBe(1); + expect(await branchExistsLocalAsync(repo, "feature-a")).toBe(true); + }); + + test("guard order: dirty is reported before unpushed", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + commitIn(path, "new.txt", "local only\n"); + writeFileSync(join(path, "gen.txt"), "alpha\nbeta\ngamma\n"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + + const result = await disposeTree(makeDeps(), rec, {}); + expect(result).toEqual({ disposed: false, refusal: "dirty" }); + }); + + test("guard order: attended is reported before grace", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a", { + claimedAt: new Date().toISOString(), + })); + writeLease("acme-42.json", { + mr: "https://gitlab.com/acme/acme/-/merge_requests/42", + heartbeatAt: Date.now(), + ttlSeconds: 300, + }); + + const sha = execSync(`git -C ${path} rev-parse HEAD`, { encoding: "utf8" }).trim(); + const deps = makeDeps({ + cacheEntries: { "feature-a": { mr: { iid: 42, sha, state: "merged" }, repoName } }, + }); + const result = await disposeTree(deps, rec, { auto: true }); + expect(result).toEqual({ disposed: false, refusal: "attended" }); + }); + + test("killProcesses wiring runs the killer without disturbing disposal", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + + const result = await disposeTree(makeDeps({ killProcesses: true }), rec, {}); + expect(result).toEqual({ disposed: true }); + expect(existsSync(path)).toBe(false); + }); + + test("a branchless record disposes and prunes the registry", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, { ...ephemeral("tree-a", path, "feature-a"), branch: null }); + + const result = await disposeTree(makeDeps(), rec, {}); + expect(result).toEqual({ disposed: true }); + expect(existsSync(path)).toBe(false); + expect(loadRegistry(repoName).length).toBe(0); + }); + + test("dispose tolerates a branch git already deleted", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + execSync(`git -C ${repo} worktree remove --force ${path} && git -C ${repo} branch -D feature-a`, { + shell: "/bin/zsh", + stdio: "pipe", + }); + + const result = await disposeTree(makeDeps(), rec, { force: true }); + expect(result).toEqual({ disposed: true }); + expect(loadRegistry(repoName).length).toBe(0); + }); +}); diff --git a/lib/worktree/__tests__/git-async.test.ts b/lib/worktree/__tests__/git-async.test.ts new file mode 100644 index 00000000..3d78ddce --- /dev/null +++ b/lib/worktree/__tests__/git-async.test.ts @@ -0,0 +1,125 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { execSync } from "child_process"; +import { mkdtempSync, readFileSync, realpathSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { + currentBranchAsync, + branchExistsLocalAsync, + isAncestorAsync, + remoteDefaultRef, + ensureInfoExclude, + listWorktreesAsync, + runGit, + gitOk, + statusPorcelainAsync, + remoteRefExists, + headSha, + stashChangesAsync, + popStashAsync, + findDesktopStashAsync, +} from "../git-async.ts"; + +function makeRepo(): string { + // realpathSync: git canonicalizes /var → /private/var on macOS (Global Constraints) + const dir = realpathSync(mkdtempSync(join(tmpdir(), "rtgit-"))); + execSync("git init -b main && git -c user.email=t@t -c user.name=t commit --allow-empty -m init", { cwd: dir, shell: "/bin/zsh" }); + return dir; +} + +describe("git-async", () => { + let repo: string; + beforeEach(() => { repo = makeRepo(); }); + + test("currentBranchAsync", async () => expect(await currentBranchAsync(repo)).toBe("main")); + + test("currentBranchAsync returns null when detached", async () => { + execSync("git checkout --detach HEAD", { cwd: repo, shell: "/bin/zsh" }); + expect(await currentBranchAsync(repo)).toBeNull(); + }); + + test("branchExistsLocalAsync", async () => { + expect(await branchExistsLocalAsync(repo, "main")).toBe(true); + expect(await branchExistsLocalAsync(repo, "nope")).toBe(false); + }); + + test("isAncestorAsync HEAD of itself", async () => expect(await isAncestorAsync(repo, "HEAD", "HEAD")).toBe(true)); + + test("remoteDefaultRef falls back with no remote", async () => expect(await remoteDefaultRef(repo)).toBe("origin/master")); + + test("remoteDefaultRef resolves origin/main on a main-default origin", async () => { + // bare-clone repo as origin, add + fetch (this fixture shape is reused by Tasks 8/11/12/13) + const bare = mkdtempSync(join(tmpdir(), "rtgit-bare-")); + execSync(`git clone --bare ${repo} ${bare}/o.git && git -C ${repo} remote add origin ${bare}/o.git && git -C ${repo} fetch origin`, { shell: "/bin/zsh" }); + expect(await remoteDefaultRef(repo)).toBe("origin/main"); + }); + + test("listWorktreesAsync lists main + added tree with branches", async () => { + execSync(`git -C ${repo} worktree add ${repo}-wt -b side`, { shell: "/bin/zsh" }); + const trees = (await listWorktreesAsync(repo))!; + expect(trees.length).toBe(2); + expect(trees[1]).toEqual({ path: `${repo}-wt`, branch: "side" }); + }); + + test("listWorktreesAsync returns null on a nonzero git exit", async () => { + const notARepo = mkdtempSync(join(tmpdir(), "rtgit-notrepo-")); + expect(await listWorktreesAsync(notARepo)).toBeNull(); + }); + + test("runGit captures stderr", async () => { + const r = await runGit(repo, ["checkout", "definitely-not-a-ref"]); + expect(r.exitCode).not.toBe(0); + expect(r.stderr.length).toBeGreaterThan(0); + }); + + test("ensureInfoExclude appends once", async () => { + expect(await ensureInfoExclude(repo, ".worktrees/")).toBe(true); + expect(await ensureInfoExclude(repo, ".worktrees/")).toBe(false); + const content = readFileSync(join(repo, ".git", "info", "exclude"), "utf8"); + expect(content.match(/\.worktrees\//g)!.length).toBe(1); + }); + + test("gitOk true/false on exit code", async () => { + expect(await gitOk(repo, ["rev-parse", "HEAD"])).toBe(true); + expect(await gitOk(repo, ["rev-parse", "no-such-ref"])).toBe(false); + }); + + test("statusPorcelainAsync reflects an untracked file", async () => { + writeFileSync(join(repo, "untracked.txt"), "hi"); + const status = await statusPorcelainAsync(repo); + expect(status).toContain("untracked.txt"); + }); + + test("remoteRefExists true only after fetch", async () => { + expect(await remoteRefExists(repo, "main")).toBe(false); + const bare = mkdtempSync(join(tmpdir(), "rtgit-bare-")); + execSync(`git clone --bare ${repo} ${bare}/o.git && git -C ${repo} remote add origin ${bare}/o.git && git -C ${repo} fetch origin`, { shell: "/bin/zsh" }); + expect(await remoteRefExists(repo, "main")).toBe(true); + }); + + test("headSha returns the current commit sha", async () => { + const expected = execSync("git rev-parse HEAD", { cwd: repo, encoding: "utf8" }).trim(); + expect(await headSha(repo)).toBe(expected); + }); + + test("stashChangesAsync / findDesktopStashAsync / popStashAsync round-trip", async () => { + writeFileSync(join(repo, "tracked.txt"), "v1"); + execSync("git add tracked.txt && git commit -m tracked", { cwd: repo, shell: "/bin/zsh" }); + writeFileSync(join(repo, "tracked.txt"), "v2"); + + await stashChangesAsync(repo, "main"); + const status = await statusPorcelainAsync(repo); + expect(status).toBe(""); + + const found = await findDesktopStashAsync(repo, "main"); + expect(found).not.toBeNull(); + + await popStashAsync(repo, found!.name); + const content = readFileSync(join(repo, "tracked.txt"), "utf8"); + expect(content).toBe("v2"); + }); + + test("findDesktopStashAsync returns null when no stash exists", async () => { + expect(await findDesktopStashAsync(repo, "main")).toBeNull(); + }); +}); diff --git a/lib/worktree/__tests__/locks.test.ts b/lib/worktree/__tests__/locks.test.ts new file mode 100644 index 00000000..d2a9ba5d --- /dev/null +++ b/lib/worktree/__tests__/locks.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from "bun:test"; +import { tryLockTree, isTreeLocked, withTreeLock } from "../locks"; + +describe("tryLockTree", () => { + it("acquires lock and returns release function on first call", () => { + const path = "/test/tree/1"; + const release = tryLockTree(path); + expect(release).not.toBeNull(); + expect(typeof release).toBe("function"); + release?.(); + }); + + it("returns null when trying to acquire already-held lock", () => { + const path = "/test/tree/2"; + const release1 = tryLockTree(path); + expect(release1).not.toBeNull(); + + const release2 = tryLockTree(path); + expect(release2).toBeNull(); + + release1?.(); + }); + + it("allows re-acquiring lock after release", () => { + const path = "/test/tree/3"; + const release1 = tryLockTree(path); + expect(release1).not.toBeNull(); + release1?.(); + + const release2 = tryLockTree(path); + expect(release2).not.toBeNull(); + release2?.(); + }); +}); + +describe("isTreeLocked", () => { + it("returns false when tree is not locked", () => { + const path = "/test/tree/4"; + expect(isTreeLocked(path)).toBe(false); + }); + + it("returns true when tree is locked", () => { + const path = "/test/tree/5"; + const release = tryLockTree(path); + expect(isTreeLocked(path)).toBe(true); + release?.(); + }); + + it("returns false after lock is released", () => { + const path = "/test/tree/6"; + const release = tryLockTree(path); + release?.(); + expect(isTreeLocked(path)).toBe(false); + }); +}); + +describe("withTreeLock", () => { + it("executes function when lock is acquired", async () => { + const path = "/test/tree/7"; + let executed = false; + const result = await withTreeLock(path, async () => { + executed = true; + return "success"; + }); + expect(executed).toBe(true); + expect(result).toBe("success"); + }); + + it("returns 'busy' when lock is already held", async () => { + const path = "/test/tree/8"; + const release = tryLockTree(path); + expect(release).not.toBeNull(); + + const result = await withTreeLock(path, async () => { + return "should not execute"; + }); + expect(result).toBe("busy"); + + release?.(); + }); + + it("releases lock after function resolves", async () => { + const path = "/test/tree/9"; + await withTreeLock(path, async () => { + expect(isTreeLocked(path)).toBe(true); + return "done"; + }); + expect(isTreeLocked(path)).toBe(false); + }); + + it("releases lock when function throws", async () => { + const path = "/test/tree/10"; + try { + await withTreeLock(path, async () => { + expect(isTreeLocked(path)).toBe(true); + throw new Error("test error"); + }); + } catch { + // Expected to throw + } + expect(isTreeLocked(path)).toBe(false); + }); + + it("does not acquire lock when returning 'busy'", async () => { + const path = "/test/tree/11"; + const release1 = tryLockTree(path); + expect(release1).not.toBeNull(); + + const result = await withTreeLock(path, async () => { + return "should not execute"; + }); + expect(result).toBe("busy"); + expect(isTreeLocked(path)).toBe(true); + + release1?.(); + expect(isTreeLocked(path)).toBe(false); + }); + + it("regression: stale release does not steal new holder's lock", () => { + // A acquires lock + const path = "/test/tree/12"; + const releaseA = tryLockTree(path); + expect(releaseA).not.toBeNull(); + expect(isTreeLocked(path)).toBe(true); + + // A releases + releaseA?.(); + expect(isTreeLocked(path)).toBe(false); + + // B acquires the same lock + const releaseB = tryLockTree(path); + expect(releaseB).not.toBeNull(); + expect(isTreeLocked(path)).toBe(true); + + // A's stale release fires again (e.g., from catch+finally or retry logic) + releaseA?.(); + // B's lock should still be held (not stolen by A's stale release) + expect(isTreeLocked(path)).toBe(true); + + // B's release should still work + releaseB?.(); + expect(isTreeLocked(path)).toBe(false); + }); +}); diff --git a/lib/worktree/__tests__/names.test.ts b/lib/worktree/__tests__/names.test.ts new file mode 100644 index 00000000..e412734e --- /dev/null +++ b/lib/worktree/__tests__/names.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, spyOn } from "bun:test"; +import { pickName } from "../names.ts"; +import { slugifyTicketTitle, disambiguate } from "../branch-name.ts"; + +describe("pickName", () => { + it("picks a random unused name from pool", () => { + const pool = ["alpha", "bravo", "charlie"]; + const used = new Set(["bravo"]); + + // Monkeypatch Math.random to return 0.5 (middle of pool after filter) + const spy = spyOn(Math, "random").mockReturnValue(0.5); + + const result = pickName(pool, used); + + // With random 0.5 and pool ["alpha", "charlie"], should pick "charlie" (index 1) + expect(result).toBe("charlie"); + + spy.mockRestore(); + }); + + it("falls back to neutral generator when pool is exhausted", () => { + const pool = ["alpha", "bravo"]; + const used = new Set(["alpha", "bravo"]); + + const result = pickName(pool, used); + + // Should be in format "-" + expect(result).toMatch(/^[a-z]+-[a-z]+$/); + expect(result).not.toEqual("alpha"); + expect(result).not.toEqual("bravo"); + }); + + it("falls back to neutral generator when pool is undefined", () => { + const used = new Set(); + + const result = pickName(undefined, used); + + // Should be in format "-" + expect(result).toMatch(/^[a-z]+-[a-z]+$/); + }); + + it("retries generator with numeric suffix on collision", () => { + let callCount = 0; + + // Mock Math.random to return predictable sequence + // First two calls return 0.0 (first adj, first noun) + const spy = spyOn(Math, "random").mockImplementation((): number => { + const sequence: number[] = [0.0, 0.0]; + const value = sequence[callCount % sequence.length]!; + callCount++; + return value; + }); + + // Based on the mock, first generation would be "amber-anvil" (indices 0, 0) + const used = new Set(["amber-anvil"]); + + const result = pickName(undefined, used); + + // Should have tried "amber-anvil", found collision, and returned "amber-anvil-2" + expect(result).toBe("amber-anvil-2"); + + spy.mockRestore(); + }); +}); + +describe("slugifyTicketTitle", () => { + it("converts ticket ID and title to slug format", () => { + const result = slugifyTicketTitle("RT-34", "Ephemeral Worktrees: rule!", "-"); + expect(result).toBe("rt-34-ephemeral-worktrees-rule"); + }); + + it("handles non-alphanumeric characters", () => { + const result = slugifyTicketTitle("ABC-123", "Hello & World!!!!", "-"); + expect(result).toBe("abc-123-hello-world"); + }); + + it("collapses multiple dashes", () => { + const result = slugifyTicketTitle("TEST-1", "Multiple -- Dashes", "-"); + expect(result).toBe("test-1-multiple-dashes"); + }); + + it("caps slug at 40 characters", () => { + // Title that produces a slug longer than 40 chars + // "aaaaa bbbbb ccccc ddddd eeeee fffff ggggg" → slug "aaaaa-bbbbb-ccccc-ddddd-eeeee-fffff-ggggg" (41 chars) + // Capped at 40: "aaaaa-bbbbb-ccccc-ddddd-eeeee-fffff-gggg" + const result = slugifyTicketTitle("ID-1", "aaaaa bbbbb ccccc ddddd eeeee fffff ggggg", "-"); + // Exact expected: "id-1-" (5 chars) + slug capped at 40 chars + expect(result).toBe("id-1-aaaaa-bbbbb-ccccc-ddddd-eeeee-fffff-gggg"); + }); + + it("trims whitespace from slug", () => { + const result = slugifyTicketTitle("X-1", " Title With Spaces ", "-"); + expect(result).toMatch(/^x-1-/); + expect(result).not.toMatch(/^x-1-\s/); + expect(result).not.toMatch(/\s$/); + }); + + it("sanitizes a ticket ID with spaces the same way as the slug", () => { + const result = slugifyTicketTitle("RT 34", "Worktree lifecycle", "-"); + expect(result).toBe("rt-34-worktree-lifecycle"); + }); + + it("sanitizes a ticket ID with non-alphanumeric punctuation", () => { + const result = slugifyTicketTitle("RT^9", "Fix it", "-"); + expect(result).toBe("rt-9-fix-it"); + }); + + it("substitutes every occurrence of a repeated placeholder", () => { + const result = slugifyTicketTitle("RT-34", "Worktrees", "/-"); + expect(result).toBe("rt-34/rt-34-worktrees"); + }); + + it("a title containing $& does not corrupt the substitution", () => { + const result = slugifyTicketTitle("RT-34", "Fix $& in prod", "-"); + expect(result).toBe("rt-34-fix-in-prod"); + }); +}); + +describe("disambiguate", () => { + it("returns base when exists returns false", () => { + const result = disambiguate("x", () => false); + expect(result).toBe("x"); + }); + + it("returns base-2 when base exists", () => { + const result = disambiguate("x", (c) => c === "x"); + expect(result).toBe("x-2"); + }); + + it("returns base-3 when base and base-2 exist", () => { + const result = disambiguate("x", (c) => c === "x" || c === "x-2"); + expect(result).toBe("x-3"); + }); + + it("finds first non-existing candidate", () => { + const result = disambiguate("myname", (c) => c === "myname" || c === "myname-2"); + expect(result).toBe("myname-3"); + }); +}); diff --git a/lib/worktree/__tests__/ready.test.ts b/lib/worktree/__tests__/ready.test.ts new file mode 100644 index 00000000..f3642eb7 --- /dev/null +++ b/lib/worktree/__tests__/ready.test.ts @@ -0,0 +1,118 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { execSync } from "child_process"; +import { mkdtempSync, realpathSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import type { ReadyStep } from "../config.ts"; +import { changedSince, stepsToRun, runReadySteps } from "../ready.ts"; + +function makeRepo(): string { + // realpathSync: git canonicalizes /var → /private/var on macOS (Global Constraints) + const dir = realpathSync(mkdtempSync(join(tmpdir(), "rtready-"))); + execSync("git init -b main && git -c user.email=t@t -c user.name=t commit --allow-empty -m init", { + cwd: dir, + shell: "/bin/zsh", + }); + return dir; +} + +describe("changedSince", () => { + let repo: string; + beforeEach(() => { + repo = makeRepo(); + }); + + test("returns touched files between two commits", async () => { + const stamp = execSync("git rev-parse HEAD", { cwd: repo, shell: "/bin/zsh" }).toString().trim(); + execSync("mkdir -p db/schema", { cwd: repo, shell: "/bin/zsh" }); + writeFileSync(join(repo, "db", "schema", "x.sql"), "select 1;\n"); + execSync("git add -A && git -c user.email=t@t -c user.name=t commit -m add-schema", { + cwd: repo, + shell: "/bin/zsh", + }); + + const changed = await changedSince(repo, stamp); + expect(changed).not.toBeNull(); + expect(changed).toContain("db/schema/x.sql"); + }); + + test("returns null when the stamp is unknown to git", async () => { + const changed = await changedSince(repo, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"); + expect(changed).toBeNull(); + }); +}); + +describe("stepsToRun", () => { + const installStep: ReadyStep = { run: "bun install", when: "changed:bun.lock*" }; + const schemaStep: ReadyStep = { run: "db push", when: "changed:db/schema/**" }; + const noWhenStep: ReadyStep = { run: "echo hi" }; + const steps: ReadyStep[] = [installStep, schemaStep, noWhenStep]; + + test("changed === null runs every step, including no-when steps", () => { + expect(stepsToRun(steps, null)).toEqual(steps); + }); + + test("glob matching fires on a matching changed path", () => { + const result = stepsToRun(steps, ["db/schema/x.sql"]); + expect(result).toEqual([schemaStep]); + }); + + test("glob matching does not fire on a non-matching changed path", () => { + const result = stepsToRun(steps, ["README.md"]); + expect(result).toEqual([]); + }); + + test("no-when step is skipped when changed is a concrete list", () => { + const result = stepsToRun([noWhenStep], ["README.md"]); + expect(result).toEqual([]); + }); + + test("no-when step is included when changed === null", () => { + const result = stepsToRun([noWhenStep], null); + expect(result).toEqual([noWhenStep]); + }); +}); + +describe("runReadySteps", () => { + let repo: string; + beforeEach(() => { + repo = makeRepo(); + }); + + test("all steps succeed", async () => { + const result = await runReadySteps(repo, [{ run: "true" }, { run: "true" }]); + expect(result).toEqual({ ok: true }); + }); + + test("executes in order and stops at the first failure", async () => { + const marker = join(repo, "marker"); + const result = await runReadySteps(repo, [ + { run: `echo one > ${marker}` }, + { run: "false" }, + { run: `echo two >> ${marker}` }, + ]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failedStep).toBe("false"); + } + const content = execSync(`cat ${marker}`, { shell: "/bin/zsh" }).toString(); + expect(content).toBe("one\n"); + }); + + test("failing step surfaces stdout+stderr in output", async () => { + const result = await runReadySteps(repo, [{ run: "echo out-line; echo err-line >&2; exit 1" }]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.output).toContain("out-line"); + expect(result.output).toContain("err-line"); + } + }); + + test("a failing step that writes only to stderr still surfaces its output", async () => { + const result = await runReadySteps(repo, [{ run: "echo boom >&2; exit 1" }]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.output).toContain("boom"); + } + }); +}); diff --git a/lib/worktree/__tests__/registry.test.ts b/lib/worktree/__tests__/registry.test.ts new file mode 100644 index 00000000..a8de4932 --- /dev/null +++ b/lib/worktree/__tests__/registry.test.ts @@ -0,0 +1,69 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { dirname, join } from "path"; +import { + loadRegistry, + registryEpoch, + saveRegistry, + findByBranch, + registryPath, + usedNames, + type TreeRecord, +} from "../registry.ts"; + +const rec = (over: Partial): TreeRecord => ({ + name: "bellatrix", + path: "/tmp/x", + kind: "ephemeral", + state: "on-deck", + branch: "on-deck/bellatrix", + createdAt: new Date(0).toISOString(), + ...over, +}); + +describe("worktree registry", () => { + beforeEach(() => { + process.env.HOME = mkdtempSync(join(tmpdir(), "rtreg-")); + }); + test("empty loads []", () => expect(loadRegistry("r")).toEqual([])); + test("round-trip", () => { + saveRegistry("r", [rec({})]); + expect(loadRegistry("r")[0]!.name).toBe("bellatrix"); + }); + test("findByBranch returns all matches", () => { + const trees = [ + rec({ path: "/a", branch: "x" }), + rec({ name: "dobby", path: "/b", branch: "x" }), + ]; + expect(findByBranch(trees, "x").length).toBe(2); + }); + test("usedNames includes creating", () => { + expect(usedNames([rec({ state: "creating" })]).has("bellatrix")).toBe(true); + }); + test("registryEpoch bumps on every save, per repo", () => { + const before = registryEpoch("r"); + const otherBefore = registryEpoch("other"); + + saveRegistry("r", [rec({})]); + expect(registryEpoch("r")).toBe(before + 1); + + saveRegistry("r", [rec({ name: "dobby" })]); + expect(registryEpoch("r")).toBe(before + 2); + + // A write to one repo never disturbs another repo's epoch. + expect(registryEpoch("other")).toBe(otherBefore); + }); + test("malformed registry file ({}) loads as []", () => { + const path = registryPath("r"); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "{}"); + expect(loadRegistry("r")).toEqual([]); + }); + test("malformed registry file ({\"trees\": null}) loads as []", () => { + const path = registryPath("r"); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify({ trees: null })); + expect(loadRegistry("r")).toEqual([]); + }); +}); diff --git a/lib/worktree/branch-name.ts b/lib/worktree/branch-name.ts new file mode 100644 index 00000000..1b6c177d --- /dev/null +++ b/lib/worktree/branch-name.ts @@ -0,0 +1,58 @@ +/** + * Convert ticket ID and title to slug format per the given format string. + * Format: lowercase ticketId + "-" + title lowercased, with non-alphanumerics → "-", + * collapsed, trimmed, and slug capped at 40 chars. + * + * Example: slugifyTicketTitle("RT-34", "Ephemeral Worktrees: rule!", "-") + * → "rt-34-ephemeral-worktrees-rule" + */ +/** Lowercase, non-alphanumerics -> dash, collapsed, trimmed at both edges. */ +function sanitize(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export function slugifyTicketTitle( + ticketId: string, + title: string, + format: string +): string { + const ticketSlug = sanitize(ticketId); + + // Replace non-alphanumeric characters with dashes + let titleSlug = sanitize(title); + + // Cap the slug portion at 40 characters BEFORE assembling + titleSlug = titleSlug.substring(0, 40).replace(/-+$/, ""); // Trim trailing dashes after cap + + // Build the result based on format. replaceAll (not replace): String.replace + // only substitutes the first occurrence of a repeated placeholder. A + // function replacer (not a plain string) because $-sequences in a string + // replacement value are interpreted as patterns by both replace AND + // replaceAll... a slug containing "$&" would otherwise get mangled. + const result = format + .replaceAll("", () => ticketSlug) + .replaceAll("", () => titleSlug); + + return result; +} + +/** + * Find the first candidate (base, base-2, base-3...) where exists() returns false. + */ +export function disambiguate(base: string, exists: (candidate: string) => boolean): string { + if (!exists(base)) { + return base; + } + + let counter = 2; + while (true) { + const candidate = `${base}-${counter}`; + if (!exists(candidate)) { + return candidate; + } + counter++; + } +} diff --git a/lib/worktree/config.ts b/lib/worktree/config.ts new file mode 100644 index 00000000..3bb9d7a0 --- /dev/null +++ b/lib/worktree/config.ts @@ -0,0 +1,164 @@ +/** + * Worktree config: repo overlay + app-level, with a one-time compat seed. + * + * Two files, two owners: + * - `~/.rt/repos//config.json` — repo-config.ts owns most of this file + * (setup/clean/startScript/open). This module reads the SAME file but only + * ever looks at its optional "worktrees" key, and never writes it. + * - `~/.rt/worktrees.json` — owned entirely by this module. `{enabled, + * killProcesses}`, seeded once from the legacy `~/.rt/parking-lot.json` + * (section 11.1 retires the old file after the seed) when the new file is + * absent and the old one exists. Both default to true, matching + * parking-lot-config.ts's legacy `raw?.enabled !== false` semantics. + */ + +import { existsSync, readFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; +import { readJson, writeJson } from "../json-store.ts"; +import { repoDataDir, rtDir } from "../rt-paths.ts"; + +/** + * Expand a leading `~` against call-time HOME (matching rt-paths convention: + * `process.env.HOME ?? homedir()`, resolved at call time so tests can repoint + * the whole tree by setting process.env.HOME before calling). Only a leading + * `~` or `~/...` is special; `~foo` and mid-string `~` are left alone. + */ +function expandHome(path: string): string { + if (path === "~") return process.env.HOME ?? homedir(); + if (path.startsWith("~/")) return join(process.env.HOME ?? homedir(), path.slice(2)); + return path; +} + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface ReadyStep { + run: string; + when?: string; +} + +export interface WorktreeRepoConfig { + onDeck: number; // default 0 + namePool?: string[]; + root: string; // default join(repoPath, ".worktrees") + branchFormat: string; // default "-" + ready: ReadyStep[]; // declared domain steps ONLY (implicit install prepended at resolve time) +} + +export interface WorktreeAppConfig { + enabled: boolean; + killProcesses: boolean; +} + +// ─── Repo overlay ──────────────────────────────────────────────────────────── + +interface RawRepoConfigFile { + worktrees?: Partial; +} + +/** + * Reads the "worktrees" key of ~/.rt/repos//config.json. repo-config.ts + * owns every other key in that file; this never writes it. + */ +export function loadWorktreeRepoConfig(repoName: string, repoPath: string): WorktreeRepoConfig { + const path = join(repoDataDir(repoName), "config.json"); + const raw = readJson(path, {}); + const declared = raw.worktrees ?? {}; + + const cfg: WorktreeRepoConfig = { + onDeck: declared.onDeck ?? 0, + root: declared.root ? expandHome(declared.root) : join(repoPath, ".worktrees"), + branchFormat: declared.branchFormat ?? "-", + ready: declared.ready ?? [], + }; + if (declared.namePool) cfg.namePool = declared.namePool; + return cfg; +} + +// ─── Implicit install ladder ───────────────────────────────────────────────── + +type Manager = "pnpm" | "bun" | "yarn" | "npm"; + +const MANAGER_STEP: Record = { + pnpm: { run: "pnpm install --side-effects-cache", when: "changed:pnpm-lock.yaml" }, + bun: { run: "bun install", when: "changed:bun.lock*" }, + yarn: { run: "yarn install", when: "changed:yarn.lock" }, + npm: { run: "npm install", when: "changed:package-lock.json" }, +}; + +function detectManager(repoPath: string): Manager | null { + const packageJsonPath = join(repoPath, "package.json"); + if (!existsSync(packageJsonPath)) return null; + + try { + const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")); + const declared = typeof pkg?.packageManager === "string" ? pkg.packageManager : undefined; + if (declared) { + const prefix = declared.split("@")[0]; + if (prefix === "pnpm" || prefix === "bun" || prefix === "yarn" || prefix === "npm") { + return prefix; + } + } + } catch { + // malformed package.json falls through to lockfile sniffing + } + + if (existsSync(join(repoPath, "pnpm-lock.yaml"))) return "pnpm"; + if (existsSync(join(repoPath, "bun.lock")) || existsSync(join(repoPath, "bun.lockb"))) return "bun"; + if (existsSync(join(repoPath, "yarn.lock"))) return "yarn"; + if (existsSync(join(repoPath, "package-lock.json"))) return "npm"; + return "npm"; +} + +/** + * ladder (spec §5): no package.json → null; packageManager field prefix + * pnpm|bun|yarn|npm → step; else lockfile sniff pnpm-lock.yaml→pnpm, + * bun.lock|bun.lockb→bun, yarn.lock→yarn, package-lock.json→npm; else npm. + */ +export function resolveImplicitInstall(repoPath: string): ReadyStep | null { + const manager = detectManager(repoPath); + return manager ? MANAGER_STEP[manager] : null; +} + +/** + * Implicit install first UNLESS cfg.ready already declares its own install + * step for the detected manager (run starts with " install", e.g. + * "pnpm install --side-effects-cache") — only an install step replaces the + * implicit one; any other declared command for that manager (e.g. "pnpm + * lint") does not suppress it. Otherwise cfg.ready in order. + */ +export function resolveReadySteps(cfg: WorktreeRepoConfig, repoPath: string): ReadyStep[] { + const manager = detectManager(repoPath); + if (!manager) return cfg.ready; + + const installPrefix = `${manager} install`; + const alreadyDeclared = cfg.ready.some((step) => step.run.startsWith(installPrefix)); + if (alreadyDeclared) return cfg.ready; + + return [MANAGER_STEP[manager], ...cfg.ready]; +} + +// ─── App-level config ──────────────────────────────────────────────────────── + +const APP_CONFIG_DEFAULTS: WorktreeAppConfig = { enabled: true, killProcesses: true }; + +/** + * ~/.rt/worktrees.json; if absent AND ~/.rt/parking-lot.json exists, seed from + * it once (write the new file), then read the new file. Defaults + * { enabled: true, killProcesses: true }. + */ +export function loadWorktreeAppConfig(): WorktreeAppConfig { + const path = join(rtDir(), "worktrees.json"); + const legacyPath = join(rtDir(), "parking-lot.json"); + + if (!existsSync(path) && existsSync(legacyPath)) { + const legacy = readJson<{ enabled?: boolean; killProcesses?: boolean }>(legacyPath, {}); + const seeded: WorktreeAppConfig = { + enabled: legacy.enabled !== false, + killProcesses: legacy.killProcesses !== false, + }; + writeJson(path, seeded); + } + + return readJson(path, APP_CONFIG_DEFAULTS); +} diff --git a/lib/worktree/create.ts b/lib/worktree/create.ts new file mode 100644 index 00000000..79e39e1a --- /dev/null +++ b/lib/worktree/create.ts @@ -0,0 +1,177 @@ +/** + * Cold create: allocate a fresh ephemeral worktree from scratch. + * + * Registry-first ordering is load-bearing: the "creating" entry is written + * BEFORE `git worktree add` runs, and the tree lock is taken before that + * write. That way a crash mid-create always leaves a registry row a sweep + * can find and reconcile, never an orphaned worktree the registry doesn't + * know about. Any failure after the registry write scraps everything + * (worktree + on-deck branch + registry row) and reports typed detail. + */ + +import { join } from "path"; +import { + loadRegistry, + saveRegistry, + usedNames, + type TreeRecord, +} from "./registry.ts"; +import { + runGit, + remoteDefaultRef, + headSha, + ensureInfoExclude, + listWorktreesAsync, +} from "./git-async.ts"; +import { pickName } from "./names.ts"; +import { loadWorktreeRepoConfig, resolveReadySteps, type WorktreeRepoConfig } from "./config.ts"; +import { runReadySteps } from "./ready.ts"; +import { withTreeLock } from "./locks.ts"; +import { reconcileForRepo } from "../daemon/doppler-sync.ts"; + +const CREATE_TIMEOUT_MS = 5 * 60_000; + +/** `git worktree remove` deletes node_modules by hand; a pnpm-scale tree on + * APFS routinely outruns runGit's 60s default. */ +const REMOVE_TIMEOUT_MS = 5 * 60_000; + +/** Longest slice of a failed step's output carried into the log line. */ +const MAX_LOGGED_OUTPUT = 2000; + +/** The tail of a step's output — a failing install reports at the end, not the start. */ +function outputTail(output: string, maxChars: number): string { + const trimmed = output.trim(); + return trimmed.length <= maxChars ? trimmed : `…${trimmed.slice(-maxChars)}`; +} + +export interface CreateDeps { + repoName: string; + repoPath: string; + emit: (type: string, data: unknown) => void; + log: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void }; +} + +export type CreateResult = + | { ok: true; tree: TreeRecord } + | { ok: false; error: "create-failed"; failedStep?: string; output?: string } + | { ok: false; error: "busy" }; + +export async function createTree(deps: CreateDeps): Promise { + const { repoName, repoPath } = deps; + const cfg = loadWorktreeRepoConfig(repoName, repoPath); + const existing = loadRegistry(repoName); + const name = pickName(cfg.namePool, usedNames(existing)); + const path = join(cfg.root, name); + + const defaultRoot = join(repoPath, ".worktrees"); + if (cfg.root === defaultRoot) { + await ensureInfoExclude(repoPath, ".worktrees/"); + } + + const outcome = await withTreeLock(path, () => runCreate(deps, cfg, name, path)); + if (outcome === "busy") { + return { ok: false, error: "busy" }; + } + return outcome; +} + +async function runCreate( + deps: CreateDeps, + cfg: WorktreeRepoConfig, + name: string, + path: string, +): Promise { + const { repoName, repoPath, emit, log } = deps; + const branch = `on-deck/${name}`; + + const rec: TreeRecord = { + name, + path, + kind: "ephemeral", + state: "creating", + branch, + createdAt: new Date().toISOString(), + }; + + // Registry-first: this write must land before any git mutation below. + const trees = loadRegistry(repoName); + trees.push(rec); + saveRegistry(repoName, trees); + + const fail = async (failedStep: string, output: string): Promise => { + // The output is the whole diagnosis (which install died, and why); without + // it the log says only which step's name failed. + log.warn( + { repo: repoName, tree: name, failedStep, output: outputTail(output, MAX_LOGGED_OUTPUT) }, + "worktree create failed", + ); + await scrapTree(deps, rec); + return { ok: false, error: "create-failed", failedStep, output }; + }; + + const defaultRef = await remoteDefaultRef(repoPath); + const defaultBranchName = defaultRef.replace(/^origin\//, ""); + + const fetchResult = await runGit(repoPath, ["fetch", "origin", defaultBranchName], { + timeoutMs: CREATE_TIMEOUT_MS, + }); + if (fetchResult.exitCode !== 0) { + return fail(`git fetch origin ${defaultBranchName}`, fetchResult.stdout + fetchResult.stderr); + } + + const addResult = await runGit( + repoPath, + ["worktree", "add", "-b", branch, path, defaultRef], + { timeoutMs: CREATE_TIMEOUT_MS }, + ); + if (addResult.exitCode !== 0) { + return fail(`git worktree add -b ${branch} ${path} ${defaultRef}`, addResult.stdout + addResult.stderr); + } + + const readySteps = resolveReadySteps(cfg, repoPath); + const readyResult = await runReadySteps(path, readySteps); + if (!readyResult.ok) { + return fail(readyResult.failedStep, readyResult.output); + } + + const gitEntries = await listWorktreesAsync(repoPath); + if (gitEntries === null) { + log.warn({ repo: repoName, tree: name, path }, "worktree create: git worktree list failed; skipping doppler sync"); + } else { + const worktreeRoots = gitEntries.map((w) => w.path); + await reconcileForRepo({ repoName, worktreeRoots }); + } + + const readyStamp = await headSha(path); + const updated: TreeRecord = { + ...rec, + state: "on-deck", + readyAt: new Date().toISOString(), + ...(readyStamp ? { readyStamp } : {}), + }; + + const finalTrees = loadRegistry(repoName).map((t) => (t.path === path ? updated : t)); + saveRegistry(repoName, finalTrees); + + emit("worktree:created", { repo: repoName, tree: name, path }); + log.info({ repo: repoName, tree: name, path }, "worktree created"); + + return { ok: true, tree: updated }; +} + +/** + * Force-remove the worktree, delete its on-deck/ branch, and prune the + * registry entry. Tolerant of partial existence: the worktree may not exist + * yet (git worktree add never ran or failed before creating it), and the + * branch may not exist either. + */ +export async function scrapTree(deps: CreateDeps, rec: TreeRecord): Promise { + await runGit(deps.repoPath, ["worktree", "remove", "--force", rec.path], { + timeoutMs: REMOVE_TIMEOUT_MS, + }); + if (rec.branch) { + await runGit(deps.repoPath, ["branch", "-D", rec.branch]); + } + const trees = loadRegistry(deps.repoName).filter((t) => t.path !== rec.path); + saveRegistry(deps.repoName, trees); +} diff --git a/lib/worktree/dispose.ts b/lib/worktree/dispose.ts new file mode 100644 index 00000000..e69391a0 --- /dev/null +++ b/lib/worktree/dispose.ts @@ -0,0 +1,293 @@ +/** + * Dispose guard + removal (spec §8). + * + * Disposal is the only destructive verb in the lifecycle, so the guard is the + * feature: five checks, in a fixed order, each returning a stable refusal + * string the reactor records as `disposableReason` and the CLI prints. The + * order matters — cheap, local, categorical checks first, then the ones that + * can hit the network (fetch) or someone else's coordination state (leases). + * + * `force` overrides guards 2-5 and never guard 1: "main" and "unmanaged" trees + * are categorically not rt's to delete, no matter what the caller asks for. + */ + +import { existsSync } from "fs"; +import { gitOk, isAncestorAsync, remoteDefaultRef, remoteRefExists, runGit } from "./git-async.ts"; +import { loadRegistry, saveRegistry, type TreeRecord } from "./registry.ts"; +import { hasFreshAttendantLease } from "./lease.ts"; +import { loadSyncConfig, matchRule } from "../sync-config.ts"; +import { repoDataDir } from "../rt-paths.ts"; +import { killWorktreeProcesses } from "../daemon/worktree-process-kill.ts"; + +/** Merge-reactor disposals ignore claims younger than this (stale-event protection). */ +const GRACE_MS = 10 * 60_000; + +const FETCH_TIMEOUT_MS = 2 * 60_000; + +/** `git worktree remove` deletes node_modules by hand; a pnpm-scale tree on + * APFS routinely outruns runGit's 60s default. */ +const REMOVE_TIMEOUT_MS = 5 * 60_000; + +// ─── Dirty classification (harvested from parking-lot.ts, execSync → runGit) ── + +/** One entry from `git status --porcelain`. */ +interface DirtyEntry { status: string; path: string; } + +function parseDirtyEntries(porcelain: string): DirtyEntry[] { + const entries: DirtyEntry[] = []; + for (const line of porcelain.split("\n")) { + if (line.length < 4) continue; + const status = line.slice(0, 2); + let path = line.slice(3); + // Rename/copy entries read "old -> new"; the destination is what's dirty. + const arrow = path.indexOf(" -> "); + if (arrow !== -1) path = path.slice(arrow + 4); + // git quotes paths containing specials (C-style escapes). + if (path.startsWith('"') && path.endsWith('"')) { + try { path = JSON.parse(path) as string; } catch { path = path.slice(1, -1); } + } + entries.push({ status, path }); + } + return entries; +} + +/** + * Whether a tracked file's local change is pure whitespace relative to HEAD. + * Compared against HEAD (not the index) so a staged whitespace-only edit counts. + */ +async function isWhitespaceOnlyChange(cwd: string, path: string): Promise { + // exit 0 → nothing left once whitespace is ignored + return gitOk(cwd, ["diff", "HEAD", "--ignore-all-space", "--exit-code", "--", path]); +} + +/** + * Split a worktree's dirt into what may be thrown away and what must not be. + * + * `discard` covers tracked modifications to files the repo's sync.json declares + * auto-resolvable with `strategy: "theirs"`, whose local diff is pure + * whitespace. These are generated artifacts that drift by a trailing newline + * and are rebuilt by the next build; the declaration says upstream wins. + * + * Everything else is a blocker — disposal refuses and freshen stashes. A + * background sweep must not destroy content it didn't generate. That + * deliberately includes substantive changes to declared files: the declaration + * is about regenerable drift, not licence to delete real edits. + * + * One intelligence, two callers (dispose guard 2 and the freshen sweep). + * + * Fails CLOSED: a `git status` that exits nonzero (corrupt index, unreadable + * worktree, missing directory) yields a `` blocker rather than + * an empty-and-therefore-clean answer. Unknown dirt is dirt — the alternative + * is deleting a tree holding uncommitted work because git couldn't be asked. + */ +export const STATUS_FAILED_BLOCKER = ""; + +export async function classifyDirtyAsync( + worktreePath: string, + repoName: string, +): Promise<{ discard: string[]; blockers: string[] }> { + const rules = loadSyncConfig(repoDataDir(repoName)).autoResolve; + const status = await runGit(worktreePath, ["status", "--porcelain"]); + if (status.exitCode !== 0) { + return { discard: [], blockers: [STATUS_FAILED_BLOCKER] }; + } + const entries = parseDirtyEntries(status.stdout); + + const discard: string[] = []; + const blockers: string[] = []; + + for (const e of entries) { + const untracked = e.status === "??"; + const modified = !untracked && e.status.includes("M"); + if ( + modified && + matchRule(e.path, rules)?.strategy === "theirs" && + await isWhitespaceOnlyChange(worktreePath, e.path) + ) { + discard.push(e.path); + } else { + blockers.push(e.path); + } + } + + return { discard, blockers }; +} + +// ─── Dispose ───────────────────────────────────────────────────────────────── + +export interface DisposeDeps { + repoName: string; + repoPath: string; + /** Branch-keyed MR cache (daemon `ctx.cache.entries`). */ + cacheEntries: Record; + emit: (type: string, data: unknown) => void; + log: { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + /** Optional: auto-path refusals log here (they re-fire every reactor pass). */ + debug?: (...args: unknown[]) => void; + }; + killProcesses: boolean; +} + +export type DisposeOutcome = { disposed: true } | { disposed: false; refusal: string }; + +/** The MR joined to this tree, if the branch cache knows one for this repo. */ +function joinedMr( + deps: DisposeDeps, + rec: TreeRecord, +): { iid?: number; sha?: string | null; state?: string | null } | null { + if (!rec.branch) return null; + const entry = deps.cacheEntries[rec.branch]; + if (!entry || !entry.mr) return null; + // Entries carry repoName once freshness has attributed them; an unattributed + // entry is accepted (single-repo caches predate the field). + if (entry.repoName && entry.repoName !== deps.repoName) return null; + return entry.mr as { iid?: number; sha?: string | null; state?: string | null }; +} + +/** + * Guard 3, MR-anchored: under squash/rebase merge the branch tip is never an + * ancestor of the default branch, and delete-source-branch removes + * origin/ before the tick sees the merge — so the MR head sha from the + * branch cache is the only anchor that survives both. A sha that is present but + * unknown locally → fetch once, then refuse rather than guess. + * + * Selected on the MR's state, NOT on `auto`: the anchor is a fact about the + * branch's history, not about who asked. A human disposing a squash-merged tree + * needs exactly the same anchor the reactor does — gating it on `auto` handed + * them "unpushed" and pushed them toward `--force`, which also strips the dirty + * guard. + * + * Only reached when the MR actually carries a sha: roughly a quarter of merged + * entries in the live cache have no `sha` field at all, and refusing those + * would strand every one of them as disposable. Those fall back to the remote + * anchor (the caller decides), which is a real containment check, not a guess. + */ +async function mrAnchorRefusal(deps: DisposeDeps, rec: TreeRecord, sha: string): Promise { + if (!(await gitOk(rec.path, ["cat-file", "-e", sha]))) { + await runGit(deps.repoPath, ["fetch", "origin"], { timeoutMs: FETCH_TIMEOUT_MS }); + if (!(await gitOk(rec.path, ["cat-file", "-e", sha]))) return "mr-sha-unresolvable"; + } + return (await isAncestorAsync(rec.path, "HEAD", sha)) ? null : "unpushed"; +} + +/** Guard 3, no-MR: pushed-but-MR-less branches anchor on their own remote ref. */ +async function remoteAnchorRefusal(rec: TreeRecord): Promise { + const anchor = + rec.branch && (await remoteRefExists(rec.path, rec.branch)) + ? `refs/remotes/origin/${rec.branch}` + : await remoteDefaultRef(rec.path); + return (await isAncestorAsync(rec.path, "HEAD", anchor)) ? null : "unpushed"; +} + +/** + * Guard the tree, then remove it: worktree, branch, registry entry, event. + * + * Returns the refusal reason instead of throwing; callers decide what to do + * with it (the reactor flips the tree to `disposable` with the reason, the CLI + * prints it). + */ +export async function disposeTree( + deps: DisposeDeps, + rec: TreeRecord, + opts: { force?: boolean; auto?: boolean }, +): Promise { + const { repoName, repoPath, emit, log } = deps; + const force = opts.force === true; + const auto = opts.auto === true; + + const refuse = (refusal: string): DisposeOutcome => { + const fields = { repo: repoName, tree: rec.name, refusal }; + // Auto refusals repeat every reactor pass for as long as the tree sits + // disposable, so they belong at debug; a human-driven refusal is a one-off. + if (auto && log.debug) log.debug(fields, "worktree dispose refused"); + else log.info(fields, "worktree dispose refused"); + return { disposed: false, refusal }; + }; + + // 1. Categorical: only rt-built ephemeral trees are rt's to delete. No force. + if (rec.kind !== "ephemeral") return refuse(`kind-${rec.kind}`); + + let discarded: string[] = []; + + if (!force) { + // 2. Clean modulo declared generated drift. + const { discard, blockers } = await classifyDirtyAsync(rec.path, repoName); + if (blockers.length > 0) return refuse("dirty"); + discarded = discard; + + // 3. Nothing local-only, checked against the right anchor. + const mr = joinedMr(deps, rec); + const mrSha = typeof mr?.sha === "string" && mr.sha.length > 0 ? mr.sha : null; + // An MR with no cached sha is common (the projection drops it for a + // sizeable slice of merged MRs) and is NOT an unresolvable sha — it falls + // back to the remote anchor. "mr-sha-unresolvable" is reserved for a sha + // that is present and still unknown after a fetch. + const anchorRefusal = + mrSha && mr?.state === "merged" + ? await mrAnchorRefusal(deps, rec, mrSha) + : await remoteAnchorRefusal(rec); + if (anchorRefusal) return refuse(anchorRefusal); + + // 4. Nobody is attending the MR right now. + if (mr && typeof mr.iid === "number" && hasFreshAttendantLease(mr.iid)) { + return refuse("attended"); + } + + // 5. Auto only: a just-claimed tree can't be reaped by a stale merge event. + if (auto && rec.claimedAt) { + const claimedMs = Date.parse(rec.claimedAt); + if (!Number.isNaN(claimedMs) && Date.now() - claimedMs < GRACE_MS) return refuse("grace"); + } + } + + if (deps.killProcesses) { + const { terminated } = killWorktreeProcesses(rec.path); + if (terminated.length > 0) { + log.info( + { repo: repoName, tree: rec.name, count: terminated.length }, + "worktree processes terminated", + ); + } + } + + // --force here is plumbing, not a safety statement: `git worktree remove` + // refuses on any untracked file, and the guard above (or the caller's + // explicit force) already made the decision. + const removal = await runGit(repoPath, ["worktree", "remove", "--force", rec.path], { + timeoutMs: REMOVE_TIMEOUT_MS, + }); + if (removal.exitCode !== 0) { + const output = (removal.stdout + removal.stderr).trim(); + log.warn( + { repo: repoName, tree: rec.name, path: rec.path, output }, + "git worktree remove failed during dispose", + ); + // A directory that is already gone is the expected failure and disposal + // continues (the registry row is the thing left to clean up). A tree still + // on disk means removal genuinely failed — locked file, permissions — and + // pruning the registry there would orphan a real worktree with its + // metadata lost. Refuse instead; the caller retries or forces. + if (existsSync(rec.path)) return refuse("remove-failed"); + } + + if (rec.branch) { + // Already-gone branches are expected (the reactor disposes after a merge + // that may have deleted it), so a failure here is not worth a warning. + await runGit(repoPath, ["branch", "-D", rec.branch]); + } + + saveRegistry(repoName, loadRegistry(repoName).filter((t) => t.path !== rec.path)); + + emit("worktree:disposed", { + repo: repoName, + tree: rec.name, + path: rec.path, + branch: rec.branch, + discarded, + }); + log.info({ repo: repoName, tree: rec.name, path: rec.path }, "worktree disposed"); + + return { disposed: true }; +} diff --git a/lib/worktree/git-async.ts b/lib/worktree/git-async.ts new file mode 100644 index 00000000..1d994b66 --- /dev/null +++ b/lib/worktree/git-async.ts @@ -0,0 +1,201 @@ +/** + * Async git helpers for the worktree lifecycle. + * + * The daemon-safe replacement for the execSync-based git wrappers in + * lib/git-ops.ts and lib/git-worktrees.ts: execSync blocks Bun's event loop + * for the whole child lifetime, which on the daemon shows up as timed-out + * status polls (see lib/subprocess.ts). Everything reachable from a daemon + * timer or handler goes through here instead. + */ + +import { existsSync, readFileSync, appendFileSync, mkdirSync } from "fs"; +import { dirname, isAbsolute, join } from "path"; +import { runCapture } from "../subprocess.ts"; + +export interface GitResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export interface WorktreeEntry { + path: string; + branch: string | null; +} + +// rt drives git inside target repos but must never fire their hooks (repo +// stealth). A broken husky post-checkout/post-merge hook otherwise makes the +// checkout exit non-zero *after* it already succeeded, surfacing as a spurious +// "checkout-failed" even though the branch switched fine. Disable hooks on every +// mutating command by pointing hooksPath at a nonexistent dir. +const NO_HOOKS = ["-c", "core.hooksPath=/dev/null"]; + +const DEFAULT_TIMEOUT_MS = 60_000; + +const DESKTOP_STASH_RE = /!!GitHub_Desktop<(.+)>$/; + +/** Run a git command with hooks suppressed, capturing stdout+stderr. Never throws. */ +export async function runGit( + cwd: string, + args: string[], + opts: { timeoutMs?: number } = {}, +): Promise { + const argv: [string, ...string[]] = ["git", ...NO_HOOKS, ...args]; + const result = await runCapture(argv, { + cwd, + timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, + stderr: "pipe", + }); + return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }; +} + +/** Run a git command and report only whether it succeeded (exitCode === 0). */ +export async function gitOk(cwd: string, args: string[]): Promise { + const r = await runGit(cwd, args); + return r.exitCode === 0; +} + +/** Current branch name, or null when HEAD is detached. */ +export async function currentBranchAsync(cwd: string): Promise { + const r = await runGit(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"]); + if (r.exitCode !== 0) return null; + const branch = r.stdout.trim(); + return branch.length > 0 ? branch : null; +} + +export async function statusPorcelainAsync(cwd: string): Promise { + const r = await runGit(cwd, ["status", "--porcelain"]); + return r.stdout; +} + +export async function branchExistsLocalAsync(cwd: string, branch: string): Promise { + return gitOk(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]); +} + +/** Whether refs/remotes/origin/ exists (i.e. was fetched). */ +export async function remoteRefExists(cwd: string, branch: string): Promise { + return gitOk(cwd, ["rev-parse", "--verify", `refs/remotes/origin/${branch}`]); +} + +export async function isAncestorAsync(cwd: string, ancestor: string, tip: string): Promise { + return gitOk(cwd, ["merge-base", "--is-ancestor", ancestor, tip]); +} + +/** + * Resolve the repo's default remote ref. Ported from getRemoteDefaultBranch + * (lib/git-ops.ts) rather than `symbolic-ref refs/remotes/origin/HEAD` — that + * ref only exists after `git clone`/`remote set-head`, and fixtures built with + * `remote add` + `fetch` never have it. Always returns; falls back to + * "origin/master" when neither candidate resolves. + */ +export async function remoteDefaultRef(cwd: string): Promise { + for (const candidate of ["origin/main", "origin/master"]) { + if (await gitOk(cwd, ["rev-parse", "--verify", candidate])) return candidate; + } + return "origin/master"; +} + +export async function headSha(cwd: string): Promise { + const r = await runGit(cwd, ["rev-parse", "HEAD"]); + if (r.exitCode !== 0) return null; + const sha = r.stdout.trim(); + return sha.length > 0 ? sha : null; +} + +/** + * Enumerate worktrees for a repo via `git worktree list --porcelain`. + * branch is null when a worktree is detached. Paths are filtered to those + * that exist on disk (a worktree removed via `rm -rf` still shows up in + * git's porcelain output, but it's not one we can operate on). + * + * Returns null on a nonzero exit (e.g. a broken or nonexistent git dir) so + * callers can tell "git failed" apart from "git succeeded and reported no + * worktrees" -- treating a failed listing as an empty one is how a registry + * gets mass-pruned against a repo git briefly couldn't read. + * + * NOTE: returns git's canonicalized paths (/private/var/... on macOS + * tmpdirs); callers comparing against stored paths rely on the + * canonical-fixtures rule in Global Constraints. + */ +export async function listWorktreesAsync(repoPath: string): Promise { + const r = await runGit(repoPath, ["worktree", "list", "--porcelain"]); + if (r.exitCode !== 0) return null; + const results: WorktreeEntry[] = []; + let curPath: string | null = null; + let curBranch: string | null = null; + + const flush = () => { + if (curPath && existsSync(curPath)) { + results.push({ path: curPath, branch: curBranch }); + } + }; + + for (const line of r.stdout.split("\n")) { + if (line.startsWith("worktree ")) { + flush(); + curPath = line.slice("worktree ".length).trim(); + curBranch = null; + } else if (line.startsWith("branch ")) { + curBranch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, ""); + } + } + flush(); + + return results; +} + +/** + * Idempotently append `pattern` to the common git dir's info/exclude, with a + * "# rt worktree" marker comment written on first use of this file. Returns + * true when it wrote (pattern was missing), false when already present. + */ +export async function ensureInfoExclude(repoPath: string, pattern: string): Promise { + const r = await runGit(repoPath, ["rev-parse", "--git-common-dir"]); + if (r.exitCode !== 0) return false; + const commonDirRaw = r.stdout.trim(); + const commonDir = isAbsolute(commonDirRaw) ? commonDirRaw : join(repoPath, commonDirRaw); + const excludePath = join(commonDir, "info", "exclude"); + + mkdirSync(dirname(excludePath), { recursive: true }); + + const content = existsSync(excludePath) ? readFileSync(excludePath, "utf8") : ""; + if (content.split("\n").some((line) => line.trim() === pattern.trim())) { + return false; + } + + const prefix = content.length > 0 ? "\n" : ""; + const header = content.includes("# rt worktree") ? "" : "# rt worktree\n"; + appendFileSync(excludePath, `${prefix}${header}${pattern}\n`); + return true; +} + +/** + * Stash uncommitted changes with a GitHub Desktop-compatible marker. + * Async port of git-ops.ts stashChanges — interoperable with GitHub Desktop + * and worktree-context. + */ +export async function stashChangesAsync(cwd: string, label: string): Promise { + const message = `!!GitHub_Desktop<${label}>`; + await runGit(cwd, ["stash", "push", "-u", "-m", message]); +} + +export async function popStashAsync(cwd: string, stashName: string): Promise { + await runGit(cwd, ["stash", "pop", stashName]); +} + +/** + * Find the most recent GitHub Desktop-tagged stash entry for a branch. + * Async port of git-ops.ts findDesktopStash. + */ +export async function findDesktopStashAsync(cwd: string, branch: string): Promise<{ name: string } | null> { + const r = await runGit(cwd, ["stash", "list"]); + if (r.exitCode !== 0) return null; + for (const line of r.stdout.split("\n")) { + const match = DESKTOP_STASH_RE.exec(line); + if (match && match[1] === branch) { + const nameMatch = /^(stash@\{\d+\})/.exec(line); + if (nameMatch) return { name: nameMatch[1]! }; + } + } + return null; +} diff --git a/lib/worktree/lease.ts b/lib/worktree/lease.ts new file mode 100644 index 00000000..46626f3d --- /dev/null +++ b/lib/worktree/lease.ts @@ -0,0 +1,94 @@ +/** + * CI-attendant lease probe (BOARD-10). + * + * An attendant (a CI-babysitting agent) writes a heartbeat file per MR it is + * attending into ~/.mattstack/ci-attendants/. Disposal defers to the next tick + * while such a lease is fresh: reaping a tree out from under an agent that is + * still pushing fixes to its MR is the one un-undoable mistake here. + * + * The on-disk shape is BOARD-10's, verified against real files — `mr` is the MR + * *URL string* (not an iid) and `heartbeatAt` is epoch millis (not ISO). This + * module reads that shape and never writes it: rt is a reader of someone else's + * coordination state (~/.mattstack is cross-actor, no single owner). + * + * Everything here is deliberately forgiving. An unreadable, half-written, or + * schema-drifted lease file yields "not attended" rather than an exception: a + * garbage file must never wedge the disposal path forever. + */ + +import { existsSync, readdirSync, readFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; + +/** Attendant heartbeats older than this (when the file declares no ttl) are stale. */ +const DEFAULT_TTL_SECONDS = 300; + +/** + * ~/.mattstack/ci-attendants — HOME resolved at CALL time (same convention as + * lib/rt-paths.ts) so a test pointing process.env.HOME at a temp dir isolates + * the real one. + */ +export function ciAttendantsDir(): string { + return join(process.env.HOME ?? homedir(), ".mattstack", "ci-attendants"); +} + +/** Epoch millis from either the real (number) or a defensively-tolerated (ISO) heartbeat. */ +function heartbeatMillis(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; + } + return null; +} + +/** Whether a lease's `mr` URL points at this iid (trailing /merge_requests/). */ +function urlMatchesIid(mr: unknown, mrIid: number): boolean { + if (typeof mr !== "string") return false; + return mr.replace(/\/+$/, "").endsWith(`/merge_requests/${mrIid}`); +} + +/** + * Whether some attendant currently holds a fresh lease on this MR. + * + * Matching is belt-and-braces: the filename convention is + * `-.json`, but the authoritative field is the `mr` URL, so + * either identifies the MR. `now` is injectable for tests. + */ +export function hasFreshAttendantLease(mrIid: number, now: number = Date.now()): boolean { + const dir = ciAttendantsDir(); + if (!existsSync(dir)) return false; + + let files: string[]; + try { + files = readdirSync(dir).filter((f) => f.endsWith(".json")); + } catch { + return false; + } + + for (const file of files) { + let lease: Record; + try { + const raw = JSON.parse(readFileSync(join(dir, file), "utf8")); + if (!raw || typeof raw !== "object") continue; + lease = raw as Record; + } catch { + continue; // unreadable or half-written — never block on garbage + } + + const matches = urlMatchesIid(lease.mr, mrIid) || file.endsWith(`-${mrIid}.json`); + if (!matches) continue; + + const heartbeat = heartbeatMillis(lease.heartbeatAt); + if (heartbeat === null) continue; + + const ttlSeconds = + typeof lease.ttlSeconds === "number" && Number.isFinite(lease.ttlSeconds) + ? lease.ttlSeconds + : DEFAULT_TTL_SECONDS; + + if (now - heartbeat < ttlSeconds * 1000) return true; + } + + return false; +} diff --git a/lib/worktree/locks.ts b/lib/worktree/locks.ts new file mode 100644 index 00000000..b0ef09c7 --- /dev/null +++ b/lib/worktree/locks.ts @@ -0,0 +1,36 @@ +const locks = new Map(); + +export function tryLockTree(path: string): (() => void) | null { + if (locks.has(path)) { + return null; + } + const token = Symbol("lock"); + locks.set(path, token); + return () => { + // Only delete if the token still matches (ownership check prevents stale releases) + if (locks.get(path) === token) { + locks.delete(path); + } + }; +} + +export function isTreeLocked(path: string): boolean { + return locks.has(path); +} + +export async function withTreeLock( + path: string, + fn: () => Promise +): Promise { + const release = tryLockTree(path); + if (release === null) { + return "busy"; + } + + try { + const result = await fn(); + return result; + } finally { + release(); + } +} diff --git a/lib/worktree/names.ts b/lib/worktree/names.ts new file mode 100644 index 00000000..bb8ad625 --- /dev/null +++ b/lib/worktree/names.ts @@ -0,0 +1,86 @@ +// Adjectives for neutral name generator +const ADJECTIVES = [ + "amber", + "brisk", + "calm", + "deft", + "eager", + "frank", + "grand", + "happy", + "ideal", + "joyful", + "kind", + "lively", + "merry", + "noble", + "olive", + "proud", +]; + +// Nouns for neutral name generator +const NOUNS = [ + "anvil", + "beacon", + "cedar", + "daisy", + "eagle", + "feather", + "garden", + "harbor", + "inlet", + "jungle", + "kettle", + "lantern", + "marble", + "nectar", + "oyster", + "paddle", +]; + +/** + * Pick a random unused name from pool, or generate neutral name if pool exhausted/absent. + * Neutral names are generated in format "-" with numeric suffixes on collision. + */ +export function pickName(pool: string[] | undefined, used: Set): string { + // Filter pool to unused names + if (pool && pool.length > 0) { + const available = pool.filter((name) => !used.has(name)); + if (available.length > 0) { + const randomIndex = Math.floor(Math.random() * available.length); + return available[randomIndex]!; + } + } + + // Fall back to neutral generator + return generateNeutralName(used); +} + +/** + * Generate a neutral name in format "-" with retry on collision. + * Generates base pair once, then retries with numeric suffixes. + */ +function generateNeutralName(used: Set): string { + // Generate the base pair + const adjIndex = Math.floor(Math.random() * ADJECTIVES.length); + const nounIndex = Math.floor(Math.random() * NOUNS.length); + + const adj = ADJECTIVES[adjIndex]!; + const noun = NOUNS[nounIndex]!; + const baseName = `${adj}-${noun}`; + + // Return base if unused + if (!used.has(baseName)) { + return baseName; + } + + // Retry with numeric suffixes until unused + let counter = 2; + while (true) { + const candidate = `${baseName}-${counter}`; + if (!used.has(candidate)) { + return candidate; + } + counter++; + } +} diff --git a/lib/worktree/ready.ts b/lib/worktree/ready.ts new file mode 100644 index 00000000..fde3811e --- /dev/null +++ b/lib/worktree/ready.ts @@ -0,0 +1,62 @@ +/** + * Ready-step engine: which steps a worktree freshen needs to run, and + * running them. + * + * "changed" steps only fire when their glob matches a path that actually + * changed since the worktree's last ready stamp; a null changed set (stamp + * unknown to git, e.g. cold create) means run everything. + */ + +import { runGit } from "./git-async.ts"; +import { runCapture } from "../subprocess.ts"; +import type { ReadyStep } from "./config.ts"; + +/** + * Paths changed between readyStamp and HEAD, or null when readyStamp is + * unknown to git (treat as "everything changed"). + */ +export async function changedSince(worktreePath: string, readyStamp: string): Promise { + const r = await runGit(worktreePath, ["diff", "--name-only", `${readyStamp}..HEAD`]); + if (r.exitCode !== 0) return null; + return r.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +const CHANGED_WHEN_RE = /^changed:(.+)$/; + +/** + * changed === null → every step (cold create / unknown stamp). Otherwise: + * steps whose `when` is "changed:" and the glob matches some changed + * path; steps with no `when` are skipped. + */ +export function stepsToRun(steps: ReadyStep[], changed: string[] | null): ReadyStep[] { + if (changed === null) return steps; + + return steps.filter((step) => { + if (!step.when) return false; + const match = CHANGED_WHEN_RE.exec(step.when); + if (!match) return false; + const glob = new Bun.Glob(match[1]!); + return changed.some((path) => glob.match(path)); + }); +} + +/** Run ready steps in order via zsh, stopping at the first failure. */ +export async function runReadySteps( + worktreePath: string, + steps: ReadyStep[], +): Promise<{ ok: true } | { ok: false; failedStep: string; output: string }> { + for (const step of steps) { + const r = await runCapture(["/bin/zsh", "-lc", step.run], { + cwd: worktreePath, + timeoutMs: 15 * 60_000, + stderr: "pipe", + }); + if (r.exitCode !== 0) { + return { ok: false, failedStep: step.run, output: r.stdout + r.stderr }; + } + } + return { ok: true }; +} diff --git a/lib/worktree/registry.ts b/lib/worktree/registry.ts new file mode 100644 index 00000000..36c7386e --- /dev/null +++ b/lib/worktree/registry.ts @@ -0,0 +1,78 @@ +import { join } from "path"; +import { readJson, writeJson } from "../json-store.ts"; +import { repoDataDir } from "../rt-paths.ts"; + +export type TreeKind = "main" | "ephemeral" | "unmanaged"; +export type TreeState = "creating" | "on-deck" | "claimed" | "disposable"; +export type DisposalMode = "merge" | "job"; + +export interface TreeRecord { + name: string; + path: string; // absolute + kind: TreeKind; + state?: TreeState; // ephemeral only + branch: string | null; // git ground truth, reconciled every pass + owner?: string; + disposal?: DisposalMode; + createdAt: string; // ISO + claimedAt?: string; + readyAt?: string; // last successful full readiness (ISO) + readyStamp?: string; // commit sha the ready steps last ran against + disposableReason?: string; + retryFailures?: number; // shared backoff counter (create/freshen) + nextRetryAt?: string; // ISO; skip mutating work until then +} + +interface RegistryFile { + trees: TreeRecord[]; +} + +export function registryPath(repoName: string): string { + return join(repoDataDir(repoName), "worktrees.json"); +} + +export function loadRegistry(repoName: string): TreeRecord[] { + const path = registryPath(repoName); + const data = readJson>(path, { trees: [] }); + return Array.isArray(data?.trees) ? data.trees : []; +} + +/** + * Per-repo write counter, bumped by every `saveRegistry`. + * + * The registry has exactly one writer process (the daemon), but not one writer + * *task*: provision, dispose, freshen and reconcile all interleave on the same + * event loop. Anything that loads a whole-registry snapshot, awaits, and then + * saves that snapshot back would silently overwrite whatever landed in between. + * An in-memory counter is enough to detect that (no cross-process concern) and + * lives here because `saveRegistry` is the seam every write already funnels + * through. Callers compare `registryEpoch(repo)` captured right after their + * load against its value in the same synchronous block as their save. + */ +const epochs = new Map(); + +/** How many times this repo's registry has been saved in this process. */ +export function registryEpoch(repoName: string): number { + return epochs.get(repoName) ?? 0; +} + +export function saveRegistry(repoName: string, trees: TreeRecord[]): void { + const path = registryPath(repoName); + writeJson(path, { trees }); + epochs.set(repoName, registryEpoch(repoName) + 1); +} + +export function findByPath( + trees: TreeRecord[], + path: string +): TreeRecord | undefined { + return trees.find((t) => t.path === path); +} + +export function findByBranch(trees: TreeRecord[], branch: string): TreeRecord[] { + return trees.filter((t) => t.branch === branch); +} + +export function usedNames(trees: TreeRecord[]): Set { + return new Set(trees.map((t) => t.name)); +} diff --git a/website/docs/reference/park.mdx b/website/docs/reference/park.mdx new file mode 100644 index 00000000..9698f085 --- /dev/null +++ b/website/docs/reference/park.mdx @@ -0,0 +1,20 @@ +--- +title: rt park +sidebar_label: park +--- + +# rt park + +`rt › park` + +Deprecated — replaced by rt worktree + +## Usage + +```bash +rt park +``` + +_See code: [commands/worktree.ts › parkDeprecated](https://github.com/m4ttheweric/repo-tools/blob/main/commands/worktree.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/park/disable.mdx b/website/docs/reference/park/disable.mdx deleted file mode 100644 index d815218c..00000000 --- a/website/docs/reference/park/disable.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt park disable -sidebar_label: disable ---- - -# rt park disable - -`rt › park › disable` - -Turn off auto-park (daemon scans become no-ops) - -## Usage - -```bash -rt park disable -``` - -_See code: [commands/parking-lot.ts › disableCommand](https://github.com/m4ttheweric/repo-tools/blob/main/commands/parking-lot.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/park/enable.mdx b/website/docs/reference/park/enable.mdx deleted file mode 100644 index f8c416a3..00000000 --- a/website/docs/reference/park/enable.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt park enable -sidebar_label: enable ---- - -# rt park enable - -`rt › park › enable` - -Turn on auto-park - -## Usage - -```bash -rt park enable -``` - -_See code: [commands/parking-lot.ts › enableCommand](https://github.com/m4ttheweric/repo-tools/blob/main/commands/parking-lot.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/park/index.mdx b/website/docs/reference/park/index.mdx deleted file mode 100644 index 840a79a8..00000000 --- a/website/docs/reference/park/index.mdx +++ /dev/null @@ -1,29 +0,0 @@ ---- -title: rt park -sidebar_label: park ---- - -# rt park - -`rt › park` - -Auto-park worktrees when their MR merges or closes - -## Usage - -```bash -rt park -``` - -## Subcommands - -| Command | Description | -| --- | --- | -| [`status`](status) | Show whether auto-park is enabled + worktree bindings | -| [`enable`](enable) | Turn on auto-park | -| [`disable`](disable) | Turn off auto-park (daemon scans become no-ops) | -| [`scan`](scan) | Run the park check immediately against the live cache | -| [`this`](this) | Park the current worktree now (manual override; ignores enabled flag) | -| [`pick`](pick) | Pick worktrees in this repo to park (multi-select) | - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/park/pick.mdx b/website/docs/reference/park/pick.mdx deleted file mode 100644 index 50a54a77..00000000 --- a/website/docs/reference/park/pick.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt park pick -sidebar_label: pick ---- - -# rt park pick - -`rt › park › pick` - -Pick worktrees in this repo to park (multi-select) - -## Usage - -```bash -rt park pick -``` - -_See code: [commands/parking-lot.ts › parkPickCommand](https://github.com/m4ttheweric/repo-tools/blob/main/commands/parking-lot.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/park/scan.mdx b/website/docs/reference/park/scan.mdx deleted file mode 100644 index f498956f..00000000 --- a/website/docs/reference/park/scan.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt park scan -sidebar_label: scan ---- - -# rt park scan - -`rt › park › scan` - -Run the park check immediately against the live cache - -## Usage - -```bash -rt park scan -``` - -_See code: [commands/parking-lot.ts › scanCommand](https://github.com/m4ttheweric/repo-tools/blob/main/commands/parking-lot.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/park/status.mdx b/website/docs/reference/park/status.mdx deleted file mode 100644 index c179668c..00000000 --- a/website/docs/reference/park/status.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt park status -sidebar_label: status ---- - -# rt park status - -`rt › park › status` - -Show whether auto-park is enabled + worktree bindings - -## Usage - -```bash -rt park status -``` - -_See code: [commands/parking-lot.ts › statusCommand](https://github.com/m4ttheweric/repo-tools/blob/main/commands/parking-lot.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/park/this.mdx b/website/docs/reference/park/this.mdx deleted file mode 100644 index ef602c05..00000000 --- a/website/docs/reference/park/this.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt park this -sidebar_label: this ---- - -# rt park this - -`rt › park › this` - -Park the current worktree now (manual override; ignores enabled flag) - -## Usage - -```bash -rt park this -``` - -_See code: [commands/parking-lot.ts › parkThisCommand](https://github.com/m4ttheweric/repo-tools/blob/main/commands/parking-lot.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/worktree/adopt.mdx b/website/docs/reference/worktree/adopt.mdx new file mode 100644 index 00000000..864f9e9d --- /dev/null +++ b/website/docs/reference/worktree/adopt.mdx @@ -0,0 +1,27 @@ +--- +title: rt worktree adopt +sidebar_label: adopt +--- + +# rt worktree adopt + +`rt › worktree › adopt` + +One-shot migration: adopt an unmanaged repo's worktrees into the registry + +## Usage + +```bash +rt worktree adopt [flags] +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| [`--repo`](/guides/common-flags) | text | | Registered repo name (required) | +| [`--json`](/guides/common-flags) | boolean | `false` | Print the raw result as JSON | + +_See code: [commands/worktree.ts › worktreeAdopt](https://github.com/m4ttheweric/repo-tools/blob/main/commands/worktree.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/worktree/create.mdx b/website/docs/reference/worktree/create.mdx new file mode 100644 index 00000000..fa32be85 --- /dev/null +++ b/website/docs/reference/worktree/create.mdx @@ -0,0 +1,28 @@ +--- +title: rt worktree create +sidebar_label: create +--- + +# rt worktree create + +`rt › worktree › create` + +Create a fresh worktree (optionally straight into the on-deck pool) + +## Usage + +```bash +rt worktree create [flags] +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| [`--repo`](/guides/common-flags) | text | | Registered repo name (defaults to the current repo) | +| `--on-deck` | boolean | `false` | Put the new tree in the on-deck pool instead of claiming it | +| [`--json`](/guides/common-flags) | boolean | `false` | Print the raw result as JSON | + +_See code: [commands/worktree.ts › worktreeCreate](https://github.com/m4ttheweric/repo-tools/blob/main/commands/worktree.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/worktree/dispose.mdx b/website/docs/reference/worktree/dispose.mdx new file mode 100644 index 00000000..ec8d669a --- /dev/null +++ b/website/docs/reference/worktree/dispose.mdx @@ -0,0 +1,30 @@ +--- +title: rt worktree dispose +sidebar_label: dispose +--- + +# rt worktree dispose + +`rt › worktree › dispose` + +Dispose a worktree (no target + TTY → picker) + +## Usage + +```bash +rt worktree dispose [flags] +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| `` | text | | Tree name to dispose; omit to pick interactively | +| `--owner` | text | | Dispose every tree owned by this owner (can span repos) | +| [`--repo`](/guides/common-flags) | text | | Narrow to this registered repo | +| `--force` | boolean | `false` | Override the dirty/unpushed guard | +| [`--json`](/guides/common-flags) | boolean | `false` | Print the raw result as JSON | + +_See code: [commands/worktree.ts › worktreeDispose](https://github.com/m4ttheweric/repo-tools/blob/main/commands/worktree.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/worktree/each.mdx b/website/docs/reference/worktree/each.mdx index 602db336..2826b884 100644 --- a/website/docs/reference/worktree/each.mdx +++ b/website/docs/reference/worktree/each.mdx @@ -7,7 +7,7 @@ sidebar_label: each `rt › worktree › each` -Run a command in each worktree (--all | --parked, else pick) +Run a command in each worktree (--all | --on-deck, else pick) ## Usage @@ -19,8 +19,8 @@ rt worktree each [flags] | Flag / Arg | Type | Default | Description | | --- | --- | --- | --- | -| `--all` | boolean | `false` | Run in every worktree (mutually exclusive with --parked) | -| `--parked` | boolean | `false` | Run only in parked worktrees | +| `--all` | boolean | `false` | Run in every worktree (mutually exclusive with --on-deck) | +| `--on-deck` | boolean | `false` | Run only in on-deck worktrees (alias --parked) | | `` | text | | Command to run in each selected worktree; omit both flags to pick interactively | _See code: [commands/worktree.ts › worktreeEach](https://github.com/m4ttheweric/repo-tools/blob/main/commands/worktree.ts)_ diff --git a/website/docs/reference/worktree/freshen.mdx b/website/docs/reference/worktree/freshen.mdx new file mode 100644 index 00000000..552d0822 --- /dev/null +++ b/website/docs/reference/worktree/freshen.mdx @@ -0,0 +1,28 @@ +--- +title: rt worktree freshen +sidebar_label: freshen +--- + +# rt worktree freshen + +`rt › worktree › freshen` + +Freshen worktrees (no arg + TTY → picker over freshenable trees) + +## Usage + +```bash +rt worktree freshen [flags] +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| `` | text | | Tree name to freshen; omit to pick interactively (or run for every repo, headless) | +| [`--repo`](/guides/common-flags) | text | | Narrow to this registered repo | +| [`--json`](/guides/common-flags) | boolean | `false` | Print the raw result as JSON | + +_See code: [commands/worktree.ts › worktreeFreshen](https://github.com/m4ttheweric/repo-tools/blob/main/commands/worktree.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/worktree/index.mdx b/website/docs/reference/worktree/index.mdx index 7e55bc91..4408528d 100644 --- a/website/docs/reference/worktree/index.mdx +++ b/website/docs/reference/worktree/index.mdx @@ -7,7 +7,7 @@ sidebar_label: worktree `rt › worktree` -Worktree-wide operations +Worktree lifecycle (provision/dispose/list) + worktree-wide operations ## Usage @@ -19,6 +19,14 @@ rt worktree | Command | Description | | --- | --- | -| [`each`](each) | Run a command in each worktree (--all | --parked, else pick) | +| [`provision`](provision) | Claim a worktree for a ticket or branch (from the on-deck pool, or freshly created) | +| [`create`](create) | Create a fresh worktree (optionally straight into the on-deck pool) | +| [`dispose`](dispose) | Dispose a worktree (no target + TTY → picker) | +| [`list`](list) | List worktrees | +| [`freshen`](freshen) | Freshen worktrees (no arg + TTY → picker over freshenable trees) | +| [`adopt`](adopt) | One-shot migration: adopt an unmanaged repo's worktrees into the registry | +| [`each`](each) | Run a command in each worktree (--all | --on-deck, else pick) | + +_See code: [commands/worktree.ts › worktreeNav](https://github.com/m4ttheweric/repo-tools/blob/main/commands/worktree.ts)_ {/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/worktree/list.mdx b/website/docs/reference/worktree/list.mdx new file mode 100644 index 00000000..c8a63360 --- /dev/null +++ b/website/docs/reference/worktree/list.mdx @@ -0,0 +1,27 @@ +--- +title: rt worktree list +sidebar_label: list +--- + +# rt worktree list + +`rt › worktree › list` + +List worktrees + +## Usage + +```bash +rt worktree list [flags] +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| [`--repo`](/guides/common-flags) | text | | Narrow to this registered repo (default: every registered repo) | +| [`--json`](/guides/common-flags) | boolean | `false` | Print the raw result as JSON | + +_See code: [commands/worktree.ts › worktreeList](https://github.com/m4ttheweric/repo-tools/blob/main/commands/worktree.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/worktree/provision.mdx b/website/docs/reference/worktree/provision.mdx new file mode 100644 index 00000000..a518b888 --- /dev/null +++ b/website/docs/reference/worktree/provision.mdx @@ -0,0 +1,32 @@ +--- +title: rt worktree provision +sidebar_label: provision +--- + +# rt worktree provision + +`rt › worktree › provision` + +Claim a worktree for a ticket or branch (from the on-deck pool, or freshly created) + +## Usage + +```bash +rt worktree provision [flags] +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| [`--repo`](/guides/common-flags) | text | | Registered repo name (defaults to the current repo) | +| `--ticket` | text | | Linear ticket id — derives the branch name | +| `--title` | text | | Ticket title, used with --ticket to derive the branch slug | +| `--branch` | text | | Explicit branch name (overrides --ticket) | +| `--owner` | text | | Who's claiming this tree | +| `--disposal` | text | | Disposal mode: merge (default) or job | +| [`--json`](/guides/common-flags) | boolean | `false` | Print the raw result as JSON | + +_See code: [commands/worktree.ts › worktreeProvision](https://github.com/m4ttheweric/repo-tools/blob/main/commands/worktree.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file