diff --git a/.changes/11.doc.md b/.changes/11.doc.md new file mode 100644 index 0000000..a57a0d2 --- /dev/null +++ b/.changes/11.doc.md @@ -0,0 +1 @@ +New docs/pool-mode.md explains slots, the claim/release loop, leases, and the refresh gate, new docs/recipes.md explains how caches are shared across trees, with per-ecosystem setups (Bazel, npm/pnpm/yarn, Go, direnv, ports), and docs/configuration.md gains a "Detected defaults" reference for what `wt init` proposes from a repo-root scan. (#11) diff --git a/.changes/11.enh.md b/.changes/11.enh.md new file mode 100644 index 0000000..5964ef9 --- /dev/null +++ b/.changes/11.enh.md @@ -0,0 +1 @@ +Pool mode is here: `wt init` provisions a pool of pre-warmed `slot-N` worktrees, `wt new`/`wt done` claim and release them with crash-safe leases, `wt claim`/`wt release` expose the plumbing, and `wt pool ls`/`wt pool resize` manage the pool; separately, `wt init` now pre-fills its answers in any repo by scanning the root, where a tracked lockfile proposes a hash-gated refresh hook and untracked `.env` files propose a copy list. (#11) diff --git a/PLAN.md b/PLAN.md index 0ba9880..b48368e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -323,7 +323,7 @@ the opt-in Claude Code hook adapter is a post-1.0 phase (Phase 8). **Decision:** default container is the sibling directory **`../.trees/`** — default-mode trees at `../.trees/` (`/` → `-`; collision → explicit error with suggestion, never silent suffixing), -pool slots at `../.trees/pool-1…N`. +pool slots at `../.trees/slot-1…N`. Configurable via `trees_dir`. Siblings keep trees discoverable and on the same volume. @@ -355,7 +355,7 @@ merged-branch slots, and `wt clean -n` previews every action. | Command | One-liner | | -------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `wt` | Interactive fuzzy picker over trees → cd. Non-TTY: porcelain list. | -| `wt init` | Interactive setup: base branch, trees dir, pool y/n + size, prompt indicator, copy list; writes `.git/wt.toml`. | +| `wt init` | Interactive setup: base branch, trees dir, pool y/n + size, prompt indicator, copy list; writes `.git/wt.toml`. Answers pre-filled from a repo-root scan. | | `wt new [--base ]` | Default: create worktree + branch off base. Pool: claim a slot, reset, branch there. Prints tree path on stdout. | | `wt ls [--porcelain] [--json]` | List trees: branch, path, age, ahead/behind base, dirty, slot/lease state. | | `wt go [query]` | Fuzzy-jump: best match cd (with query) or picker (without). | @@ -398,8 +398,9 @@ Merge order: built-in → global → repo. ``` last_fetch # RFC3339 timestamp of last base fetch -leases/pool-3/lease.toml # atomic-mkdir lease: pid, pid_start, branch, claimed_at +leases/slot-3/lease.toml # atomic-mkdir lease: pid, pid_start, branch, claimed_at trees//refresh_hash # SHA-256 of refresh_if_changed files at last refresh +trees//provisioned # marker: the slot's provisioning completed trees//last_used # idle heuristics and picker ranking ``` @@ -526,7 +527,8 @@ and `wt done` points prunable trees at `git worktree prune`. - **Exit:** the eval line in `.zshrc` gives cd-on-select, completions, optional prompt. Tag `v0.1.0-alpha.3`. -**Status (2026-07-20):** code complete, PR open against `dev`. +**Status (2026-07-20): complete.** +Merged to `main`; `v0.1.0-alpha.3` tagged and released. Two D12 refinements surfaced by implementation: the interactivity probe is **stdin + stderr**, never stdout — the shim captures stdout to implement the cd protocol, @@ -545,31 +547,88 @@ The shim's cd set is bare `wt`, `wt go`, and `wt new`; Completions are bootstrapped via `eval "$(wt completion zsh)"` inside the shim rather than inlined, so they track the installed binary and golden files stay stable. -Remaining before exit is met: merge, batch fragments, -tag `v0.1.0-alpha.3`. -Phase 4 (pool mode) is ready to be taken up once the tag is cut. ### Phase 4 — Pool mode (L) -- [ ] `internal/lease`: atomic-mkdir lease + PID/start-time liveness — +- [x] `internal/lease`: atomic-mkdir lease + PID/start-time liveness — TDD before claim exists (unit tests simulate a crash: lease held by a dead PID). -- [ ] Slot provisioning: `wt init` pool path and `wt pool resize` +- [x] Slot provisioning: `wt init` pool path and `wt pool resize` (grow runs the setup hook per slot; shrink refuses claimed slots). -- [ ] Claim/reset semantics (from the reference tool): - `git fetch` (if stale) → `checkout -f --detach ` → - `clean -fd` (**never `-x`** — gitignored build artifacts keep slots warm) → +- [x] Claim/reset semantics (from the reference tool): + `checkout -f --detach ` → + `clean -ffd` (**never `-x`** — gitignored build artifacts keep slots warm; + a nested git repo is refused, not destroyed) → refresh-hash gate → `hooks.refresh` if lockfiles changed → branch create. -- [ ] Pattern guard: reset/release refuse any path not matching - `/pool-N` — unit-tested with hostile inputs + (The `git fetch` (if stale) step is Phase 5's opportunistic fetch.) +- [x] Pattern guard: reset/release refuse any path not matching + `/slot-N` — unit-tested with hostile inputs (main checkout, personal tree, symlinks). -- [ ] `wt new`/`done` dispatch on pool presence; +- [x] `wt new`/`done` dispatch on pool presence; `wt claim`/`release` plumbing; `wt pool ls`. -- [ ] Fuzzy matching over slots targets the _branch/ticket_, not `pool-3` - (picker shows `PROJ-123 → pool-3`). +- [x] Fuzzy matching over slots targets the _branch/ticket_, not `slot-3` + (picker shows `PROJ-123 → slot-3`). - **Exit:** claim → work → release loop with crash-safe leases and warm-cache resets. Tag `v0.1.0-alpha.4`. +**Status (2026-07-21):** code complete, PR open against `dev`. +Design refinements surfaced by implementation: +the lease is a **two-phase handoff**: a claim is held under wt's +own PID while it provisions (a killed wt leaves a provably dead +lease), then repinned to the session captured at startup — the +shell, script, or agent doing the work — on success, with start +time read via `ps -o lstart=` under a pinned TZ and locale; +the concurrency soak test forced a short **flock** around the +lease's check-steal-create section — rename-based stealing raced — +while the mkdir'd directory remains the persistent, crash-surviving +claim, and the kernel drops the flock with its holder, +so the serialization cannot wedge (D15 intact: liveness, not +wall clock, decides staleness). +A lease directory left recordless by a crash is provably dead +under that lock and reclaimed instead of wedging the slot. +Claims take free slots before provably-dead ones, +so crash leftovers survive while the pool has room, +and a missing slot is provisioned on demand, +which makes init/resize crashes and hand-edited sizes self-heal. +`wt release` keeps the branch (plumbing hands it to the PR flow); +`wt done` deletes it, symmetric with default mode. +`hooks.refresh` also runs behind the same hash gate on +default-mode `wt new`, so the two modes share one mechanism: +a fresh tree or slot warms up exactly once — +the setup hook when configured (recording the hash it satisfied), +the gated refresh otherwise. +An adversarial review pass then hardened the loop: +lease records land atomically (a crash can leave a reclaimable +recordless directory, never an unprovable torn record), +release repins the lease to its own session before guards and +reset run (closing a race where a concurrent claim's steal could +put two sessions in one slot), +a durable provisioned marker makes killed provisions redo +themselves instead of silently skipping setup forever, +and claims skip guard-blocked slots with a notice rather than +letting one bad slot fail the whole pool. +`slot-N` became reserved vocabulary in the process: a personal +tree wearing that name would turn silently resettable the day +pool mode is switched on, so `wt new` refuses it, case-folded +because macOS filesystems are. +Two additions beyond the checklist, both serving D5's +"users must not have to invent hook values": +`wt init` now pre-fills its answers from a repo-root scan +(the first *tracked* lockfile proposes a refresh hook gated on +itself; *untracked* `.env`/`.envrc` propose a copy list; flags +and global config both beat the scan, and `hooks.setup` is never +guessed) — documented under "Detected defaults" in +`docs/configuration.md`; +and one aligner renders every tabular listing, so `wt ls` and +`wt pool ls` cannot drift on spacing or trailing whitespace (D13). +A simplification pass then removed what the hardening had +over-built: a memo over `ps` start tokens that saved a handful of +subprocesses and cost a PID-reuse hazard, and a layer of derived +helpers around the init scan's notes. +Remaining before exit is met: merge, batch fragments, +tag `v0.1.0-alpha.4`. +Phase 5 (sync & freshness) is ready once the tag is cut. + ### Phase 5 — Sync & freshness (M) - [ ] `last_fetch` staleness core; display in `ls`/`status`. @@ -625,8 +684,10 @@ stays short): resize, worked example with real hook config. - `docs/configuration.md` — full config reference (every key, default, and merge order), state-dir layout. - - `docs/recipes.md` — per-ecosystem examples - (Go, pnpm, npm/yarn, direnv, `.env` porting, port-per-slot pattern). + - `docs/recipes.md` — the shared-vs-per-tree cache model and + per-ecosystem examples (Bazel, Go, pnpm, npm/yarn, direnv, + `.env` porting, port-per-slot pattern). + _(First version landed with Phase 4; Phase 7 edits it.)_ - `docs/shell.md` — shim internals, prompt indicator, starship segment, completions. - `docs/agents.md` — machine contract: stdout/stderr rules, exit codes, diff --git a/README.md b/README.md index d043821..7709026 100644 --- a/README.md +++ b/README.md @@ -47,18 +47,23 @@ Details in [docs/shell.md](docs/shell.md). | Command | One-liner | | -------------------------------- | ----------------------------------------------------------------------------- | | `wt` | Interactive fuzzy picker over trees → cd. Without a TTY: porcelain list. | -| `wt init` | Set up wt for a repo (prompts, or `--yes` + flags); writes `.git/wt.toml`. | -| `wt new [--base ]` | Create a worktree + branch off the base, and cd there under the shim. | +| `wt init` | Set up wt for a repo (prompts, or `--yes` + flags); writes `.git/wt.toml`. Pre-fills its answers from a [scan of the repo root](docs/configuration.md#detected-defaults). | +| `wt new [--base ]` | Create a worktree + branch off the base, and cd there under the shim. In [pool mode](docs/pool-mode.md): claim a pre-warmed slot instead. | | `wt ls [--porcelain]` | List worktrees: branch, path, state. | | `wt go [query]` | Fuzzy-jump: best match cds (with a query) or picker (without). | | `wt done [name] [--keep-branch]` | Finish a tree: safety checks, remove it, delete its branch. Alias: `wt rm`. | +| `wt claim ` | Pool mode: claim a slot for a branch; slot path on stdout (plumbing). | +| `wt release [name]` | Pool mode: park a slot back on the base, keeping its branch (plumbing). | +| `wt pool ls` | Pool mode: slot-centric view — free, claimed, by whom. | +| `wt pool resize ` | Pool mode: grow (provision + warm) or shrink (free slots only). | | `wt path [name]` | Print a tree's absolute path (plumbing). | | `wt config [--edit]` | Show active config paths and merged values; `--edit` opens `$VISUAL`/`$EDITOR`. | | `wt shell-init zsh [--prompt]` | Emit the shim, completions, and optional prompt hook for eval in `.zshrc`. | | `wt --version` | Version, commit, build date. | -The full surface (`sync`, `clean`, pool mode, …) +The full surface (`sync`, `clean`, `status`, …) lands phase by phase; see [PLAN.md](PLAN.md). +Monorepo pool mode: [docs/pool-mode.md](docs/pool-mode.md). Configuration reference: [docs/configuration.md](docs/configuration.md). Scripting and agent contract: [docs/agents.md](docs/agents.md). diff --git a/docs/agents.md b/docs/agents.md index ce7a843..3a7d5fb 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -46,9 +46,20 @@ then retry", never "wt is broken". | `wt config` | Merged config as TOML; the two config file paths ride along as `#` comments, so the whole document stays parseable TOML. | | `wt init` | Nothing (chatter on stderr). Non-interactive use requires `--yes` plus value flags; without a TTY, prompting is refused (exit 2) rather than hanging. | | `wt done` | Nothing (chatter on stderr). | +| `wt claim` | The claimed slot's absolute path, one line. No free slot: exit 3. | +| `wt release` | Nothing (chatter on stderr). Not a slot / not claimed: exit 3. | +| `wt pool ls` | One aligned row per slot: slot, state (`free`, `claimed`, `stale`, `unprovisioned`), branch, detail. | -`--json` on `ls`/`status`/`doctor` and the pool plumbing -(`wt claim` / `wt release`) land in later phases. +The claim/release loop for agents +(see [pool-mode.md](pool-mode.md)): + +```sh +slot="$(wt claim "$TICKET")" # a warm slot in seconds +cd "$slot" && …work… +wt release "$TICKET" # branch survives for the PR flow +``` + +`--json` on `ls`/`status`/`doctor` lands in a later phase. A porcelain line looks like: diff --git a/docs/configuration.md b/docs/configuration.md index a7e3f8b..fe2440a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,15 +24,19 @@ It is per-person, per-machine config — like `.git/config` itself. ## Per-repo file — `.git/wt.toml` +Written by `wt init`, which pre-fills its answers +from a scan of the repo root — see +[detected defaults](#detected-defaults) below. + ```toml base = "main" # branch new trees start from trees_dir = "../acme.trees" # container for wt-managed trees copy = [".env", ".envrc"] # untracked files copied into new trees [hooks] -setup = "make bootstrap" # runs once, inside each new tree -refresh = "pnpm install" # pool mode (Phase 4): runs on claim, -refresh_if_changed = ["pnpm-lock.yaml"] # …only when these files' hash changed +setup = "make bootstrap" # runs once, inside each new tree/slot +refresh = "pnpm install" # runs on each claim — but only +refresh_if_changed = ["pnpm-lock.yaml"] # …when these files' hash changed [pool] # presence of this table = pool mode size = 6 @@ -43,10 +47,71 @@ size = 6 | `base` | `main` | Base branch `wt new` branches off (override per call with `--base`). | | `trees_dir` | `../.trees` | Container for managed trees. Relative paths anchor at the main checkout, so they mean the same thing from any worktree. | | `copy` | `[]` | Untracked files ported (copied, never symlinked) from the main checkout into each new tree. Entries must stay inside the tree. | -| `hooks.setup` | — | Command run once inside a freshly created tree, via `sh -c`. Its output goes to stderr. | -| `hooks.refresh` | — | Pool mode, lands in Phase 4. | -| `hooks.refresh_if_changed` | `[]` | Pool mode, lands in Phase 4. | -| `pool.size` | — | Number of pre-warmed slots; the `[pool]` table's presence is what enables pool mode. Lands in Phase 4. | +| `hooks.setup` | — | Command run once inside a freshly created tree or provisioned slot, via `sh -c`. Its output goes to stderr. | +| `hooks.refresh` | — | Command run on every claim, and on `wt new` when no setup hook is configured (setup is presumed to leave the tree fully built). Gated by `refresh_if_changed`; without a gate it runs each time. | +| `hooks.refresh_if_changed` | `[]` | Files whose combined hash gates `hooks.refresh`: unchanged hash, no run. The lockfile short-circuit of [pool mode](pool-mode.md). | +| `pool.size` | — | Number of pre-warmed slots; the `[pool]` table's presence is what enables [pool mode](pool-mode.md). Resize with `wt pool resize`. | + +### Detected defaults + +Users rarely know what "warm" means for their tree in wt's terms, +but their lockfiles do. +So `wt init` scans the repo root and pre-fills the form +(or, under `--yes`, the file it writes) from what it finds. +It only ever proposes: every value stays editable. + +**Refresh hook.** The first tracked marker present wins, +most specific first: + +| Marker | Proposed `hooks.refresh` | +| ------------------- | ------------------------ | +| `pnpm-lock.yaml` | `pnpm install` | +| `package-lock.json` | `npm ci` | +| `yarn.lock` | `yarn install` | +| `uv.lock` | `uv sync` | +| `poetry.lock` | `poetry install` | +| `Cargo.lock` | `cargo fetch` | + +The winning marker also becomes `hooks.refresh_if_changed`, +so the hook re-runs only when that lockfile actually moves. +The marker must be **tracked**: +an untracked lockfile never reaches a fresh tree, +so a gate on it would hash as absent forever +and the hook would run once and then never again. + +**Copy list.** `.env`, `.envrc`, and `.env.local` are proposed +when present and **untracked** — the opposite rule, +because a tracked `.env` already travels with every checkout +and needs no copying. + +**`hooks.setup` is never proposed.** +A real bootstrap (`make bootstrap`, `bazel build //...`) +is exactly what a scan cannot guess, +and on a fresh tree the refresh hook runs anyway. + +Three layers settle each value, most explicit first: + +1. **Flags** — `--refresh`, `--refresh-if-changed`, `--copy`, `--setup`. +2. **Global config** — `~/.config/wt/config.toml`. +3. **The scan.** + +wt prints one stderr line per proposal that actually landed, +so it never advertises a value a flag or your global config then beat: + +``` +$ wt init --yes +detected pnpm-lock.yaml — proposing refresh hook "pnpm install" gated on it +detected untracked .env — proposing it for the copy list +initialized wt (default mode) — config at /path/to/repo/.git/wt.toml +``` + +An explicitly empty flag declines a proposal outright: +`wt init --yes --refresh ''` writes no refresh hook, +and drops the gate along with it +(a gate with no hook would describe a run that cannot happen). +It cannot unset a *global* default, though: +by the merge rules above an empty value never overrides, +so remove it from the global file instead. ### Copied files and `wt done` @@ -90,10 +155,12 @@ wt: /path/.git/wt.toml:2:1: unknown key "bogus" (not part of wt's config) under `$XDG_STATE_HOME` (default `~/.local/state`): ``` -wt/repos/acme-3f2a9c1b/ # - +wt/repos/acme-3f2a9c1b/ # - + leases/slot-3/lease.toml # who holds slot 3: pid, start time, branch + trees//refresh_hash # refresh_if_changed hash at last refresh + trees//provisioned # slot finished provisioning (setup ran) ``` The slug keeps state directories human-readable; the hash keeps two clones named `acme` apart. -Phase 2 defines the location; leases, fetch timestamps, -and refresh hashes fill it in as later phases land. +Fetch timestamps join the layout in Phase 5. diff --git a/docs/index.md b/docs/index.md index c69f934..2853f56 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,9 +3,9 @@ Pages land alongside the features they document (standing rule 2 of [PLAN.md](../PLAN.md)): -- [`configuration.md`](configuration.md) — config reference, merge order, state layout +- [`configuration.md`](configuration.md) — config reference, merge order, detected defaults, state layout - [`agents.md`](agents.md) — machine-output contract: streams, exit codes -- `pool-mode.md` — monorepo pool setup and lifecycle (Phase 4) -- `recipes.md` — per-ecosystem examples (Phase 5+) +- [`pool-mode.md`](pool-mode.md) — monorepo pool setup, claim/release lifecycle, leases +- [`recipes.md`](recipes.md) — how caches are (and aren't) shared across trees; per-ecosystem examples - [`shell.md`](shell.md) — shim internals, cd protocol, prompt indicator, completions - `faq.md` — doctor-adjacent questions (Phase 6) diff --git a/docs/pool-mode.md b/docs/pool-mode.md new file mode 100644 index 0000000..339fcf7 --- /dev/null +++ b/docs/pool-mode.md @@ -0,0 +1,133 @@ +# Pool mode + +Pool mode exists for monorepos where a cold worktree is unusable: +a fresh checkout means a ten-minute install +and a 750k-file `node_modules` before the first command runs. +Instead of creating and destroying trees, +`wt` keeps a fixed pool of pre-warmed slots — +`slot-1 … slot-N` inside your trees directory — +that are **claimed**, worked in, and **released** back, warm. + +Ordinary repos don't need any of this; +the default mode's create/remove lifecycle is simpler. +Reach for a pool when tree creation is what hurts. +How caches are shared (and deliberately not shared) across trees, +with per-ecosystem setups, is covered in [recipes.md](recipes.md). + +## Enabling + +Answer "yes" to the pool question in `wt init` +(or pass `--pool-size N`). +There is no mode switch: +the presence of a `[pool]` table in `.git/wt.toml` *is* pool mode. + +```toml +base = "main" +trees_dir = "../acme.trees" +copy = [".env"] + +[hooks] +setup = "pnpm install" # once, when a slot is provisioned +refresh = "pnpm install" # on claim — but only when… +refresh_if_changed = ["pnpm-lock.yaml"] # …these files' hash changed + +[pool] +size = 4 +``` + +`wt init` provisions every slot up front: +a detached worktree parked on the base, +your `copy` files ported in, +the setup hook run, +and the `refresh_if_changed` hash recorded. +That's the slow part, paid once. + +## The loop + +```sh +wt new PROJ-123 # claim a slot, branch there, cd (under the shim) +# …work, commit, push… +wt done # guards, park the slot, delete the branch, free the lease +``` + +In pool mode `wt new` and `wt done` keep their meaning — +start work, finish work — +but land in a slot instead of creating or removing a tree. +A claim is fast because the slot is warm: + +1. take the slot's lease (free slots first, then provably dead ones); +2. reset: forced detach onto the base, then `git clean -ffd` — + never `-x`, so gitignored caches like `node_modules` survive; + an untracked nested git repository is refused with a rescue + hint rather than destroyed; +3. check out your branch (created off the base when new); +4. re-port the `copy` files; +5. run `hooks.refresh` **only if** the `refresh_if_changed` hash moved — + the lockfile short-circuit that turns minutes into seconds. + +`wt done` releases: the same safety guards as the default mode +(dirty tree, unpushed commits, orphaned detached commits), +then the slot parks back on the base and the branch is deleted. +The tree itself is never removed — warmth is the whole point. + +Personal trees still work in a pool repo: +anything that isn't a `slot-N` slot follows the default lifecycle. + +## Plumbing: `wt claim` and `wt release` + +Scripts and agents get explicit spellings: + +```sh +path=$(wt claim PROJ-123) # slot path on stdout, chatter on stderr +wt release PROJ-123 # park the slot, keep the branch +``` + +`wt claim` also accepts an **existing** branch and checks it out; +`wt new` refuses one, exactly as in default mode. +`wt release` always keeps the branch — +its lifecycle belongs to your PR flow — +where `wt done` deletes it (unless `--keep-branch`). + +## Sizing + +```sh +wt pool ls # slot, state, branch, holder +wt pool resize 6 # grow: provision + warm the new slots +wt pool resize 2 # shrink: refuses while a doomed slot is claimed +``` + +## Leases and crashes + +A claim is recorded as a lease naming the claiming session: +PID, its start time, hostname, branch, claim time. +While wt is still preparing the slot the lease names wt itself — +so a claim killed mid-provision self-expires — +and on success it is handed off to your session. +A lease is stale only when its process is **provably dead** +(the PID is gone, or reused by a different process) — +never by wall clock — +so long-running work is never reaped, +and a crashed session never wedges a slot: +the next claim reclaims it, loudly. +Deadness is only provable on the host that claimed: +a lease from another machine — or from before a hostname change — +reads as unverifiable and is never reaped; +`wt release slot-N` clears it. +`wt pool ls` shows stale leases as `stale`; +a wedged slot can always be freed by hand with `wt release slot-N`. +A slot the guards refuse to reset — stranded commits, say — +is skipped with a notice and the claim moves on to the next one. + +## Safety + +Two guards make resets structurally safe (PLAN.md D14): + +- **Pattern guard** — only a path of the exact form + `/slot-N` (symlinks resolved) can ever be reset, + released, or removed by pool machinery. + The main checkout and personal trees don't match, ever — + which also means any `slot-N` name inside the trees dir + is pool property; don't hand-make worktrees there. +- **Orphan guard** — no reset proceeds while a detached HEAD + holds commits nothing else can reach; + wt tells you how to rescue them instead. diff --git a/docs/recipes.md b/docs/recipes.md new file mode 100644 index 0000000..fa48d82 --- /dev/null +++ b/docs/recipes.md @@ -0,0 +1,140 @@ +# Caches, sharing, and per-ecosystem recipes + +You have one repo and several worktrees. +Do they share the expensive stuff — downloaded packages, +build caches, `node_modules` — or does every tree pay full price? + +This page explains the model in plain terms, +then gives a short recipe per ecosystem. + +## The mental model: two kinds of cache + +Everything expensive in a checkout falls into one of two buckets, +and they behave completely differently. + +**1. Machine-wide stores — shared across trees automatically.** +These live in your home directory, outside any tree, +and are looked up by *content*, not by which directory asked. +Examples: npm's tarball cache (`~/.npm`), +pnpm's store, Go's module cache, +Bazel's repository cache and disk cache. +If tree1 already downloaded or built something, +tree2 gets a cache hit — the tool does this by itself, +and wt's only job is to stay out of the way. + +**2. Per-tree working state — never shared, on purpose.** +Things like `node_modules/` and Bazel's `bazel-out/` +live *inside* a tree and belong to it alone. +It is tempting to symlink them between trees, and wt refuses to: +linked dependency trees break the tools that read them +(module resolution, path-keyed caches, file watchers). +So this bucket cannot be shared sideways — +it can only be **built cheaply** (from bucket 1) +or **kept warm and reused over time** (pool mode). + +That second point answers the common question directly: +**"tree1 already exists — will its caches speed up spinning +up tree2?"** +For bucket 1, yes, automatically. +For bucket 2, no — tree2 builds its own copy +(fast, because bucket 1 feeds it), +or, in [pool mode](pool-mode.md), tree2 is a slot +that was warmed up in advance and never goes cold: +resets use `git clean -ffd`, never `-x`, +so gitignored caches survive every claim/release cycle, +and `hooks.refresh` re-runs only when your lockfile +actually changed. + +## Recipes + +### Go + +Nothing to do. +The module cache and build cache are already machine-wide; +a new tree's first `go build` reuses both. + +### pnpm + +Nothing to do. +pnpm's content-addressed store means each tree's `node_modules` +is mostly hard links into one shared store — +N trees cost little more than one. + +```toml +[hooks] +refresh = "pnpm install" +refresh_if_changed = ["pnpm-lock.yaml"] +``` + +### npm / yarn + +Each tree carries a full `node_modules`, so budget disk for N copies +(`wt status` will show per-tree sizes in a later phase). +The global cache makes installs cheap-ish; +pool mode makes them rare: + +```toml +[hooks] +setup = "npm ci" +refresh = "npm ci" +refresh_if_changed = ["package-lock.json"] + +[pool] +size = 4 +``` + +With this, a claim reinstalls only when the lockfile moved. + +### Bazel + +Two caches, two behaviors: + +- The **repository cache** and a **disk cache** are shared across + every tree — but the disk cache is opt-in. + Turning it on is the single highest-leverage line here, + and it is what makes tree1's build genuinely accelerate tree2's: + + ``` + # ~/.bazelrc + build --disk_cache=~/.cache/bazel-disk + ``` + +- The **output base** (analysis cache, `bazel-out/`) is keyed by + the tree's directory *path*, so it is per-tree by nature. + Pool slots help in a quiet way: their paths are stable + (`../acme.trees/slot-3` forever), so a slot keeps the same + output base across claims and stays warm, + where throwaway trees at branch-named paths start cold each time. + +### `.env` files and other untracked secrets + +Untracked files don't follow you into a new tree. +List them once and wt copies them into every tree and slot — +copies, not symlinks, so nothing resolves paths strangely: + +```toml +copy = [".env", ".envrc"] +``` + +On a claim the copies are refreshed from the main checkout; +an edited copy is treated as your data and guarded on the way out. + +### direnv + +Combine `copy = [".envrc"]` with a setup hook that runs +`direnv allow`, or wrap your hooks in `direnv exec . ` +so they see the tree's environment. + +### One dev server per tree: ports + +Parallel trees mean parallel dev servers fighting over ports. +Derive the port from the slot number instead of hardcoding it — +in a copied `.envrc`: + +```sh +# slot-3 → port 3003 +slot="${PWD##*slot-}" +case "$PWD" in *slot-[0-9]*) export PORT=$((3000 + slot)) ;; esac +``` + +wt has no port machinery on purpose; the pattern is one line. diff --git a/internal/cli/claim.go b/internal/cli/claim.go new file mode 100644 index 0000000..1a5be8b --- /dev/null +++ b/internal/cli/claim.go @@ -0,0 +1,104 @@ +package cli + +import ( + "cmp" + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/loganthomas/wt/internal/pool" +) + +func newClaimCmd() *cobra.Command { + var base string + cmd := &cobra.Command{ + Use: "claim ", + Short: "Claim a pool slot for a branch (plumbing)", + Long: "Claim a free pool slot, reset it onto the base, and check out\n" + + "the branch there — creating it off the base when it is new.\n" + + "Prints the slot path on stdout; scripts and agents cd there.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runClaim(cmd, args[0], base) + }, + } + cmd.Flags().StringVar(&base, "base", "", + "ref to park and branch from (default: the configured base)") + return cmd +} + +func runClaim(cmd *cobra.Command, branch, baseFlag string) error { + ctx := cmd.Context() + p, err := openPool(ctx) + if err != nil { + return err + } + base := cmp.Or(baseFlag, p.cfg.Base) + if !p.g.ValidBranchName(ctx, branch) { + return usageError{fmt.Errorf("%q is not a valid branch name", branch)} + } + // R4 up front: claiming an already-checked-out branch would + // only fail later, deep inside git, with a worse message. + trees, err := p.g.Worktrees(ctx) + if err != nil { + return err + } + if t, ok := treeHoldingBranch(trees, branch); ok { + return preconditionf("branch %q is already checked out in %s", branch, t.Path) + } + if err := checkBase(ctx, p.g, base); err != nil { + return err + } + dest, err := p.claimSlot(ctx, branch, base, cmd.ErrOrStderr()) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), dest) + return nil +} + +func newReleaseCmd() *cobra.Command { + return &cobra.Command{ + Use: "release [name]", + Short: "Release a pool slot, keeping its branch (plumbing)", + Long: "Park the slot back on the base and free its lease.\n" + + "The branch survives — handing its lifecycle to the PR flow —\n" + + "where `wt done` would delete it.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runRelease(cmd, nameArg(args)) + }, + } +} + +func runRelease(cmd *cobra.Command, name string) error { + ctx := cmd.Context() + p, err := openPool(ctx) + if err != nil { + return err + } + trees, err := p.g.Worktrees(ctx) + if err != nil { + return err + } + // A slot named directly can be leased yet have no worktree: a + // claim or provision killed before worktree-add, or a tree + // removed out of band. resolveTree cannot name such a slot, and + // pool ls sends users here to clear exactly those states. + if pool.IsSlotName(name) { + if _, registered := findTree(trees, filepath.Join(p.treesDir(), name)); !registered { + return p.releaseVacantSlot(name, cmd.ErrOrStderr()) + } + } + target, err := resolveTree(ctx, trees, name) + if err != nil { + return err + } + slot, ok := pool.SlotPath(p.treesDir(), target.Path) + if !ok { + return preconditionf( + "%s is not a pool slot — `wt done` finishes personal trees", target.Path) + } + return p.releaseSlot(ctx, target, slot, false, cmd.ErrOrStderr()) +} diff --git a/internal/cli/copies.go b/internal/cli/copies.go index 6e29bf5..59a1003 100644 --- a/internal/cli/copies.go +++ b/internal/cli/copies.go @@ -15,6 +15,7 @@ import ( "path/filepath" "github.com/loganthomas/wt/internal/gitx" + "github.com/loganthomas/wt/internal/guard" ) // copyFiles ports the configured untracked files from the main @@ -71,6 +72,35 @@ func copyFile(src, dst string) error { return os.WriteFile(dst, data, info.Mode().Perm()) } +// finishGuards is the one safety sequence in front of every +// finishing action (remove, release, or shrink; R2): the planted +// copies are split, an edited one refuses (it is user data the +// action would destroy, invisible to git status when ignored), +// then the dirty and orphaned-commit guards speak. Returns the +// pristine copies for callers that must sweep them. +func finishGuards( + ctx context.Context, srcRoot string, t gitx.Worktree, copies []string, +) ([]string, error) { + pristine, edited, err := splitCopies(ctx, srcRoot, t.Path, copies) + if err != nil { + return nil, err + } + if len(edited) > 0 { + return nil, preconditionf( + "%s: the planted copy %s no longer matches the main checkout — "+ + "back it up, or make the two match first", t.Path, edited[0]) + } + if err := guard.CheckDirty(ctx, t.Path, pristine...); err != nil { + return nil, err + } + if t.Detached { + if err := guard.CheckOrphans(ctx, t.Path); err != nil { + return nil, err + } + } + return pristine, nil +} + // splitCopies partitions the configured copy files found untracked // in the tree: pristine ones still match the main checkout byte for // byte and are wt's own plantings, free to sweep on removal; edited diff --git a/internal/cli/detect.go b/internal/cli/detect.go new file mode 100644 index 0000000..604c30a --- /dev/null +++ b/internal/cli/detect.go @@ -0,0 +1,159 @@ +// Repo-root detection behind wt init's proposed defaults: users +// rarely know what "warm" means for their tree in wt's terms, but +// their lockfiles do. Everything found here is only a proposal; +// the form and the flags override every value, and an empty scan +// proposes nothing. +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/pflag" + + "github.com/loganthomas/wt/internal/config" + "github.com/loganthomas/wt/internal/gitx" + "github.com/loganthomas/wt/internal/repo" +) + +// ecosystems maps a root marker file to the refresh hook it +// implies, most specific first: the first marker present wins, so +// a repo carrying several lockfiles gets the most telling one. +// Only refresh is proposed, never setup: on a fresh tree the +// refresh hook runs anyway (finishFresh), and real setup hooks +// (make bootstrap) are exactly what a scan cannot guess. +var ecosystems = []struct { + marker string + hook string +}{ + {"pnpm-lock.yaml", "pnpm install"}, + {"package-lock.json", "npm ci"}, + {"yarn.lock", "yarn install"}, + {"uv.lock", "uv sync"}, + {"poetry.lock", "poetry install"}, + {"Cargo.lock", "cargo fetch"}, +} + +// copyCandidates are the well-known untracked files a new tree +// needs a copy of. +var copyCandidates = []string{".env", ".envrc", ".env.local"} + +// detected is what the repo scan proposes for the init form. +// The winning marker doubles as the refresh gate, so the two +// cannot drift from the hook they belong to. +type detected struct { + marker string // ecosystem marker that won, "" when none + refresh string + copies []string +} + +// detectDefaults scans root for well-known markers and proposes +// init defaults. tracked reports which candidate files the index +// owns; nil means unknown, which proposes nothing that depends on +// it. Hooks require a tracked marker: an untracked lockfile never +// reaches a fresh tree, so its gate would hash as absent forever +// and the hook would run once and then never again. Copies +// require the opposite (a tracked .env travels with checkouts and +// needs no copying). +func detectDefaults(root string, tracked map[string]bool) detected { + var d detected + if tracked == nil { + return d + } + for _, e := range ecosystems { + if !tracked[e.marker] || !present(root, e.marker) { + continue + } + d.marker, d.refresh = e.marker, e.hook + break + } + for _, name := range copyCandidates { + if !present(root, name) || tracked[name] { + continue + } + d.copies = append(d.copies, name) + } + return d +} + +// applyDetected settles the hook and copy values across the three +// layers, most explicit first: flags, then global defaults, then +// what the scan proposes, so users confirm recognizable values +// instead of inventing them. A changed flag wins even when its +// value is empty, so an empty --refresh declines the scan's +// proposal and keeps it out of the repo file. It cannot unset a +// global default: by the layering contract an empty value never +// overrides, so a global hook merges back in at load time and the +// global config is where to remove it. It returns the notes worth +// printing: only the proposals that survived, because advertising +// a value that flags or global config then beat would misstate +// what was configured. +func applyDetected( + opts *initOptions, flags *pflag.FlagSet, seed config.Config, det detected, +) []string { + if !flags.Changed("setup") { + opts.setup = seed.Hooks.Setup + } + detHook := false + if !flags.Changed("refresh") { + opts.refresh = seed.Hooks.Refresh + if opts.refresh == "" { + opts.refresh = det.refresh + detHook = opts.refresh != "" + } + } + // The detected gate travels only with the detected hook: + // pinning a hook from another layer to a lockfile it knows + // nothing about would silently skip it on unchanged claims. + if !flags.Changed("refresh-if-changed") { + opts.refreshGate = seed.Hooks.RefreshIfChanged + if opts.refreshGate == nil && detHook { + opts.refreshGate = []string{det.marker} + } + } + detCopies := false + if !flags.Changed("copy") { + opts.copyList = seed.Copy + if opts.copyList == nil { + opts.copyList = det.copies + detCopies = opts.copyList != nil + } + } + + var notes []string + if detHook { + notes = append(notes, fmt.Sprintf( + "detected %s — proposing refresh hook %q gated on it", det.marker, det.refresh)) + } + if detCopies { + for _, name := range det.copies { + notes = append(notes, fmt.Sprintf( + "detected untracked %s — proposing it for the copy list", name)) + } + } + return notes +} + +// present reports whether name exists at the repo root. +func present(root, name string) bool { + _, err := os.Stat(filepath.Join(root, name)) + return err == nil +} + +// detectTracked reports which detection candidates (ecosystem +// markers and copy files) the index owns; nil on error, which +// detectDefaults reads as "unknown, propose none". +func detectTracked(ctx context.Context, r *repo.Repo) map[string]bool { + names := make([]string, 0, len(copyCandidates)+len(ecosystems)) + names = append(names, copyCandidates...) + for _, e := range ecosystems { + names = append(names, e.marker) + } + tracked, err := gitx.New(r.Root).Tracked(ctx, names...) + if err != nil { + return nil + } + return tracked +} diff --git a/internal/cli/detect_test.go b/internal/cli/detect_test.go new file mode 100644 index 0000000..600d2f4 --- /dev/null +++ b/internal/cli/detect_test.go @@ -0,0 +1,59 @@ +package cli + +import ( + "os" + "path/filepath" + "slices" + "testing" +) + +func touch(t *testing.T, root, name string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, name), []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestDetectDefaultsEmptyRepo(t *testing.T) { + d := detectDefaults(t.TempDir(), map[string]bool{}) + if d.marker != "" || d.refresh != "" || d.copies != nil { + t.Errorf("empty repo proposed %+v, want nothing", d) + } +} + +func TestDetectDefaultsMostSpecificLockfileWins(t *testing.T) { + root := t.TempDir() + touch(t, root, "package-lock.json") + touch(t, root, "pnpm-lock.yaml") + d := detectDefaults(root, map[string]bool{"package-lock.json": true, "pnpm-lock.yaml": true}) + if d.refresh != "pnpm install" || d.marker != "pnpm-lock.yaml" { + t.Errorf("proposed %q gated on %q, want pnpm install on its lockfile", d.refresh, d.marker) + } +} + +func TestDetectDefaultsCopyCandidates(t *testing.T) { + root := t.TempDir() + touch(t, root, ".env") + touch(t, root, ".envrc") + + d := detectDefaults(root, map[string]bool{".envrc": true}) + if !slices.Equal(d.copies, []string{".env"}) { + t.Errorf("copies = %v, want only the untracked .env", d.copies) + } + + // Unknown tracking (nil map) must propose no copies: a wrong + // guess would plant files the user never asked for. + if d := detectDefaults(root, nil); d.copies != nil { + t.Errorf("copies with unknown tracking = %v, want none", d.copies) + } +} + +// An untracked lockfile never reaches a fresh tree, so gating on +// it would run the hook once and then never again. +func TestDetectDefaultsIgnoresUntrackedMarkers(t *testing.T) { + root := t.TempDir() + touch(t, root, "Cargo.lock") + if d := detectDefaults(root, map[string]bool{}); d.refresh != "" { + t.Errorf("refresh = %q, want none for an untracked lockfile", d.refresh) + } +} diff --git a/internal/cli/done.go b/internal/cli/done.go index 5c2bdee..1c93ad6 100644 --- a/internal/cli/done.go +++ b/internal/cli/done.go @@ -9,6 +9,7 @@ import ( "github.com/loganthomas/wt/internal/gitx" "github.com/loganthomas/wt/internal/guard" + "github.com/loganthomas/wt/internal/pool" ) func newDoneCmd() *cobra.Command { @@ -44,52 +45,29 @@ func runDone(cmd *cobra.Command, name string, keepBranch bool) error { if target.Path == w.repo.Root { return preconditionf("%s is the main checkout — wt only removes trees it manages", target.Path) } - // Checked before the guards and the copy sweep: git would - // refuse the removal anyway, but only after wt had already - // deleted the planted copy files. - if target.Locked { - reason := "" - if target.LockedReason != "" { - reason = fmt.Sprintf(" (%s)", target.LockedReason) + // A slot is finished by releasing it, never by removing it: + // the warm tree is the pool's whole value. Everything below + // stays the personal-tree path. + if w.cfg.Pool != nil { + if slot, ok := pool.SlotPath(w.treesDir(), target.Path); ok { + p, err := poolOf(w) + if err != nil { + return err + } + return p.releaseSlot(ctx, target, slot, !keepBranch, cmd.ErrOrStderr()) } - return preconditionf("%s is locked%s — `git worktree unlock %s` first", - target.Path, reason, target.Path) } - // A prunable tree's directory is already gone, so the guards - // (which run inside it) cannot vouch for anything; hand the - // cleanup to git rather than fail on a raw chdir error. - if target.Prunable { - return preconditionf( - "%s is gone from disk — `git worktree prune` clears the stale registration", - target.Path) + if err := checkRemovable(target); err != nil { + return err } // Guards before anything destructive (R2). The unpushed check // runs only when the branch is about to be deleted: with // --keep-branch every commit stays reachable through it. - // Pristine copies of the configured copy files are wt's own - // plantings and don't count as dirt; an edited one still does. - pristine, edited, err := splitCopies(ctx, w.repo.Root, target.Path, w.cfg.Copy) + pristine, err := finishGuards(ctx, w.repo.Root, target, w.cfg.Copy) if err != nil { return err } - // Edited copies are refused here, not left to the dirty guard: - // copy files are routinely gitignored, invisible to git status, - // and `git worktree remove` deletes ignored files without asking. - if len(edited) > 0 { - return preconditionf( - "%s: the planted copy %s no longer matches the main checkout — "+ - "back it up, or make the two match first", - target.Path, edited[0]) - } - if err := guard.CheckDirty(ctx, target.Path, pristine...); err != nil { - return err - } - if target.Detached { - if err := guard.CheckOrphans(ctx, target.Path); err != nil { - return err - } - } deleteBranch := target.Branch != "" && !keepBranch if deleteBranch { if err := guard.CheckUnpushed(ctx, target.Path, w.cfg.Base); err != nil { @@ -107,15 +85,16 @@ func runDone(cmd *cobra.Command, name string, keepBranch bool) error { if err := g.WorktreeRemove(ctx, target.Path); err != nil { return err } - chatter := cmd.ErrOrStderr() - fmt.Fprintf(chatter, "removed %s\n", target.Path) - if deleteBranch { - if err := g.DeleteBranch(ctx, target.Branch); err != nil { + if name, ok := w.treeStateName(target.Path); ok { + st, err := w.stateDir() + if err != nil { + return err + } + if err := st.RemoveTree(name); err != nil { return err } - fmt.Fprintf(chatter, "deleted branch %s\n", target.Branch) - } else if target.Branch != "" { - fmt.Fprintf(chatter, "kept branch %s\n", target.Branch) } - return nil + chatter := cmd.ErrOrStderr() + fmt.Fprintf(chatter, "removed %s\n", target.Path) + return finishBranch(ctx, g, target.Branch, deleteBranch, chatter) } diff --git a/internal/cli/go.go b/internal/cli/go.go index da419c6..ccd1bf9 100644 --- a/internal/cli/go.go +++ b/internal/cli/go.go @@ -8,6 +8,8 @@ import ( "github.com/loganthomas/wt/internal/gitx" "github.com/loganthomas/wt/internal/nav" + "github.com/loganthomas/wt/internal/pool" + "github.com/loganthomas/wt/internal/repo" ) func newGoCmd() *cobra.Command { @@ -31,14 +33,14 @@ func newGoCmd() *cobra.Command { // `wt go`, and `wt go `: it ends with a single tree path // on stdout, which the shell shim turns into a cd (D11). func runJump(cmd *cobra.Command, query string) error { - trees, err := repoTrees(cmd.Context()) + r, trees, err := repoTrees(cmd.Context()) if err != nil { return err } if query == "" { - return jumpInteractive(cmd, trees) + return jumpInteractive(cmd, r, trees) } - winner, contenders := nav.Resolve(jumpCandidates(trees), query) + winner, contenders := nav.Resolve(jumpCandidates(trees, slotTreesDir(r)), query) switch { case winner != nil: fmt.Fprintln(cmd.OutOrStdout(), winner.Path) @@ -57,13 +59,15 @@ func runJump(cmd *cobra.Command, query string) error { // jumpInteractive opens the picker when a human is present; // otherwise it degrades to the porcelain listing so scripts and -// agents never hang on a TUI (D12, R15). -func jumpInteractive(cmd *cobra.Command, trees []gitx.Worktree) error { +// agents never hang on a TUI (D12, R15). Candidates are built past +// the TTY gate: that listing needs none of them, and repoTrees +// keeps read-only commands off the config on purpose. +func jumpInteractive(cmd *cobra.Command, r *repo.Repo, trees []gitx.Worktree) error { if !interactive() { _, err := fmt.Fprint(cmd.OutOrStdout(), formatPorcelain(trees)) return err } - choice, err := pickTree(cmd.Context(), jumpCandidates(trees)) + choice, err := pickTree(cmd.Context(), jumpCandidates(trees, slotTreesDir(r))) if err != nil { return err } @@ -73,13 +77,36 @@ func jumpInteractive(cmd *cobra.Command, trees []gitx.Worktree) error { // jumpCandidates converts worktrees into jump targets. // Bare entries are dropped: they have no checkout to land in. -func jumpCandidates(trees []gitx.Worktree) []nav.Candidate { +// Parked slots are dropped too: there is nothing to work on in +// one, and landing there would race the next claim's reset. +// Claimed slots carry their address for display +// ("branch → slot-3", PLAN.md Phase 4). +func jumpCandidates(trees []gitx.Worktree, treesDir string) []nav.Candidate { cands := make([]nav.Candidate, 0, len(trees)) for _, t := range trees { if t.Bare { continue } - cands = append(cands, nav.Candidate{Branch: t.Branch, Path: t.Path}) + var slot string + if treesDir != "" { + slot, _ = pool.SlotPath(treesDir, t.Path) + } + if slot != "" && t.Branch == "" { + continue + } + cands = append(cands, nav.Candidate{Branch: t.Branch, Path: t.Path, Slot: slot}) } return cands } + +// slotTreesDir reports the container to annotate slots from, or +// "" when pool mode is off, and on config trouble, which only +// costs the annotation, never the jump, matching repoTrees' +// broken-config tolerance. +func slotTreesDir(r *repo.Repo) string { + cfg, err := loadMerged(r) + if err != nil || cfg.Pool == nil { + return "" + } + return r.TreesDir(cfg.TreesDir) +} diff --git a/internal/cli/init.go b/internal/cli/init.go index dd2c3e7..d63acee 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -2,8 +2,10 @@ package cli import ( "cmp" + "context" "errors" "fmt" + "io" "os" "strconv" "strings" @@ -16,11 +18,14 @@ import ( ) type initOptions struct { - base string - treesDir string - poolSize int - copyList []string - yes bool + base string + treesDir string + poolSize int + copyList []string + setup string + refresh string + refreshGate []string + yes bool } func newInitCmd() *cobra.Command { @@ -29,7 +34,13 @@ func newInitCmd() *cobra.Command { Use: "init", Short: "Set up wt for this repository", Long: "Set up wt for this repository: asks a few questions\n" + - "(or takes flags with --yes) and writes .git/wt.toml.", + "(or takes flags with --yes) and writes .git/wt.toml.\n" + + "\n" + + "The answers come pre-filled from a scan of the repo root:\n" + + "a tracked lockfile proposes a refresh hook gated on it, and\n" + + "well-known untracked files (.env, .envrc) propose a copy list.\n" + + "Flags and your global config both beat the scan, and an empty\n" + + "flag value declines a proposal outright.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return runInit(cmd, opts) @@ -39,7 +50,13 @@ func newInitCmd() *cobra.Command { f.StringVar(&opts.base, "base", "", "base branch new trees start from") f.StringVar(&opts.treesDir, "trees-dir", "", "container directory for wt-managed trees") f.IntVar(&opts.poolSize, "pool-size", 0, "pre-warmed pool slots (0 keeps pool mode off)") - f.StringSliceVar(&opts.copyList, "copy", nil, "untracked files to copy into new trees") + f.StringSliceVar(&opts.copyList, "copy", nil, + "untracked files to copy into new trees (empty declines the scan's proposal)") + f.StringVar(&opts.setup, "setup", "", "hook run once inside each fresh tree or slot") + f.StringVar(&opts.refresh, "refresh", "", + "hook that keeps trees warm on claims (empty declines the scan's proposal)") + f.StringSliceVar(&opts.refreshGate, "refresh-if-changed", nil, + "files whose hash gates the refresh hook (default: the detected lockfile)") f.BoolVar(&opts.yes, "yes", false, "no prompts; use defaults for anything not flagged") return cmd } @@ -59,33 +76,53 @@ func runInit(cmd *cobra.Command, opts initOptions) error { return preconditionf( "wt is already set up here (%s) — edit it with `wt config --edit`", r.ConfigPath()) } + // Before any scan runs: a run that is about to be refused must + // not chat about proposals first. + if !opts.yes && !isTerminal(os.Stdin) { + return usageError{errors.New( + "stdin is not a terminal; run `wt init --yes` with value flags instead")} + } - // Global defaults (and the built-ins behind them) seed the - // form, so flags only need to state what differs. + // Three layers seed the form, most explicit first: flags, then + // global defaults, then what a scan of the repo root proposes, + // so users confirm recognizable values instead of inventing + // them (lockfiles know what "warm" means here better than a + // first-time user does). seed, err := loadMerged(r) if err != nil { return err } + chatter := cmd.ErrOrStderr() opts.base = cmp.Or(opts.base, seed.Base) opts.treesDir = cmp.Or(opts.treesDir, seed.TreesDir, r.DefaultTreesDir()) - if opts.copyList == nil { - opts.copyList = seed.Copy + det := detectDefaults(r.Root, detectTracked(ctx, r)) + for _, note := range applyDetected(&opts, cmd.Flags(), seed, det) { + fmt.Fprintln(chatter, note) } if !opts.yes { - if !isTerminal(os.Stdin) { - return usageError{errors.New( - "stdin is not a terminal; run `wt init --yes` with value flags instead")} - } if err := runInitForm(&opts); err != nil { return err } } + // After both paths have had their say: a gate with no hook + // gates nothing, and writing it would put a key in wt.toml + // describing a run that cannot happen. Reachable by declining a + // global hook with an empty --refresh, clearing the form's hook + // field, or naming only --refresh-if-changed. + if opts.refresh == "" { + opts.refreshGate = nil + } cfg := config.Config{ Base: opts.base, TreesDir: opts.treesDir, Copy: opts.copyList, + Hooks: config.Hooks{ + Setup: opts.setup, + Refresh: opts.refresh, + RefreshIfChanged: opts.refreshGate, + }, } if opts.poolSize > 0 { cfg.Pool = &config.Pool{Size: opts.poolSize} @@ -98,7 +135,41 @@ func runInit(cmd *cobra.Command, opts initOptions) error { if cfg.Pool != nil { mode = fmt.Sprintf("pool mode, %d slots", cfg.Pool.Size) } - fmt.Fprintf(cmd.ErrOrStderr(), "initialized wt (%s) — config at %s\n", mode, r.ConfigPath()) + fmt.Fprintf(chatter, "initialized wt (%s) — config at %s\n", mode, r.ConfigPath()) + if cfg.Pool != nil { + return provisionInitialPool(ctx, r, chatter) + } + return nil +} + +// provisionInitialPool pre-warms the just-configured pool. +// The merged config is reloaded first: hooks and copy lists may +// come from the global layer, and provisioning must see exactly +// what a later claim will. A base that doesn't resolve yet only +// defers the work: claims provision missing slots on demand. +func provisionInitialPool(ctx context.Context, r *repo.Repo, chatter io.Writer) error { + merged, err := loadMerged(r) + if err != nil { + return err + } + p, err := poolOf(&wtRepo{repo: r, cfg: merged}) + if err != nil { + return err + } + if !p.g.HasCommit(ctx, merged.Base) { + fmt.Fprintf(chatter, + "base %q not found — slots will be provisioned on first claim\n", merged.Base) + return nil + } + if err := resizeHeld(p.provisionPool(ctx, 0, merged.Pool.Size, chatter)); err != nil { + // The config is already saved and valid, and a rerun of + // init would refuse it. The advice must name healing that + // exists: a resize to the size already configured returns + // early, while every claim provisions what it finds missing. + return fmt.Errorf( + "%w — the config is saved; fix the cause (`wt config --edit` for hooks), "+ + "and the missing slots provision on the next claim", err) + } return nil } @@ -106,6 +177,7 @@ func runInit(cmd *cobra.Command, opts initOptions) error { // pre-filled with whatever the flags and defaults already chose. func runInitForm(opts *initOptions) error { copyStr := strings.Join(opts.copyList, ",") + gateStr := strings.Join(opts.refreshGate, ",") usePool := opts.poolSize > 0 sizeStr := "4" if usePool { @@ -127,6 +199,18 @@ func runInitForm(opts *initOptions) error { Title("Copy into new trees"). Description("Untracked files new trees need, comma-separated (e.g. .env,.envrc)"). Value(©Str), + huh.NewInput(). + Title("Setup hook"). + Description("Runs once inside each fresh tree or slot; empty for none"). + Value(&opts.setup), + huh.NewInput(). + Title("Refresh hook"). + Description("Keeps trees warm on claims (e.g. pnpm install); empty for none"). + Value(&opts.refresh), + huh.NewInput(). + Title("Refresh gate"). + Description("Comma-separated files; refresh runs only when their hash changes"). + Value(&gateStr), huh.NewConfirm(). Title("Pool mode?"). Description("Pre-warmed, reusable slots — for big repos where cold trees are unusable"). @@ -145,6 +229,7 @@ func runInitForm(opts *initOptions) error { } opts.copyList = splitList(copyStr) + opts.refreshGate = splitList(gateStr) opts.poolSize = 0 if usePool { // Validated by the form; Atoi cannot fail here. diff --git a/internal/cli/ls.go b/internal/cli/ls.go index 1ff05ff..d8c4452 100644 --- a/internal/cli/ls.go +++ b/internal/cli/ls.go @@ -27,7 +27,7 @@ func newLsCmd() *cobra.Command { } func runLs(cmd *cobra.Command, porcelain bool) error { - trees, err := repoTrees(cmd.Context()) + _, trees, err := repoTrees(cmd.Context()) if err != nil { return err } @@ -53,29 +53,43 @@ func formatPorcelain(trees []gitx.Worktree) string { } // formatRows renders one aligned row per worktree. -// Widths are computed by hand rather than with text/tabwriter: -// padding must only ever sit between cells, -// because trimming rendered lines would also strip -// a path's own trailing spaces, -// and stdout must stay exact for machine consumers (D13). func formatRows(trees []gitx.Worktree) string { - branchWidth, pathWidth := 0, 0 - rows := make([][3]string, 0, len(trees)) + rows := make([][]string, 0, len(trees)) for _, t := range trees { - row := [3]string{branchLabel(t), t.Path, stateLabel(t)} - rows = append(rows, row) - branchWidth = max(branchWidth, utf8.RuneCountInString(row[0])) - pathWidth = max(pathWidth, utf8.RuneCountInString(row[1])) + rows = append(rows, []string{branchLabel(t), t.Path, stateLabel(t)}) + } + return alignRows(rows) +} + +// alignRows renders rows in aligned columns, shared by every +// tabular listing. Widths are computed by hand rather than with +// text/tabwriter: padding must only ever sit between cells, +// because trimming rendered lines would also strip a path's own +// trailing spaces, and stdout must stay exact for machine +// consumers (D13). Trailing empty cells drop their padding too, +// so no line ever ends in spaces. +func alignRows(rows [][]string) string { + var width []int + for _, row := range rows { + for i, cell := range row { + if i == len(width) { + width = append(width, 0) + } + width[i] = max(width[i], utf8.RuneCountInString(cell)) + } } const gap = 2 var out strings.Builder for _, row := range rows { - // fmt pads %s to a minimum rune count, matching the width math above. - if row[2] == "" { - fmt.Fprintf(&out, "%-*s%s\n", branchWidth+gap, row[0], row[1]) - } else { - fmt.Fprintf(&out, "%-*s%-*s%s\n", branchWidth+gap, row[0], pathWidth+gap, row[1], row[2]) + last := len(row) - 1 + for last > 0 && row[last] == "" { + last-- + } + for i := range last { + // fmt pads %s to a minimum rune count, matching the width math above. + fmt.Fprintf(&out, "%-*s", width[i]+gap, row[i]) } + fmt.Fprintln(&out, row[last]) } return out.String() } diff --git a/internal/cli/new.go b/internal/cli/new.go index 9ec6553..618582d 100644 --- a/internal/cli/new.go +++ b/internal/cli/new.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" "github.com/loganthomas/wt/internal/gitx" + "github.com/loganthomas/wt/internal/pool" "github.com/loganthomas/wt/internal/repo" ) @@ -41,27 +42,57 @@ func runNew(cmd *cobra.Command, branch, baseFlag string) error { if !g.ValidBranchName(ctx, branch) { return usageError{fmt.Errorf("%q is not a valid branch name", branch)} } - if g.HasCommit(ctx, "refs/heads/"+branch) { + if g.HasBranch(ctx, branch) { trees, err := g.Worktrees(ctx) if err != nil { return err } // R4: when the branch lives in some tree already, // the error must point straight at it. - for _, t := range trees { - if t.Branch == branch { - return preconditionf("branch %q is already checked out in %s", branch, t.Path) - } + if t, ok := treeHoldingBranch(trees, branch); ok { + return preconditionf("branch %q is already checked out in %s", branch, t.Path) + } + // In pool mode the natural resume for an existing branch, + // say one left by a claim that failed after its branch + // create, is a claim, and the advice must say so. + if w.cfg.Pool != nil { + return preconditionf( + "branch %q already exists — `wt claim %s` resumes it in a slot, "+ + "or pick another name", branch, branch) } return preconditionf( "branch %q already exists — pick another name, or delete the branch first", branch) } - if !g.HasCommit(ctx, base) { - return preconditionf( - "base %q does not resolve to a commit — fetch it, or set base in wt.toml", base) + if err := checkBase(ctx, g, base); err != nil { + return err + } + chatter := cmd.ErrOrStderr() + + // Pool mode: the same intent lands in a claimed slot instead + // of a fresh tree (D3: the [pool] table is the dispatch). + if w.cfg.Pool != nil { + p, err := poolOf(w) + if err != nil { + return err + } + dest, err := p.claimSlot(ctx, branch, base, chatter) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), dest) + return nil } - dest := filepath.Join(w.treesDir(), repo.SanitizeBranch(branch)) + // slot-N names are pool property (D14): a personal tree wearing + // one would become silently resettable the day pool mode is + // switched on, and its uncommitted work fair game for a claim. + name := repo.SanitizeBranch(branch) + if pool.CollidesWithSlotName(name) { + return preconditionf( + "branch %q would take the tree name %s, which is reserved for pool slots — "+ + "pick another name", branch, name) + } + dest := filepath.Join(w.treesDir(), name) if _, err := os.Stat(dest); err == nil { return preconditionf( "%s already exists (branch names flatten '/' to '-') — pick another name", dest) @@ -73,17 +104,17 @@ func runNew(cmd *cobra.Command, branch, baseFlag string) error { if err := g.WorktreeAdd(ctx, dest, branch, base); err != nil { return err } - chatter := cmd.ErrOrStderr() fmt.Fprintf(chatter, "created %s (branch %s off %s)\n", dest, branch, base) if err := copyFiles(ctx, w.repo.Root, dest, w.cfg.Copy, chatter); err != nil { return fmt.Errorf("%w — the tree remains at %s", err, dest) } - if setup := w.cfg.Hooks.Setup; setup != "" { - fmt.Fprintf(chatter, "running setup hook: %s\n", setup) - if err := runHook(ctx, dest, setup, chatter); err != nil { - return fmt.Errorf("setup hook failed: %w — the tree remains at %s", err, dest) - } + st, err := w.stateDir() + if err != nil { + return err + } + if err := finishFresh(ctx, w.cfg, st, dest, name, chatter); err != nil { + return fmt.Errorf("%w — the tree remains at %s", err, dest) } // The tree path is the machine-facing product (D13); diff --git a/internal/cli/path.go b/internal/cli/path.go index 88e0a63..1ee6a3d 100644 --- a/internal/cli/path.go +++ b/internal/cli/path.go @@ -19,7 +19,7 @@ func newPathCmd() *cobra.Command { func runPath(cmd *cobra.Command, name string) error { ctx := cmd.Context() - trees, err := repoTrees(ctx) + _, trees, err := repoTrees(ctx) if err != nil { return err } diff --git a/internal/cli/pool.go b/internal/cli/pool.go new file mode 100644 index 0000000..e7b8f1e --- /dev/null +++ b/internal/cli/pool.go @@ -0,0 +1,260 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "path/filepath" + "strconv" + "time" + + "github.com/spf13/cobra" + + "github.com/loganthomas/wt/internal/config" + "github.com/loganthomas/wt/internal/gitx" + "github.com/loganthomas/wt/internal/lease" + "github.com/loganthomas/wt/internal/pool" +) + +func newPoolCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "pool", + Short: "Inspect and size the slot pool", + } + cmd.AddCommand(newPoolLsCmd(), newPoolResizeCmd()) + return cmd +} + +func newPoolLsCmd() *cobra.Command { + return &cobra.Command{ + Use: "ls", + Short: "List slots: free, claimed, and by whom", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runPoolLs(cmd) + }, + } +} + +func runPoolLs(cmd *cobra.Command) error { + ctx := cmd.Context() + p, err := openPool(ctx) + if err != nil { + return err + } + trees, err := p.g.Worktrees(ctx) + if err != nil { + return err + } + rows := make([][]string, 0, p.cfg.Pool.Size) + for _, slot := range pool.Names(p.cfg.Pool.Size) { + _, registered := findTree(trees, filepath.Join(p.treesDir(), slot)) + held, err := lease.Get(p.state.LeasesDir(), slot) + rows = append(rows, slotRow(slot, registered, held, err)) + } + _, err = fmt.Fprint(cmd.OutOrStdout(), alignRows(rows)) + return err +} + +// slotRow renders one slot's occupancy: slot, state, branch, detail. +func slotRow(slot string, registered bool, held *lease.Info, err error) []string { + switch { + case err != nil: + return []string{ + slot, "claimed", "?", + fmt.Sprintf("lease record unreadable — `wt release %s` clears it", slot), + } + case held == nil && !registered: + return []string{slot, "unprovisioned", "-", "provisions on first claim"} + case held == nil: + return []string{slot, "free", "-", ""} + case held.Stale(): + return []string{ + slot, "stale", held.Branch, + fmt.Sprintf("dead pid %d — reclaimed on next claim", held.PID), + } + default: + return []string{ + slot, "claimed", held.Branch, + fmt.Sprintf("pid %d, claimed %s", held.PID, humanAge(held.ClaimedAt)), + } + } +} + +// humanAge says how long ago t was, coarsely: pool occupancy is +// read at a glance, not billed by the second. +func humanAge(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } +} + +func newPoolResizeCmd() *cobra.Command { + return &cobra.Command{ + Use: "resize ", + Short: "Grow or shrink the pool", + Long: "Grow provisions and warms the new slots (setup hook included).\n" + + "Shrink removes the top slots, refusing while any of them is claimed.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPoolResize(cmd, args[0]) + }, + } +} + +func runPoolResize(cmd *cobra.Command, arg string) error { + size, err := strconv.Atoi(arg) + if err != nil || size < 1 { + return usageError{fmt.Errorf("pool size must be a whole number of at least 1, got %q", arg)} + } + ctx := cmd.Context() + p, err := openPool(ctx) + if err != nil { + return err + } + chatter := cmd.ErrOrStderr() + current := p.cfg.Pool.Size + switch { + case size == current: + fmt.Fprintf(chatter, "the pool already has %d slots\n", size) + return nil + case size > current: + return p.grow(ctx, current, size, chatter) + default: + return p.shrink(ctx, current, size, chatter) + } +} + +// grow writes the new size first: a crash mid-provision leaves an +// oversized config with missing slots, which claims heal by +// provisioning on demand, never the reverse, where warm trees +// sit outside the configured pool. +func (p *poolRepo) grow(ctx context.Context, from, to int, chatter io.Writer) error { + if err := checkBase(ctx, p.g, p.cfg.Base); err != nil { + return err + } + if err := p.savePoolSize(to); err != nil { + return err + } + return resizeHeld(p.provisionPool(ctx, from, to, chatter)) +} + +// resizeHeld maps a lease refusal during pool provisioning to +// exit 3 with the honest way forward: a readable holder means a +// claim raced and a rerun will succeed once it settles; an +// unreadable record never resolves itself and only `wt release` +// clears it. Shared by resize and init, the two bulk provisioners. +func resizeHeld(err error) error { + var held *lease.HeldError + if !errors.As(err, &held) { + return err + } + if held.Info == nil { + return preconditionf("%v — `wt release %s` clears it", held, held.Slot) + } + return preconditionf("%v — a concurrent claim holds it; rerun once it settles", held) +} + +// shrink removes the top slots down to size. Claimed victims +// refuse the whole shrink up front; each survivor is then removed +// under its own lease so no claim can race in. The config shrinks +// last: a crash leaves extra slots configured and intact, never +// warm trees orphaned outside the pool. +func (p *poolRepo) shrink(ctx context.Context, from, to int, chatter io.Writer) error { + leases := p.state.LeasesDir() + for i := to + 1; i <= from; i++ { + slot := pool.SlotName(i) + held, err := lease.Get(leases, slot) + if err != nil { + return preconditionf( + "%s's lease record is unreadable — `wt release %s` clears it", slot, slot) + } + if held != nil && !held.Stale() { + // Internal leases name no tree, so the wt done advice + // would be nonsense for them. + if lease.IsInternal(held.Branch) { + return preconditionf( + "%s is held by another wt operation %s — let it finish; "+ + "if it crashed, `wt release %s` clears it", + slot, held.Branch, slot) + } + return preconditionf("%s is claimed for %s — `wt done %s` first", + slot, held.Branch, held.Branch) + } + } + for i := from; i > to; i-- { + slot := pool.SlotName(i) + mine, err := lease.Acquire(leases, slot, lease.Removing) + if err != nil { + return resizeHeld(err) + } + // Listed under the lease: a claim that came and went since + // the precheck may have provisioned the slot, and a stale + // listing would skip the removal while the config shrank. + trees, err := p.g.Worktrees(ctx) + if err != nil { + _ = lease.Release(leases, slot, mine) + return err + } + if err := p.removeSlot(ctx, trees, slot); err != nil { + _ = lease.Release(leases, slot, mine) + return err + } + if err := lease.Release(leases, slot, mine); err != nil { + return err + } + fmt.Fprintf(chatter, "removed %s\n", slot) + } + return p.savePoolSize(to) +} + +// removeSlot deletes one slot's tree and state, with the same +// guards as any other destructive path (R2, D14). +func (p *poolRepo) removeSlot(ctx context.Context, trees []gitx.Worktree, slot string) error { + dest := filepath.Join(p.treesDir(), slot) + t, registered := findTree(trees, dest) + if !registered { + return p.state.RemoveTree(slot) + } + if err := checkRemovable(t); err != nil { + return err + } + if _, err := p.requireSlot(t.Path, "remove"); err != nil { + return err + } + if _, err := finishGuards(ctx, p.repo.Root, t, p.cfg.Copy); err != nil { + return err + } + if err := p.g.WorktreeRemoveForce(ctx, dest); err != nil { + return err + } + return p.state.RemoveTree(slot) +} + +// savePoolSize rewrites only the repo file's pool size, keeping +// the in-memory view honest for the rest of the command. +func (p *poolRepo) savePoolSize(size int) error { + path := p.repo.ConfigPath() + cfg, err := config.LoadRepo(path) + if err != nil { + return err + } + if cfg.Pool == nil { + cfg.Pool = &config.Pool{} + } + cfg.Pool.Size = size + if err := config.Save(path, cfg); err != nil { + return err + } + p.cfg.Pool.Size = size + return nil +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 1f141da..1de73db 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -87,6 +87,9 @@ func newRootCmd(info BuildInfo) *cobra.Command { newLsCmd(), newGoCmd(), newDoneCmd(), + newClaimCmd(), + newReleaseCmd(), + newPoolCmd(), newPathCmd(), newConfigCmd(), newShellInitCmd(), diff --git a/internal/cli/script_test.go b/internal/cli/script_test.go index 3d32ca9..c733d5e 100644 --- a/internal/cli/script_test.go +++ b/internal/cli/script_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "os/exec" + "path/filepath" "strconv" "strings" "testing" @@ -31,6 +32,9 @@ func TestScript(t *testing.T) { // repo discovery inside the work dir even when the system // temp dir sits under some git checkout. env.Setenv("GIT_CEILING_DIRECTORIES", env.WorkDir) + // Leases and refresh hashes must land in the script's + // world, not the developer's real state dir. + env.Setenv("XDG_STATE_HOME", filepath.Join(env.WorkDir, ".state")) for _, kv := range gittest.BaseEnv() { name, value, _ := strings.Cut(kv, "=") env.Setenv(name, value) diff --git a/internal/cli/slots.go b/internal/cli/slots.go new file mode 100644 index 0000000..6c2cc8e --- /dev/null +++ b/internal/cli/slots.go @@ -0,0 +1,560 @@ +// Pool-mode orchestration: claiming, provisioning, resetting, and +// releasing slots (PLAN.md Phase 4). The lease is always taken +// before any git runs and dropped on any failure; the pattern +// guard fences every destructive step (D14); and the refresh gate +// is what turns a claim into seconds instead of minutes (D5). +package cli + +import ( + "cmp" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/loganthomas/wt/internal/gitx" + "github.com/loganthomas/wt/internal/guard" + "github.com/loganthomas/wt/internal/lease" + "github.com/loganthomas/wt/internal/pool" + "github.com/loganthomas/wt/internal/state" +) + +// poolRepo is an open repo whose config enables pool mode, +// plus the seams every slot operation needs. +type poolRepo struct { + *wtRepo + g *gitx.Git // rooted at the main checkout + state state.Dir +} + +// openPool is the pool commands' shared preamble: resolve the +// repo, then require pool mode. +func openPool(ctx context.Context) (*poolRepo, error) { + w, err := openRepo(ctx) + if err != nil { + return nil, err + } + return poolOf(w) +} + +// poolOf upgrades an open repo to pool operations, or explains +// why it cannot: the repo is fine, the mode is off (exit 3). +func poolOf(w *wtRepo) (*poolRepo, error) { + if w.cfg.Pool == nil { + return nil, preconditionf( + "pool mode is not enabled here — rerun `wt init`, or add a [pool] table to wt.toml") + } + st, err := w.stateDir() + if err != nil { + return nil, err + } + return &poolRepo{wtRepo: w, g: gitx.New(w.repo.Root), state: st}, nil +} + +// claimSlot runs a whole claim: lease a slot, provision or reset +// it, attach the branch, port the copy files, refresh behind the +// lockfile gate. It returns the slot's path, the machine product +// of every claim (D13). A failure drops the lease again, so a +// failed claim does not shrink the usable pool, with one honest +// exception: when even re-parking the slot fails, the lease is +// kept on the session so the slot's condition stays visible. A +// slot the guards refuse (say, stranded orphan commits) is +// skipped with a notice rather than blocking every claim while +// healthy slots sit free (R3). +func (p *poolRepo) claimSlot( + ctx context.Context, branch, base string, chatter io.Writer, +) (string, error) { + skip := make(map[string]bool) + for { + slot, mine, reclaimed, err := p.acquire(branch, skip) + if err != nil { + return "", err + } + if reclaimed != nil { + fmt.Fprintf(chatter, "reclaimed %s from a dead session (pid %d, was %s)\n", + slot, reclaimed.PID, reclaimed.Branch) + } + dest, err := p.prepareSlot(ctx, slot, branch, base, chatter) + if err == nil { + // The handoff: the slot was held by wt itself while it + // provisioned; now the session doing the work takes over + // (see the lease package comment). + if _, err := lease.Repin(p.state.LeasesDir(), slot, branch, mine); err != nil { + var heldErr *lease.HeldError + if errors.As(err, &heldErr) { + return "", preconditionf( + "%v — the slot changed hands mid-claim; rerun the claim", heldErr) + } + return "", err + } + fmt.Fprintf(chatter, "claimed %s for %s\n", slot, branch) + return dest, nil + } + if rerr := p.reparkSlot(ctx, slot, branch, base); rerr != nil { + // The lease so far names wt itself and dies with it; the + // repin hands it to the session so the slot's condition + // stays visible past this process's exit. Best effort: + // should it fail too, the lease simply goes stale and the + // next claim reclaims the slot, which the advice below + // still covers. + _, _ = lease.Repin(p.state.LeasesDir(), slot, branch, mine) + return "", fmt.Errorf( + "%w — re-parking also failed (%v); `wt release %s` clears the slot", + err, rerr, slot) + } + _ = lease.Release(p.state.LeasesDir(), slot, mine) + if exitCodeFor(err) != exitPrecondition { + return "", err + } + fmt.Fprintf(chatter, "skipping %s: %v\n", slot, err) + skip[slot] = true + } +} + +// reparkSlot returns a slot to its parked state after a claim +// failed partway, but only when the failure struck after this +// claim's own branch attach: dropping the lease with the branch +// still checked out would bounce retries off "already checked +// out" while concurrent claims silently reset the tree. Slots the +// guards refused are left exactly as found: a forced detach +// there would destroy the very state the guard protected. When +// the re-park itself fails, the caller repins the lease to the +// session so the slot's condition stays visibly claimed instead +// of expiring with wt and being silently reset by the next claim. +func (p *poolRepo) reparkSlot(ctx context.Context, slot, branch, base string) error { + dest := filepath.Join(p.treesDir(), slot) + trees, err := p.g.Worktrees(ctx) + if err != nil { + return err + } + t, registered := findTree(trees, dest) + if !registered || t.Branch != branch { + return nil + } + return gitx.New(dest).CheckoutDetach(ctx, base) +} + +// prepareSlot readies one leased slot for branch: provision or +// reset, branch attach, copies, gated refresh. +func (p *poolRepo) prepareSlot( + ctx context.Context, slot, branch, base string, chatter io.Writer, +) (string, error) { + dest := filepath.Join(p.treesDir(), slot) + trees, err := p.g.Worktrees(ctx) + if err != nil { + return "", err + } + t, registered := findTree(trees, dest) + fresh := false + switch { + case registered && t.Prunable: + return "", preconditionf( + "%s is registered but gone from disk — `git worktree prune`, then claim again", dest) + case registered && t.Detached && !p.state.Provisioned(slot): + // A provision died between worktree-add and its marker: + // the slot looks real but its setup hook never finished, + // and a plain reset would skip setup forever. Redo it from + // scratch: nothing but base content and half-built + // artifacts can be in there, with the orphan guard as the + // backstop for anything committed by hand. Detached only: + // provisioning never attaches a branch, so a branch on an + // unmarked slot proves the recorded state was lost, not + // that the tree is disposable; it takes the reset path + // below, which keeps branch commits reachable and says + // what it discards instead of force-removing silently. + if err := guard.CheckOrphans(ctx, t.Path); err != nil { + return "", err + } + fmt.Fprintf(chatter, "reprovisioning %s (an earlier provision did not complete)\n", slot) + if err := p.g.WorktreeRemoveForce(ctx, dest); err != nil { + return "", err + } + if err := p.provisionSlot(ctx, slot, base, chatter); err != nil { + return "", err + } + fresh = true + case registered: + if err := p.resetSlot(ctx, t, base, chatter); err != nil { + return "", err + } + default: + // An unregistered slot may have just been removed by a + // shrink working from a newer config than this claim + // loaded; re-read the size before materializing a tree the + // pool no longer owns; it would be invisible to pool ls. + if err := p.checkStillInPool(slot); err != nil { + return "", err + } + if err := p.provisionSlot(ctx, slot, base, chatter); err != nil { + return "", err + } + fresh = true + } + + sg := gitx.New(dest) + branchIsNew := !p.g.HasBranch(ctx, branch) + if branchIsNew { + // The slot is parked at base, so creating here branches + // off exactly what wt new promises. + err = sg.SwitchCreate(ctx, branch) + } else { + err = sg.Switch(ctx, branch) + } + if err != nil { + return "", err + } + + // A slot provisioned just above is already warm, and a branch + // created here carries base's content unchanged, so the tree is + // exactly what warmSlot copied and warmed: repeating that work + // would re-plant every copy file and, with no refresh gate + // configured, run hooks.refresh a second time. Checking out an + // existing branch does change the content, so that case warms + // again for the branch it landed on. + if fresh && branchIsNew { + return dest, nil + } + if err := copyFiles(ctx, p.repo.Root, dest, p.cfg.Copy, chatter); err != nil { + return "", err + } + if err := refreshTree(ctx, p.cfg, p.state, dest, slot, chatter); err != nil { + return "", err + } + return dest, nil +} + +// acquire leases the first available slot outside skip: free ones +// first, then provably dead ones, so a crashed session's leftovers +// survive as long as the pool has other room. It returns the lease +// record it wrote, and a stolen lease's old record so the caller +// can say what was reclaimed. +func (p *poolRepo) acquire( + branch string, skip map[string]bool, +) (string, *lease.Info, *lease.Info, error) { + leases := p.state.LeasesDir() + names := pool.Names(p.cfg.Pool.Size) + for _, slot := range names { + if skip[slot] { + continue + } + if held, err := lease.Get(leases, slot); err != nil || held != nil { + continue + } + mine, err := lease.Acquire(leases, slot, branch) + if err == nil { + return slot, mine, nil, nil + } + if !isHeld(err) { + // Only a lost race reads as "keep scanning"; an I/O + // failure would repeat on every slot and must not be + // dressed up as a full pool. + return "", nil, nil, err + } + } + for _, slot := range names { + if skip[slot] { + continue + } + old, err := lease.Get(leases, slot) + if err == nil && (old == nil || !old.Stale()) { + continue + } + // A stale lease, or one Get cannot read: hand the slot to + // Acquire, which under its lock steals the provably dead, + // reclaims the recordless (a claimer that died between + // mkdir and record write), and refuses everything else. + // Filtering unreadable leases out here left that reclaim + // unreachable, wedging the slot until a manual release. + mine, aerr := lease.Acquire(leases, slot, branch) + if aerr == nil { + return slot, mine, old, nil + } + if !isHeld(aerr) { + return "", nil, nil, aerr + } + } + size := p.cfg.Pool.Size + if len(skip) > 0 { + return "", nil, nil, preconditionf( + "no usable slot in the pool of %d (%d blocked, reasons above) — "+ + "resolve a blocked slot, or `wt pool resize %d`", size, len(skip), size+1) + } + return "", nil, nil, preconditionf( + "no free slot in the pool of %d — `wt pool ls` shows the holders; "+ + "`wt done` a finished one, or `wt pool resize %d`", size, size+1) +} + +// resetSlot parks a slot back at base: forced detach, then clean +// without -x, so gitignored caches stay warm (D14). The pattern +// guard runs first (reset is the destructive primitive, and only +// true slot paths may ever reach it), and the orphan guard keeps +// a detached slot's commits recoverable (R2). Everything else +// uncommitted is discarded with notice: it belongs to no live +// session, or its session already passed the release guards. +// Entries named in tolerate are wt's own pristine plantings, already +// vouched for by the caller's guards, so they are swept without being +// announced as lost work; a caller that cannot vouch passes none and +// every leftover is reported. +func (p *poolRepo) resetSlot( + ctx context.Context, t gitx.Worktree, base string, chatter io.Writer, tolerate ...string, +) error { + slot, err := p.requireSlot(t.Path, "reset") + if err != nil { + return err + } + if t.Detached { + if err := guard.CheckOrphans(ctx, t.Path); err != nil { + return err + } + } + sg := gitx.New(t.Path) + entries, err := sg.Status(ctx) + if err != nil { + return err + } + discarded := 0 + for _, e := range entries { + planted := e.Code == "??" && slices.Contains(tolerate, e.Path) + if !planted { + discarded++ + } + // git status collapses an untracked nested repository to + // "dir/" even under -uall; its commits live only in that + // nested .git, invisible to the orphan guard, and the + // forced clean would destroy them. Refuse like any other + // guard: the claim skips the slot, and a human decides. + if !strings.HasSuffix(e.Path, "/") { + continue + } + if _, err := os.Stat(filepath.Join(t.Path, e.Path, ".git")); err == nil { + return preconditionf( + "%s holds a nested git repository %s — a reset would destroy its "+ + "history; move it out or delete it by hand", slot, e.Path) + } + } + if discarded > 0 { + fmt.Fprintf(chatter, "%s: discarding %d leftover uncommitted change(s)\n", + slot, discarded) + } + if err := sg.CheckoutDetach(ctx, base); err != nil { + return err + } + return sg.CleanUntracked(ctx) +} + +// releaseSlot finishes work in a slot: guards, park at base, +// optional branch delete, lease drop. The tree itself always +// stays: a warm slot is the entire point of the pool. +func (p *poolRepo) releaseSlot( + ctx context.Context, t gitx.Worktree, slot string, deleteBranch bool, chatter io.Writer, +) error { + // Before the repin: these would otherwise fail deep in the reset + // with a raw git error, after the lease has already been + // rewritten onto this session. A lock is the user's "leave this + // alone", and it binds a slot exactly as it binds a personal + // tree: releasing resets the tree, which is destructive enough + // to honour it. + if err := checkRemovable(t); err != nil { + return err + } + if err := checkBase(ctx, p.g, p.cfg.Base); err != nil { + return err + } + // A parked slot with a branch still attached is releasable + // (that is what parking fixes), so the claim requirement holds + // only for detached trees. + pinned, err := p.pinForRelease(slot, cmp.Or(t.Branch, lease.Releasing), t.Detached) + if err != nil { + return err + } + leases := p.state.LeasesDir() + + pristine, err := finishGuards(ctx, p.repo.Root, t, p.cfg.Copy) + if err != nil { + return err + } + if deleteBranch && t.Branch != "" { + if err := guard.CheckUnpushed(ctx, t.Path, p.cfg.Base); err != nil { + return err + } + } + + if err := p.resetSlot(ctx, t, p.cfg.Base, chatter, pristine...); err != nil { + return err + } + if err := lease.Release(leases, slot, pinned); err != nil { + return err + } + fmt.Fprintf(chatter, "released %s\n", slot) + return finishBranch(ctx, p.g, t.Branch, deleteBranch, chatter) +} + +// isHeld reports whether err is a lease refusal (the expected, +// scannable kind of Acquire failure) as opposed to a hard error. +func isHeld(err error) bool { + var held *lease.HeldError + return errors.As(err, &held) +} + +// requireSlot is the D14 pattern guard at the door of every +// destructive slot operation: only a true slot path may pass, +// and the refusal names the operation it stopped. +func (p *poolRepo) requireSlot(path, verb string) (string, error) { + slot, ok := pool.SlotPath(p.treesDir(), path) + if !ok { + return "", fmt.Errorf( + "refusing to %s %s: not a pool slot under %s", verb, path, p.treesDir()) + } + return slot, nil +} + +// checkStillInPool re-reads the configured size and refuses a +// slot the pool no longer covers. Config trouble only costs the +// recheck: the claim then trusts the size it loaded at startup. +func (p *poolRepo) checkStillInPool(slot string) error { + idx, ok := pool.SlotIndex(slot) + if !ok { + return fmt.Errorf("%s is not a slot name", slot) + } + merged, err := loadMerged(p.repo) + if err != nil { + return nil + } + if merged.Pool == nil || idx > merged.Pool.Size { + return preconditionf( + "the pool shrank below %s while this claim ran — rerun the claim", slot) + } + return nil +} + +// pinForRelease transfers slot's lease to this session, the +// entry half of every release: past a successful pin no +// concurrent claim can steal the slot, and the lease dropped at +// the end is provably the one handled here. A failure after the +// pin simply leaves the slot claimed by this session: truthful, +// retryable, and self-expiring if the session dies. A free slot +// is refused when claimRequired; an unreadable record is taken +// over regardless, because claim never steals what it cannot +// prove dead and release is the documented way out. +func (p *poolRepo) pinForRelease( + slot, branch string, claimRequired bool, +) (*lease.Info, error) { + leases := p.state.LeasesDir() + // Get yields a nil record on every error path, so an unreadable + // lease reaches Repin as "expect nothing" without further ado. + held, err := lease.Get(leases, slot) + if err == nil && held == nil && claimRequired { + return nil, preconditionf("%s is not claimed — nothing to release", slot) + } + pinned, err := lease.Repin(leases, slot, branch, held) + if err != nil { + var heldErr *lease.HeldError + if errors.As(err, &heldErr) { + return nil, preconditionf( + "%v — the slot changed hands; let that claim finish", heldErr) + } + return nil, err + } + return pinned, nil +} + +// releaseVacantSlot clears a lease on a slot with no worktree +// behind it, via the same pin-then-drop protocol as a full +// release, minus the tree work there is no tree to do. The slot's +// recorded state goes too: with the tree gone it describes +// nothing, and a later provision must not inherit it. +func (p *poolRepo) releaseVacantSlot(slot string, chatter io.Writer) error { + pinned, err := p.pinForRelease(slot, lease.Releasing, true) + if err != nil { + return err + } + // State goes while the pin still holds: dropped after the + // lease, it could race a fresh claim and delete the marker + // that claim just wrote. + if err := p.state.RemoveTree(slot); err != nil { + return err + } + if err := lease.Release(p.state.LeasesDir(), slot, pinned); err != nil { + return err + } + fmt.Fprintf(chatter, "released %s\n", slot) + return nil +} + +// provisionSlot creates and warms one slot: a detached worktree +// at base, ported copies, the setup hook, and the refresh hash +// recorded so the first claim doesn't redo what setup just did. +// A failed provision rolls the worktree back: a half-provisioned +// slot that skipped its setup hook would poison every later +// claim, and a clean retry is cheap by comparison. +func (p *poolRepo) provisionSlot( + ctx context.Context, slot, base string, chatter io.Writer, +) error { + dest := filepath.Join(p.treesDir(), slot) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + // A leftover directory from a previous incarnation of the repo + // (a re-clone next to a surviving trees dir) would make git's + // worktree add fail with a raw "already exists"; name the + // remedy instead, and keep the refusal at exit 3 so a claim + // skips past it to healthy slots. + if _, err := os.Stat(dest); err == nil { + return preconditionf( + "%s already exists but is not a registered worktree — "+ + "remove the leftover directory, then rerun", dest) + } + // Cleared before the worktree exists: a stale provisioned + // marker still standing inside the add-to-warm crash window + // would make a crashed provision read as complete. finishFresh + // clears the rest of the stale state for every fresh tree. + if err := p.state.RemoveTree(slot); err != nil { + return err + } + if err := p.g.WorktreeAddDetach(ctx, dest, base); err != nil { + return err + } + if err := p.warmSlot(ctx, dest, slot, chatter); err != nil { + _ = p.g.WorktreeRemoveForce(ctx, dest) + _ = p.state.RemoveTree(slot) + return fmt.Errorf("provisioning %s failed: %w", slot, err) + } + fmt.Fprintf(chatter, "provisioned %s (detached at %s)\n", slot, base) + return nil +} + +func (p *poolRepo) warmSlot(ctx context.Context, dest, slot string, chatter io.Writer) error { + if err := copyFiles(ctx, p.repo.Root, dest, p.cfg.Copy, chatter); err != nil { + return err + } + if err := finishFresh(ctx, p.cfg, p.state, dest, slot, chatter); err != nil { + return err + } + return p.state.MarkProvisioned(slot) +} + +// provisionPool warms slots from+1 through to, holding each +// slot's lease while it builds so a concurrent claim can never +// grab a half-built slot. Used by wt init and wt pool resize. +func (p *poolRepo) provisionPool(ctx context.Context, from, to int, chatter io.Writer) error { + leases := p.state.LeasesDir() + for i := from + 1; i <= to; i++ { + slot := pool.SlotName(i) + mine, err := lease.Acquire(leases, slot, lease.Provisioning) + if err != nil { + return err + } + err = p.provisionSlot(ctx, slot, p.cfg.Base, chatter) + if rerr := lease.Release(leases, slot, mine); err == nil { + err = rerr + } + if err != nil { + return err + } + } + return nil +} diff --git a/internal/cli/testdata/script/init-detect.txtar b/internal/cli/testdata/script/init-detect.txtar new file mode 100644 index 0000000..fa0dd92 --- /dev/null +++ b/internal/cli/testdata/script/init-detect.txtar @@ -0,0 +1,69 @@ +# wt init proposes hook and copy defaults from what the repo root +# already says: lockfiles pick the refresh hook and its gate, and +# well-known untracked files join the copy list. Flags still win. + +[!exec:git] skip 'git not on PATH' + +exec git init -q -b main app +cd app +exec git commit -q --allow-empty -m 'initial' +exec sh -c 'echo lock > pnpm-lock.yaml && git add pnpm-lock.yaml && git commit -q -m lock' +exec sh -c 'echo secret > .env' + +exec wt init --yes +stderr 'detected pnpm-lock.yaml' +stderr 'detected untracked \.env' +grep 'refresh = .pnpm install.' .git/wt.toml +grep 'refresh_if_changed' .git/wt.toml +grep 'copy = ' .git/wt.toml + +# a tracked .env is not proposed: it travels with checkouts +cd $WORK +exec git init -q -b main app2 +cd app2 +exec sh -c 'echo public > .env && git add .env && git commit -q -m env' +exec wt init --yes +! stderr 'untracked \.env' +! grep 'copy = ' .git/wt.toml + +# flags beat detection, and a flagged hook never inherits the +# detected gate: it would silently skip on unchanged lockfiles +cd $WORK +exec git init -q -b main app3 +cd app3 +exec git commit -q --allow-empty -m 'initial' +exec sh -c 'echo lock > package-lock.json && git add package-lock.json && git commit -q -m lock' +exec wt init --yes --refresh 'make deps' +! stderr 'npm ci' +grep 'refresh = .make deps.' .git/wt.toml +! grep 'refresh_if_changed' .git/wt.toml +! grep 'npm ci' .git/wt.toml + +# an explicitly empty flag declines the proposal outright +cd $WORK +exec git init -q -b main app4 +cd app4 +exec git commit -q --allow-empty -m 'initial' +exec sh -c 'echo lock > package-lock.json && git add package-lock.json && git commit -q -m lock' +exec wt init --yes --refresh '' +! grep 'refresh' .git/wt.toml + +# declining the hook takes its gate with it: a gate with no hook +# would describe a run that cannot happen +cd $WORK +exec git init -q -b main app6 +cd app6 +exec git commit -q --allow-empty -m 'initial' +exec wt init --yes --refresh-if-changed deps.lock +! grep 'refresh_if_changed' .git/wt.toml + +# an untracked lockfile proposes nothing: it would never reach a +# fresh tree, and a gate on it would hash as absent forever +cd $WORK +exec git init -q -b main app5 +cd app5 +exec git commit -q --allow-empty -m 'initial' +exec sh -c 'echo lock > Cargo.lock' +exec wt init --yes +! stderr 'cargo fetch' +! grep 'refresh' .git/wt.toml diff --git a/internal/cli/testdata/script/new.txtar b/internal/cli/testdata/script/new.txtar index e46778b..6941792 100644 --- a/internal/cli/testdata/script/new.txtar +++ b/internal/cli/testdata/script/new.txtar @@ -40,6 +40,16 @@ stderr 'feature-login' exitcode 2 wt new 'bad..name' stderr 'not a valid branch name' +# slot-shaped names belong to the pool even in default mode: such +# a tree would become resettable the day a [pool] table is added +exitcode 3 wt new slot-1 +stderr 'reserved for pool slots' +! stdout . + +# and case-folded, because the filesystem folds it too +exitcode 3 wt new Slot-2 +stderr 'reserved for pool slots' + # an unresolvable base is caught before git creates anything exitcode 3 wt new doomed --base no-such-ref stderr 'no-such-ref' diff --git a/internal/cli/testdata/script/pool-recovery.txtar b/internal/cli/testdata/script/pool-recovery.txtar new file mode 100644 index 0000000..c5a5d74 --- /dev/null +++ b/internal/cli/testdata/script/pool-recovery.txtar @@ -0,0 +1,81 @@ +# Broken pool states must always have a way out (D15, R3): +# wedged lease records clear via release, half-provisioned slots +# reprovision themselves, and a guard-blocked slot never takes +# the whole pool down. + +[!exec:git] skip 'git not on PATH' + +exec git init -q -b main acme +cd acme +exec git commit -q --allow-empty -m 'initial' +exec wt init --yes --pool-size 1 +stderr 'provisioned slot-1' + +# a claim/done cycle guarantees the state tree for this repo exists +exec wt new feat-a +exec wt done feat-a + +# corrupt the lease record by hand, as a crash or fs fault might +# (the glob covers only the existing slug segment; the lease dir +# itself is being invented here) +exec sh -c 'd="$(echo "$XDG_STATE_HOME"/wt/repos/*)" && mkdir -p "$d/leases/slot-1" && printf "not = [toml" > "$d/leases/slot-1/lease.toml"' + +# the pool is wedged: claim must not steal what it cannot prove dead +exitcode 3 wt claim feat-b +stderr 'no free slot' +exec wt pool ls +stdout 'slot-1\s+claimed\s+\?\s+lease record unreadable' + +# the escape hatch: release frees the slot anyway +exec wt release slot-1 +stderr 'released slot-1' +exec wt claim feat-b +stdout '[/\\]slot-1$' + +# a provision killed between worktree-add and warm leaves a +# registered but never-set-up slot; the next claim must redo the +# provision, not silently skip the setup hook forever +exec wt release feat-b +cp $WORK/hooked.toml .git/wt.toml +exec sh -c 'd="$(echo "$XDG_STATE_HOME"/wt/repos/*)" && rm -rf "$d/trees/slot-1" && rm -rf "$d/leases/slot-1"' +exec wt claim feat-c +stderr 'reprovisioning slot-1' +stderr 'running setup hook' +exists ../acme.trees/slot-1/setup-ran + +# the hook artifact is not gitignored in this fixture, so the +# dirty guard still speaks for it on the way out +rm ../acme.trees/slot-1/setup-ran +exec wt done feat-c + +# a slot the guards refuse must not take the whole pool down: +# the claim notes it, skips it, and uses the next slot +exec wt pool resize 2 +stderr 'provisioned slot-2' +exec wt claim feat-d +stdout '[/\\]slot-1$' + +# strand an orphan commit in slot-1 and drop its lease by hand, +# as a crashed session plus a reboot (PID reuse) would +exec git -C ../acme.trees/slot-1 checkout -q --detach +exec git -C ../acme.trees/slot-1 commit -q --allow-empty -m 'stranded' +exec sh -c 'd="$(echo "$XDG_STATE_HOME"/wt/repos/*)" && rm -rf "$d/leases/slot-1"' + +exec wt claim feat-e +stdout '[/\\]slot-2$' +stderr 'skipping slot-1' +stderr 'reachable only from its detached HEAD' + +# when nothing else is left, the claim surfaces the blockers +exitcode 3 wt claim feat-f +stderr 'blocked' + +-- hooked.toml -- +base = 'main' +trees_dir = '../acme.trees' + +[hooks] +setup = 'touch setup-ran' + +[pool] +size = 1 diff --git a/internal/cli/testdata/script/pool-refresh.txtar b/internal/cli/testdata/script/pool-refresh.txtar new file mode 100644 index 0000000..3b0f1b9 --- /dev/null +++ b/internal/cli/testdata/script/pool-refresh.txtar @@ -0,0 +1,92 @@ +# The refresh gate (PLAN.md D5): hooks.refresh runs on claim only +# when the refresh_if_changed files differ from the recorded hash: +# the lockfile short-circuit that makes claims take seconds. +# Config is written by hand here, so the first claim also proves +# that a missing slot is provisioned on demand. + +[!exec:git] skip 'git not on PATH' + +exec git init -q -b main acme +cd acme +cp $WORK/dep.lock dep.lock +cp $WORK/gitignore .gitignore +exec git add dep.lock .gitignore +exec git commit -q -m 'lockfile' +cp $WORK/wt.toml .git/wt.toml + +# first claim provisions the slot: setup runs, hash is recorded +exec wt claim feat-1 +stdout '[/\\]slot-1$' +stderr 'provisioned slot-1' +stderr 'running setup hook' +exists ../acme.trees/slot-1/setup-ran + +# unchanged lockfile: the next claim skips the refresh hook +exec wt release feat-1 +exec wt claim feat-2 +! stderr 'refresh hook' + +# lockfile changed on base: the next claim refreshes, once +exec wt release feat-2 +cp $WORK/dep2.lock dep.lock +exec git add dep.lock +exec git commit -q -m 'bump lockfile' +exec wt claim feat-3 +stderr 'running refresh hook' +exec wt release feat-3 +exec wt claim feat-4 +! stderr 'refresh hook' +exec wt release feat-4 + +# a refresh-only pool (no setup hook): provisioning runs the +# gated refresh itself, so the first claim never starts cold +cd .. +exec git init -q -b main lite +cd lite +cp $WORK/dep.lock dep.lock +cp $WORK/gitignore .gitignore +exec git add dep.lock .gitignore +exec git commit -q -m 'lockfile' +cp $WORK/lite.toml .git/wt.toml +exec wt claim feat-1 +stderr 'provisioned slot-1' +stderr 'running refresh hook' +exists ../lite.trees/slot-1/refresh-ran + +# and the hash was recorded: the next claim skips it +exec wt release feat-1 +exec wt claim feat-2 +! stderr 'refresh hook' +exec wt release feat-2 + +-- lite.toml -- +base = 'main' +trees_dir = '../lite.trees' + +[hooks] +refresh = 'touch refresh-ran' +refresh_if_changed = ['dep.lock'] + +[pool] +size = 1 + +-- wt.toml -- +base = 'main' +trees_dir = '../acme.trees' + +[hooks] +setup = 'touch setup-ran' +refresh = 'touch refresh-ran' +refresh_if_changed = ['dep.lock'] + +[pool] +size = 1 + +-- gitignore -- +*-ran + +-- dep.lock -- +v1 + +-- dep2.lock -- +v2 diff --git a/internal/cli/testdata/script/pool-resize.txtar b/internal/cli/testdata/script/pool-resize.txtar new file mode 100644 index 0000000..395deec --- /dev/null +++ b/internal/cli/testdata/script/pool-resize.txtar @@ -0,0 +1,57 @@ +# wt pool resize: grow provisions and warms new slots, +# shrink refuses claimed slots and removes only free ones, +# and the config always reflects the surviving pool. + +[!exec:git] skip 'git not on PATH' + +exec git init -q -b main acme +cd acme +exec git commit -q --allow-empty -m 'initial' +exec wt init --yes --pool-size 1 +stderr 'provisioned slot-1' + +exec wt pool resize 3 +stderr 'provisioned slot-2' +stderr 'provisioned slot-3' +grep 'size = 3' .git/wt.toml +exec wt pool ls +stdout 'slot-2\s+free' +stdout 'slot-3\s+free' + +# a claimed slot blocks any shrink that would remove it +exec wt claim feat-a +stdout '[/\\]slot-1$' +exec wt new feat-b +stdout '[/\\]slot-2$' +exitcode 3 wt pool resize 1 +stderr 'slot-2 is claimed' +grep 'size = 3' .git/wt.toml + +# free slots shrink away: trees, state, and config all follow +exec wt done feat-b +exec wt pool resize 1 +stderr 'removed slot-2' +stderr 'removed slot-3' +! exists ../acme.trees/slot-2 +! exists ../acme.trees/slot-3 +grep 'size = 1' .git/wt.toml +exec wt pool ls +stdout 'slot-1\s+claimed\s+feat-a' +! stdout 'slot-2' + +# the surviving claim is untouched +exec git -C ../acme.trees/slot-1 branch --show-current +stdout '^feat-a$' + +# garbage sizes are usage errors +exitcode 2 wt pool resize 0 +exitcode 2 wt pool resize banana + +# resizing outside pool mode is a precondition failure +cd .. +exec git init -q -b main plain +cd plain +exec git commit -q --allow-empty -m 'initial' +exec wt init --yes +exitcode 3 wt pool resize 2 +stderr 'pool mode is not enabled' diff --git a/internal/cli/testdata/script/pool-wedged.txtar b/internal/cli/testdata/script/pool-wedged.txtar new file mode 100644 index 0000000..a52d544 --- /dev/null +++ b/internal/cli/testdata/script/pool-wedged.txtar @@ -0,0 +1,119 @@ +# Crash artifacts a claim can prove dead must self-heal (D15, R3): +# a recordless lease directory is a claimer that died between mkdir +# and record write, and the next claim reclaims it. What no claim +# may touch (a wedged lease on a slot with no worktree behind it) +# must still clear via wt release, exactly as pool ls advertises. + +[!exec:git] skip 'git not on PATH' + +exec git init -q -b main acme +cd acme +exec git commit -q --allow-empty -m 'initial' +exec wt init --yes --pool-size 1 +stderr 'provisioned slot-1' + +# plant the crash artifact: a lease dir with no record +exec sh -c 'd="$(echo "$XDG_STATE_HOME"/wt/repos/*)" && mkdir -p "$d/leases/slot-1"' + +# the claim reclaims it instead of reporting the pool full +exec wt claim feat-a +stdout '[/\\]slot-1$' +exec wt release feat-a + +# wedge a slot that has no tree at all: the worktree vanishes out +# of band and the lease record is torn +exec git worktree remove --force ../acme.trees/slot-1 +exec sh -c 'd="$(echo "$XDG_STATE_HOME"/wt/repos/*)" && mkdir -p "$d/leases/slot-1" && printf "not = [toml" > "$d/leases/slot-1/lease.toml"' +exec wt pool ls +stdout 'slot-1\s+claimed\s+\?\s+lease record unreadable' + +# no claim can prove that record dead, so the pool is full +exitcode 3 wt claim feat-b +stderr 'no free slot' + +# the advertised escape hatch works without a tree to resolve +exec wt release slot-1 +stderr 'released slot-1' + +# a free vacant slot has nothing to release +exitcode 3 wt release slot-1 +stderr 'not claimed' + +# and the slot provisions again on the next claim +exec wt claim feat-b +stderr 'provisioned slot-1' +stdout '[/\\]slot-1$' +exec wt release feat-b + +# a slot lost out of band must not inherit its predecessor's +# refresh hash: the gate would skip the warm-up on a cold tree +cp $WORK/refresh.toml .git/wt.toml +exec wt claim feat-c +stderr 'running refresh hook' +exec wt release feat-c +exec git worktree remove --force ../acme.trees/slot-1 +exec wt claim feat-d +stderr 'provisioned slot-1' +stderr 'running refresh hook' + +# losing the state dir wipes leases and markers both; the claimed, +# branch-attached slot must take the reset path (which says what +# it discards and keeps the branch reachable), never the silent +# force-remove reserved for dead detached provisions +exec sh -c 'echo wip > ../acme.trees/slot-1/scratch.txt' +exec sh -c 'rm -rf "$(echo "$XDG_STATE_HOME"/wt/repos/*)"' +exec wt claim feat-e +stdout '[/\\]slot-1$' +stderr 'discarding' +! stderr 'reprovisioning' +exec git branch --list feat-d +stdout 'feat-d' +exec wt release feat-e + +# a claim that fails after its branch attach must re-park the +# slot: a branch left checked out behind a dropped lease would +# bounce retries off "already checked out" +cp $WORK/failing.toml .git/wt.toml +exec wt claim feat-f +exec wt release feat-f +exec sh -c 'echo x > fail-now' +exitcode 1 wt claim feat-g +stderr 'refresh hook failed' +exec wt pool ls +stdout 'slot-1\s+free' +exec git -C ../acme.trees/slot-1 status +stdout 'HEAD detached' + +# a dead session's nested git repository must not be silently +# destroyed by a reclaim: its commits live only in the nested +# .git, so the reset refuses and the claim skips the slot +rm fail-now +exec wt claim feat-h +exec git -C ../acme.trees/slot-1 init -q scratchdep +exec git -C ../acme.trees/slot-1/scratchdep commit -q --allow-empty -m 'nested' +exec sh -c 'd="$(echo "$XDG_STATE_HOME"/wt/repos/*)" && rm -rf "$d/leases/slot-1"' +exitcode 3 wt claim feat-i +stderr 'nested git repository' + +-- failing.toml -- +base = 'main' +trees_dir = '../acme.trees' +copy = ['fail-now'] + +[hooks] +refresh = 'test ! -f fail-now' +refresh_if_changed = ['fail-now'] + +[pool] +size = 1 + +-- refresh.toml -- +base = 'main' +trees_dir = '../acme.trees' + +[hooks] +refresh = 'echo warmed' +refresh_if_changed = ['dep-lock'] + +[pool] +size = 1 diff --git a/internal/cli/testdata/script/pool.txtar b/internal/cli/testdata/script/pool.txtar new file mode 100644 index 0000000..809dd2c --- /dev/null +++ b/internal/cli/testdata/script/pool.txtar @@ -0,0 +1,106 @@ +# Pool mode: the claim, work, release loop (PLAN.md Phase 4). +# wt new and wt done dispatch on pool presence; claim/release are +# the plumbing spellings; slots reset instead of being removed. + +[!exec:git] skip 'git not on PATH' + +exec git init -q -b main acme +cd acme +exec git commit -q --allow-empty -m 'initial' +exec wt init --yes --pool-size 2 +stderr 'pool mode, 2 slots' +stderr 'provisioned slot-1' +stderr 'provisioned slot-2' + +# slots are parked: detached at base, no branch +exec git -C ../acme.trees/slot-1 branch --show-current +! stdout . + +# wt new claims a slot instead of creating a personal tree +exec wt new feat-a +stdout '[/\\]acme\.trees[/\\]slot-1$' +stderr 'claimed slot-1' +exec git -C ../acme.trees/slot-1 branch --show-current +stdout '^feat-a$' + +exec wt new feat-b +stdout '[/\\]slot-2$' + +# pool exhausted: precondition, not a crash +exitcode 3 wt new feat-c +stderr 'no free slot' +! stdout . + +# slot-centric view +exec wt pool ls +stdout 'slot-1\s+claimed\s+feat-a' +stdout 'slot-2\s+claimed\s+feat-b' + +# navigation targets the branch; the slot name still resolves +exec wt go feat-a +stdout '[/\\]slot-1$' +exec wt path slot-2 +stdout '[/\\]slot-2$' + +# done on a slot releases it: reset + branch delete, tree remains +exec wt done feat-a +stderr 'released slot-1' +stderr 'deleted branch feat-a' +exec git -C ../acme.trees/slot-1 branch --show-current +! stdout . +exec git -C ../acme.trees/slot-1 status --porcelain +! stdout . +exec wt pool ls +stdout 'slot-1\s+free' + +# a released slot is claimable again; claim prints the slot path +exec wt claim feat-c +stdout '[/\\]slot-1$' + +# release is the branch-keeping plumbing counterpart of done +exec wt release feat-c +stderr 'released slot-1' +exec git branch --list feat-c +stdout 'feat-c' + +# claiming an existing branch checks it out instead of failing +exec wt claim feat-c +stdout '[/\\]slot-1$' +exec git -C ../acme.trees/slot-1 branch --show-current +stdout '^feat-c$' + +# wt new refuses the existing branch even in pool mode (R4) +exitcode 3 wt new feat-b +stderr 'already checked out in .*[/\\]slot-2' +exitcode 3 wt new feat-c +stderr 'already checked out' + +# a branch claimed in another slot cannot be claimed twice +exitcode 3 wt claim feat-b +stderr 'already checked out in .*[/\\]slot-2' + +# guards hold on release: uncommitted work blocks it, +# and the blocked slot stays claimed for its branch +cp $WORK/junk.txt ../acme.trees/slot-1/junk.txt +exitcode 3 wt done feat-c +stderr 'uncommitted changes' +exec wt pool ls +stdout 'slot-1\s+claimed\s+feat-c' +rm ../acme.trees/slot-1/junk.txt +exec wt done feat-c +stderr 'released slot-1' + +# releasing a parked slot is a no-op with a clear message +exitcode 3 wt release slot-1 +stderr 'not claimed' + +# a personal tree created outside the pool is still removed the +# default way; the release path only ever touches slots +exec git worktree add -q ../acme.trees/personal -b personal main +exec wt done personal +stderr 'removed .*[/\\]personal' +stderr 'deleted branch personal' +exists ../acme.trees/slot-1 + +-- junk.txt -- +junk diff --git a/internal/cli/testdata/script/refresh.txtar b/internal/cli/testdata/script/refresh.txtar new file mode 100644 index 0000000..028d0fc --- /dev/null +++ b/internal/cli/testdata/script/refresh.txtar @@ -0,0 +1,41 @@ +# The refresh gate on default-mode wt new (D5): setup, when +# configured, is presumed to leave the tree fully built (the +# refresh hash is recorded, never redundantly re-run right after), +# while a refresh-only config uses refresh as the warm-up itself. + +[!exec:git] skip 'git not on PATH' + +exec git init -q -b main acme +cd acme +cp $WORK/dep.lock dep.lock +exec git add dep.lock +exec git commit -q -m 'lockfile' + +cp $WORK/both.toml .git/wt.toml +exec wt new both +stderr 'running setup hook' +! stderr 'refresh hook' + +cp $WORK/only.toml .git/wt.toml +exec wt new only +stderr 'running refresh hook' + +-- dep.lock -- +v1 + +-- both.toml -- +base = 'main' +trees_dir = '../acme.trees' + +[hooks] +setup = 'true' +refresh = 'true' +refresh_if_changed = ['dep.lock'] + +-- only.toml -- +base = 'main' +trees_dir = '../acme.trees' + +[hooks] +refresh = 'true' +refresh_if_changed = ['dep.lock'] diff --git a/internal/cli/tree.go b/internal/cli/tree.go index 0fe4bcd..a777394 100644 --- a/internal/cli/tree.go +++ b/internal/cli/tree.go @@ -3,6 +3,7 @@ package cli import ( "context" "fmt" + "io" "github.com/loganthomas/wt/internal/gitx" "github.com/loganthomas/wt/internal/nav" @@ -18,10 +19,8 @@ func resolveTree(ctx context.Context, trees []gitx.Worktree, name string) (gitx. if err != nil { return gitx.Worktree{}, err } - for _, t := range trees { - if t.Path == top { - return t, nil - } + if t, ok := findTree(trees, top); ok { + return t, nil } return gitx.Worktree{}, fmt.Errorf("git does not list the current tree %s", top) } @@ -33,10 +32,8 @@ func resolveTree(ctx context.Context, trees []gitx.Worktree, name string) (gitx. cands[i] = nav.Candidate{Branch: t.Branch, Path: t.Path} } if winner := nav.ResolveExact(cands, name); winner != nil { - for _, t := range trees { - if t.Path == winner.Path { - return t, nil - } + if t, ok := findTree(trees, winner.Path); ok { + return t, nil } } return gitx.Worktree{}, errNoTreeMatches(name) @@ -48,6 +45,81 @@ func errNoTreeMatches(name string) error { return fmt.Errorf("no tree matches %q — `wt ls` shows what exists", name) } +// checkBase is the one spelling of the unresolvable-base error, +// shared by new, claim, and resize. +func checkBase(ctx context.Context, g *gitx.Git, base string) error { + if !g.HasCommit(ctx, base) { + return preconditionf( + "base %q does not resolve to a commit — fetch it, or set base in wt.toml", base) + } + return nil +} + +// checkRemovable refuses trees git cannot remove cleanly, before +// any guard or sweep runs: a locked tree would fail only after wt +// had already deleted the planted copy files, and a prunable +// tree's directory is gone, so the guards (which run inside it) +// cannot vouch for anything: hand that cleanup to git. Shared by +// wt done and slot removal, the two tree-deleting paths. +func checkRemovable(t gitx.Worktree) error { + if t.Locked { + reason := "" + if t.LockedReason != "" { + reason = fmt.Sprintf(" (%s)", t.LockedReason) + } + return preconditionf("%s is locked%s — `git worktree unlock %s` first", + t.Path, reason, t.Path) + } + if t.Prunable { + return preconditionf( + "%s is gone from disk — `git worktree prune` clears the stale registration", + t.Path) + } + return nil +} + +// findTree looks a path up in git's worktree list. +func findTree(trees []gitx.Worktree, path string) (gitx.Worktree, bool) { + for _, t := range trees { + if t.Path == path { + return t, true + } + } + return gitx.Worktree{}, false +} + +// finishBranch disposes of a finished tree's branch and says what +// it did: the one spelling shared by wt done and slot release, so +// the two cannot drift on the wording or on what a detached tree +// (no branch to speak of) reports. +func finishBranch( + ctx context.Context, g *gitx.Git, branch string, deleteBranch bool, chatter io.Writer, +) error { + if branch == "" { + return nil + } + if !deleteBranch { + fmt.Fprintf(chatter, "kept branch %s\n", branch) + return nil + } + if err := g.DeleteBranch(ctx, branch); err != nil { + return err + } + fmt.Fprintf(chatter, "deleted branch %s\n", branch) + return nil +} + +// treeHoldingBranch finds the worktree with branch checked out, +// for the R4 errors that must point straight at it. +func treeHoldingBranch(trees []gitx.Worktree, branch string) (gitx.Worktree, bool) { + for _, t := range trees { + if t.Branch == branch { + return t, true + } + } + return gitx.Worktree{}, false +} + // nameArg unpacks the optional [name] positional argument. func nameArg(args []string) string { if len(args) == 0 { diff --git a/internal/cli/warm.go b/internal/cli/warm.go new file mode 100644 index 0000000..32cfcab --- /dev/null +++ b/internal/cli/warm.go @@ -0,0 +1,85 @@ +// The warm-up mechanism shared by both modes: a fresh tree or slot +// is built exactly once, and a claim re-runs hooks.refresh only +// when the gate says the tree went cold (D5). Kept out of slots.go +// so default-mode `wt new` does not have to reach into pool-mode +// orchestration for the definition of "fresh". +package cli + +import ( + "context" + "fmt" + "io" + + "github.com/loganthomas/wt/internal/config" + "github.com/loganthomas/wt/internal/pool" + "github.com/loganthomas/wt/internal/state" +) + +// finishFresh completes a just-created tree or slot, one warm-up +// only: the setup hook when configured (presumed to leave the +// tree fully built, so the refresh hash it implicitly satisfied +// is recorded rather than immediately re-run), otherwise the +// refresh hook through its usual gate. Shared by wt new and slot +// provisioning so the two can never disagree on what "fresh" +// means. +func finishFresh( + ctx context.Context, cfg config.Config, st state.Dir, dest, name string, chatter io.Writer, +) error { + // Fresh also means no inherited state: a namesake tree removed + // out of band can leave a refresh hash behind that would + // satisfy the gate and skip the very warm-up this function + // exists to run. + if err := st.RemoveTree(name); err != nil { + return err + } + setup := cfg.Hooks.Setup + if setup == "" { + return refreshTree(ctx, cfg, st, dest, name, chatter) + } + fmt.Fprintf(chatter, "running setup hook: %s\n", setup) + if err := runHook(ctx, dest, setup, chatter); err != nil { + return fmt.Errorf("setup hook failed: %w", err) + } + if files := cfg.Hooks.RefreshIfChanged; len(files) > 0 { + hash, err := pool.Hash(dest, files) + if err != nil { + return err + } + return st.WriteRefreshHash(name, hash) + } + return nil +} + +// refreshTree runs hooks.refresh behind the lockfile gate (D5): +// with refresh_if_changed configured, only a hash change triggers +// it, and only success re-records the hash; without the gate it +// runs on every claim and new. Shared by pool claims and +// default-mode trees so the two can never drift. +func refreshTree( + ctx context.Context, cfg config.Config, st state.Dir, dest, name string, chatter io.Writer, +) error { + refresh := cfg.Hooks.Refresh + if refresh == "" { + return nil + } + files := cfg.Hooks.RefreshIfChanged + var current string + if len(files) > 0 { + var err error + current, err = pool.Hash(dest, files) + if err != nil { + return err + } + if current == st.RefreshHash(name) { + return nil + } + } + fmt.Fprintf(chatter, "running refresh hook: %s\n", refresh) + if err := runHook(ctx, dest, refresh, chatter); err != nil { + return fmt.Errorf("refresh hook failed: %w", err) + } + if len(files) > 0 { + return st.WriteRefreshHash(name, current) + } + return nil +} diff --git a/internal/cli/wtrepo.go b/internal/cli/wtrepo.go index 67b8234..39b1c6d 100644 --- a/internal/cli/wtrepo.go +++ b/internal/cli/wtrepo.go @@ -2,10 +2,12 @@ package cli import ( "context" + "path/filepath" "github.com/loganthomas/wt/internal/config" "github.com/loganthomas/wt/internal/gitx" "github.com/loganthomas/wt/internal/repo" + "github.com/loganthomas/wt/internal/state" ) // wtRepo bundles what nearly every command needs: @@ -45,16 +47,37 @@ func (w *wtRepo) treesDir() string { return w.repo.TreesDir(w.cfg.TreesDir) } +// treeStateName maps a worktree path to its recorded-state key. +// Only trees directly inside the trees dir have state, keyed by +// basename, so a hand-made worktree elsewhere must never touch a +// managed namesake's records. +func (w *wtRepo) treeStateName(path string) (string, bool) { + if filepath.Dir(path) != w.treesDir() { + return "", false + } + return filepath.Base(path), true +} + +// stateDir is this repo's state root: leases, refresh hashes. +func (w *wtRepo) stateDir() (state.Dir, error) { + sd, err := w.repo.StateDir() + if err != nil { + return "", err + } + return state.Dir(sd), nil +} + // repoTrees resolves the repository and lists its worktrees, // deliberately without loading config: read-only commands like // ls and path must keep working even when wt.toml is broken. // Resolving the repo first keeps the contract's exit 4 for // non-repos, and anchors the listing at the same root every // other command uses. -func repoTrees(ctx context.Context) ([]gitx.Worktree, error) { +func repoTrees(ctx context.Context) (*repo.Repo, []gitx.Worktree, error) { r, err := repo.Find(ctx, "") if err != nil { - return nil, err + return nil, nil, err } - return gitx.New(r.Root).Worktrees(ctx) + trees, err := gitx.New(r.Root).Worktrees(ctx) + return r, trees, err } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 55a5f0c..d26ba61 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -297,3 +297,33 @@ func TestGlobalPath(t *testing.T) { } }) } + +// LoadRepo feeds read-modify-write flows (wt pool resize): +// it must return exactly what the repo file says (no defaults, +// no global merge), so saving it back never smuggles in values +// the user never wrote. +func TestLoadRepo(t *testing.T) { + dir := t.TempDir() + path := writeOptional(t, dir, "wt.toml", "trees_dir = '../x.trees'\n\n[pool]\nsize = 4\n") + + cfg, err := LoadRepo(path) + if err != nil { + t.Fatal(err) + } + if cfg.Base != "" { + t.Errorf("Base = %q, want empty (no defaults merged)", cfg.Base) + } + if cfg.TreesDir != "../x.trees" { + t.Errorf("TreesDir = %q, want ../x.trees", cfg.TreesDir) + } + if cfg.Pool == nil || cfg.Pool.Size != 4 { + t.Errorf("Pool = %+v, want size 4", cfg.Pool) + } +} + +func TestLoadRepoMissingFile(t *testing.T) { + _, err := LoadRepo(filepath.Join(t.TempDir(), "wt.toml")) + if err == nil { + t.Fatal("LoadRepo on a missing file: want an error, got nil") + } +} diff --git a/internal/config/load.go b/internal/config/load.go index 56fa68b..50eff56 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -11,6 +11,23 @@ import ( "github.com/pelletier/go-toml/v2" ) +// LoadRepo reads the repo config file alone (no defaults, no +// global merge) for read-modify-write flows like `wt pool +// resize`, which must save back the same values the user wrote +// plus the one change (Save re-marshals, so comments and +// formatting are not preserved). A missing file is an error here: +// modifying a config that does not exist means wt init hasn't run. +func LoadRepo(path string) (Config, error) { + if _, err := os.Stat(path); err != nil { + return Config{}, err + } + var cfg Config + if err := decodeFile(path, &cfg); err != nil { + return Config{}, err + } + return cfg, nil +} + // decodeFile strictly decodes one TOML file into v. // A missing file is not an error: both config layers are optional. // Decode failures carry file:line:column so the user can jump diff --git a/internal/gitx/gitx.go b/internal/gitx/gitx.go index 6d952f7..c166ae3 100644 --- a/internal/gitx/gitx.go +++ b/internal/gitx/gitx.go @@ -66,6 +66,63 @@ func (g *Git) WorktreeRemove(ctx context.Context, path string) error { return err } +// WorktreeRemoveForce removes the worktree at path even when it +// holds untracked or ignored files. Only for trees wt's own +// guards have already vouched for: pool slots keep gitignored +// warm caches that git would otherwise refuse to remove. +func (g *Git) WorktreeRemoveForce(ctx context.Context, path string) error { + _, err := g.run(ctx, "worktree", "remove", "--force", path) + return err +} + +// WorktreeAddDetach creates a worktree at path with a detached +// HEAD at ref, the parked state of a pool slot. +func (g *Git) WorktreeAddDetach(ctx context.Context, path, ref string) error { + _, err := g.run(ctx, "worktree", "add", "--quiet", "--detach", path, ref) + return err +} + +// CheckoutDetach forcibly detaches HEAD at ref, discarding local +// modifications to tracked files. This is the destructive half of +// a pool slot reset; callers run the pattern and orphan guards +// first (D14). +func (g *Git) CheckoutDetach(ctx context.Context, ref string) error { + _, err := g.run(ctx, "checkout", "--quiet", "--force", "--detach", ref) + return err +} + +// CleanUntracked removes untracked files and directories. +// Never -x: gitignored build artifacts are what keep pool slots +// warm (D14), so ignored files always survive a reset. +// Double -f because a single one skips untracked nested git repos +// while still exiting 0; a reset that reported success would +// leave them to fail the next holder's guards, with no wt command +// able to clear them. +func (g *Git) CleanUntracked(ctx context.Context) error { + _, err := g.run(ctx, "clean", "-q", "-ffd") + return err +} + +// Switch checks out an existing branch. +func (g *Git) Switch(ctx context.Context, branch string) error { + _, err := g.run(ctx, "switch", "--quiet", branch) + return err +} + +// SwitchCreate creates branch at the current HEAD and checks it +// out. +func (g *Git) SwitchCreate(ctx context.Context, branch string) error { + _, err := g.run(ctx, "switch", "--quiet", "-c", branch) + return err +} + +// HasBranch reports whether a local branch of that name exists. +// The refs/heads/ prefix lives here rather than at the call sites: +// ref-namespace spelling is this package's business. +func (g *Git) HasBranch(ctx context.Context, branch string) bool { + return g.HasCommit(ctx, "refs/heads/"+branch) +} + // DeleteBranch deletes a local branch even if unmerged; // callers run the unpushed-commit guard first. func (g *Git) DeleteBranch(ctx context.Context, branch string) error { diff --git a/internal/gitx/gitx_test.go b/internal/gitx/gitx_test.go index 5948168..e7fd599 100644 --- a/internal/gitx/gitx_test.go +++ b/internal/gitx/gitx_test.go @@ -81,3 +81,82 @@ func TestRunScrubsRepoLocalEnv(t *testing.T) { t.Errorf("TopLevel() = %q, want %q despite GIT_DIR pointing elsewhere", got, target) } } + +// TestSlotLifecycleOperations walks the git operations a pool +// slot goes through: provision detached, take a branch, reset +// back to detached, clean untracked, with ignored files (the +// warm caches pool mode exists for) surviving every reset. +func TestSlotLifecycleOperations(t *testing.T) { + gittest.Scrub(t) + dir := gittest.TempDir(t) + main := gittest.Repo(t, filepath.Join(dir, "acme")) + gittest.WriteFile(t, main, ".gitignore", "node_modules/\n") + gittest.Run(t, main, "add", ".gitignore") + gittest.Run(t, main, "commit", "-q", "-m", "ignore") + + slot := filepath.Join(dir, "acme.trees", "slot-1") + g := New(main) + if err := g.WorktreeAddDetach(t.Context(), slot, "main"); err != nil { + t.Fatal(err) + } + trees, err := g.Worktrees(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(trees) != 2 || !trees[1].Detached { + t.Fatalf("after add --detach: trees = %+v, want a detached second tree", trees) + } + + sg := New(slot) + if err := sg.SwitchCreate(t.Context(), "feat"); err != nil { + t.Fatal(err) + } + if out := gittest.Run(t, slot, "branch", "--show-current"); out != "feat" { + t.Errorf("after SwitchCreate: on %q, want feat", out) + } + + gittest.WriteFile(t, slot, "scratch.txt", "leftover\n") + gittest.WriteFile(t, slot, "node_modules/dep.js", "cached\n") + gittest.Repo(t, filepath.Join(slot, "vendored")) + if err := sg.CheckoutDetach(t.Context(), "main"); err != nil { + t.Fatal(err) + } + if err := sg.CleanUntracked(t.Context()); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(slot, "scratch.txt")); err == nil { + t.Error("CleanUntracked left the untracked file behind") + } + // A single -f skips nested git repos yet exits 0; the reset + // must actually discard them or the next holder inherits dirt + // no guard lets them release. + if _, err := os.Stat(filepath.Join(slot, "vendored")); err == nil { + t.Error("CleanUntracked left a nested git repo behind") + } + // Never -x: gitignored caches are what keep slots warm (D14). + if _, err := os.Stat(filepath.Join(slot, "node_modules", "dep.js")); err != nil { + t.Error("CleanUntracked removed a gitignored file — slots would always be cold") + } + if out := gittest.Run(t, slot, "branch", "--show-current"); out != "" { + t.Errorf("after CheckoutDetach: still on branch %q", out) + } + + if err := sg.Switch(t.Context(), "feat"); err != nil { + t.Fatal(err) + } + if out := gittest.Run(t, slot, "branch", "--show-current"); out != "feat" { + t.Errorf("after Switch: on %q, want feat", out) + } + + // Removing a worktree holding ignored files needs force; + // wt's own guards have already vouched for it by then. + if err := sg.CheckoutDetach(t.Context(), "main"); err != nil { + t.Fatal(err) + } + if err := g.WorktreeRemoveForce(t.Context(), slot); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(slot); err == nil { + t.Error("WorktreeRemoveForce left the slot directory behind") + } +} diff --git a/internal/gitx/worktree_test.go b/internal/gitx/worktree_test.go index 11d30c1..ac1a814 100644 --- a/internal/gitx/worktree_test.go +++ b/internal/gitx/worktree_test.go @@ -54,12 +54,12 @@ func TestParseWorktrees(t *testing.T) { }, { name: "detached head", - input: "worktree /repos/acme.trees/pool-1\x00" + + input: "worktree /repos/acme.trees/slot-1\x00" + "HEAD cccc567890abcdef1234567890abcdef12345678\x00" + "detached\x00" + "\x00", want: []Worktree{{ - Path: "/repos/acme.trees/pool-1", + Path: "/repos/acme.trees/slot-1", Head: "cccc567890abcdef1234567890abcdef12345678", Detached: true, }}, diff --git a/internal/lease/lease.go b/internal/lease/lease.go new file mode 100644 index 0000000..19f4004 --- /dev/null +++ b/internal/lease/lease.go @@ -0,0 +1,326 @@ +// Package lease implements crash-safe claims on pool slots +// (PLAN.md D15). A lease is an atomically created directory under +// the repo's state dir whose record names the claiming session: +// PID, process start time, hostname, branch, claim time. +// A lease goes stale only when its PID is dead or its start time +// no longer matches (PID reuse), never by wall clock alone, +// so long-running legitimate work is never reaped (R3). +// +// A claim is a two-phase handoff. Acquire records wt's own PID: +// while wt provisions the slot (setup hooks can run for minutes), +// the slot is protected by wt's liveness, and a kill leaves a +// lease that is provably dead rather than one pinned to a shell +// that outlives the work. On success the caller Repins the lease +// to the session (the shell, script, or agent doing the work) +// because wt itself exits within milliseconds of finishing. +package lease + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "time" + + "github.com/pelletier/go-toml/v2" +) + +// recordName is the record file inside a lease directory +// (PLAN.md, State layout: leases/slot-3/lease.toml). +const recordName = "lease.toml" + +// Internal branch labels: wt's own operations hold slots under +// these instead of a user branch. Defined beside the record they +// end up in, so a new label cannot dodge IsInternal. +const ( + Provisioning = "(provisioning)" + Removing = "(removing)" + Releasing = "(releasing)" +) + +// IsInternal reports whether branch is one of wt's own operation +// labels. Exact matches only: parentheses are legal in git branch +// names, so a user branch called "(wip)" must not read as wt's. +func IsInternal(branch string) bool { + return branch == Provisioning || branch == Removing || branch == Releasing +} + +// Info is the record inside a lease directory. +type Info struct { + PID int `toml:"pid"` + PIDStart string `toml:"pid_start"` + Hostname string `toml:"hostname"` + Branch string `toml:"branch"` + ClaimedAt time.Time `toml:"claimed_at"` +} + +// HeldError reports a slot whose lease is live, or unverifiable, +// which wt treats the same way: it only ever steals a lease it can +// prove dead. +type HeldError struct { + Slot string + Info *Info // nil when the record is missing or unreadable +} + +// WtExitCode makes a held lease a precondition failure (D13): the +// command was fine, the slot is busy. Declared here, as guard.Error +// does, so a call site that forgets to wrap one cannot leak it as a +// hard failure and the cli exit seam needs no special case. +func (e *HeldError) WtExitCode() int { return 3 } + +func (e *HeldError) Error() string { + if e.Info == nil { + return fmt.Sprintf("%s is claimed (lease record unreadable)", e.Slot) + } + return fmt.Sprintf("%s is claimed for %s (pid %d since %s)", + e.Slot, e.Info.Branch, e.Info.PID, e.Info.ClaimedAt.Format(time.RFC3339)) +} + +// Acquire claims slot under leasesDir for branch. +// The lease directory is the persistent claim (it survives every +// process involved, which is the point), while a short flock +// serializes the check-steal-create critical section, so racing +// claimers can neither double-create nor steal a lease that was +// re-acquired between their staleness check and their theft. +// The flock cannot wedge: the kernel drops it with its holder. +// A provably dead lease is stolen; a live or unverifiable one +// returns *HeldError. The record names wt's own PID (the claim +// phase of the handoff described in the package comment) and is +// returned so the caller can later prove which lease is its own. +func Acquire(leasesDir, slot, branch string) (*Info, error) { + unlock, err := lockLeases(leasesDir) + if err != nil { + return nil, err + } + defer unlock() + + dir := filepath.Join(leasesDir, slot) + err = os.Mkdir(dir, 0o755) + if errors.Is(err, fs.ErrExist) { + info, rerr := readRecord(dir) + switch { + case errors.Is(rerr, fs.ErrNotExist): + // No record can be mid-write while this flock is held, + // so a recordless lease is a claimer that died between + // mkdir and write: reclaim it. + case rerr != nil: + // A corrupt record proves nothing about its holder; + // never steal on a guess (wt release clears a truly + // wedged slot by hand). + return nil, &HeldError{Slot: slot} + case !info.Stale(): + return nil, &HeldError{Slot: slot, Info: info} + } + if err := os.RemoveAll(dir); err != nil { + return nil, err + } + err = os.Mkdir(dir, 0o755) + } + if err != nil { + return nil, err + } + mine, err := writeRecord(dir, branch, os.Getpid()) + if err != nil { + _ = os.RemoveAll(dir) + return nil, err + } + return mine, nil +} + +// Repin atomically transfers slot's lease to the calling session, +// provided it has not changed hands since expect was read +// (nil expect: the record was absent or unreadable at entry). +// Work that runs after a successful Repin cannot race a +// concurrent Acquire, because the slot is now held live by the +// repinning session itself; should that session die +// mid-operation, its pin goes stale like any other lease. +// A live lease other than the expected one returns *HeldError; +// a stale or unreadable one is taken over regardless of expect, +// since its holder is either provably dead or unprovable-and- +// being-cleared on explicit user request. The new record names +// the session (wt's original parent): this is the handoff half +// of both protocols: a finished claim pins its slot to the +// session doing the work, and a release pins the slot to the +// session clearing it. The record written is returned. +func Repin(leasesDir, slot, branch string, expect *Info) (*Info, error) { + unlock, err := lockLeases(leasesDir) + if err != nil { + return nil, err + } + defer unlock() + + dir := filepath.Join(leasesDir, slot) + current, rerr := readRecord(dir) + if rerr == nil && !current.same(expect) && !current.Stale() { + return nil, &HeldError{Slot: slot, Info: current} + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + return writeRecord(dir, branch, sessionPID) +} + +// Release frees slot, but only when the current lease is the +// expected one, provably dead, or absent; releasing a free slot +// is not an error, so cleanup paths can run it unconditionally. +// The guard matters when the caller's lease was taken over behind +// its back (an explicit `wt release` racing a claim): removing +// unconditionally would delete the new holder's live lease and +// hand the slot to a third claimer. A live lease other than +// expect, or an unreadable record (which proves nothing), returns +// *HeldError and leaves the lease in place. +func Release(leasesDir, slot string, expect *Info) error { + unlock, err := lockLeases(leasesDir) + if err != nil { + return err + } + defer unlock() + + dir := filepath.Join(leasesDir, slot) + current, rerr := readRecord(dir) + switch { + case errors.Is(rerr, fs.ErrNotExist): + // Free, or a recordless leftover: no writer can be + // mid-record while this flock is held, so removing is safe. + case rerr != nil: + return &HeldError{Slot: slot} + case !current.same(expect) && !current.Stale(): + return &HeldError{Slot: slot, Info: current} + } + return os.RemoveAll(dir) +} + +// Get reports the lease on slot: (nil, nil) when free, the record +// when held. A held slot whose record is missing or unreadable is +// an error carrying that fact; callers decide how loudly to say it. +// The record is read before the directory is checked: the reverse +// order misread a lease released between the two calls as a held +// slot with an unreadable record. +func Get(leasesDir, slot string) (*Info, error) { + dir := filepath.Join(leasesDir, slot) + info, err := readRecord(dir) + if !errors.Is(err, fs.ErrNotExist) { + return info, err + } + if _, serr := os.Stat(dir); errors.Is(serr, fs.ErrNotExist) { + return nil, nil + } + return nil, err +} + +// Stale reports whether the lease's holder is provably gone: +// its PID is dead, or the PID is alive but belongs to a different +// process than the one recorded (start times differ: PID reuse). +// Anything unverifiable (a foreign host, an unreadable start +// time) reads as live: wt never steals on a guess. +func (i *Info) Stale() bool { + if host, err := os.Hostname(); err != nil || i.Hostname != host { + return false + } + if i.PID <= 0 { + return false + } + if !alive(i.PID) { + return true + } + if i.PIDStart == "" { + return false + } + start, err := processStart(i.PID) + if err != nil { + return false + } + return start != i.PIDStart +} + +// same reports whether two records name the same claim. +// ClaimedAt participates so a slot released and re-claimed by the +// very same session still reads as having changed hands. +func (i *Info) same(o *Info) bool { + return i != nil && o != nil && + i.PID == o.PID && i.PIDStart == o.PIDStart && + i.Hostname == o.Hostname && i.ClaimedAt.Equal(o.ClaimedAt) +} + +// sessionPID is wt's parent as it was at startup: the shell, +// script, or agent session doing the work. Captured before any +// work runs: if that session later dies, Getppid would report the +// reaper (init, or a PID-1 shell in a container) and the lease +// would wrongly track a process that never claimed anything. +// Recording the original parent means an orphaned claim's lease +// reads stale the moment its session is gone, and a container +// session that legitimately IS PID 1 stays live by its own +// start time, instead of being mistaken for a reparented orphan. +var sessionPID = os.Getppid() + +func writeRecord(dir, branch string, pid int) (*Info, error) { + host, err := os.Hostname() + if err != nil { + return nil, err + } + info := &Info{ + PID: pid, + Hostname: host, + Branch: branch, + ClaimedAt: time.Now().UTC(), + } + // Best effort: an unreadable start time weakens the PID-reuse + // guard for this lease but must not block claiming. + info.PIDStart, _ = processStart(info.PID) + raw, err := toml.Marshal(info) + if err != nil { + return nil, err + } + // Temp file + rename: a crash mid-write leaves a recordless + // directory (the state Acquire already reclaims), never a + // torn or empty record, which nothing could ever prove dead. + tmp, err := os.CreateTemp(dir, recordName+".tmp-*") + if err != nil { + return nil, err + } + if _, err := tmp.Write(raw); err != nil { + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + return nil, err + } + // Synced before the rename: without it a power loss could + // publish an empty or torn record at the final name: one that + // parses as a holder no host can ever prove dead. + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + return nil, err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmp.Name()) + return nil, err + } + if err := os.Rename(tmp.Name(), filepath.Join(dir, recordName)); err != nil { + return nil, err + } + return info, nil +} + +// lockLeases enters the lease critical section: the leases +// directory is created as needed and the acquire flock taken. +// One spelling for all three protocol entry points, so the lock +// path and mode cannot drift between them. +func lockLeases(leasesDir string) (unlock func(), err error) { + if err := os.MkdirAll(leasesDir, 0o755); err != nil { + return nil, err + } + return lockExclusive(filepath.Join(leasesDir, ".acquire.lock")) +} + +func readRecord(dir string) (*Info, error) { + raw, err := os.ReadFile(filepath.Join(dir, recordName)) + if err != nil { + return nil, err + } + var info Info + if err := toml.Unmarshal(raw, &info); err != nil { + return nil, fmt.Errorf("%s: %w", filepath.Join(dir, recordName), err) + } + return &info, nil +} diff --git a/internal/lease/lease_test.go b/internal/lease/lease_test.go new file mode 100644 index 0000000..51cfa53 --- /dev/null +++ b/internal/lease/lease_test.go @@ -0,0 +1,519 @@ +package lease + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/pelletier/go-toml/v2" +) + +func TestAcquireFree(t *testing.T) { + dir := t.TempDir() + if _, err := Acquire(dir, "slot-1", "feature/login"); err != nil { + t.Fatal(err) + } + + // The record lands at the documented layout path + // (PLAN.md, State layout): //lease.toml. + if _, err := os.Stat(filepath.Join(dir, "slot-1", "lease.toml")); err != nil { + t.Errorf("lease record not at the documented path: %v", err) + } + + info, err := Get(dir, "slot-1") + if err != nil { + t.Fatal(err) + } + if info == nil { + t.Fatal("Get after Acquire = nil, want the lease record") + } + if info.Branch != "feature/login" { + t.Errorf("Branch = %q, want %q", info.Branch, "feature/login") + } + // The claim phase anchors on the claiming process itself; the + // lease tracks the session only after the success-side Repin. + if info.PID != os.Getpid() { + t.Errorf("PID = %d, want own pid %d", info.PID, os.Getpid()) + } + if info.ClaimedAt.IsZero() { + t.Error("ClaimedAt is zero") + } + // The record write is atomic (temp file + rename): a crash can + // leave a recordless directory, never a torn record, and no + // temp litter survives a successful write. + entries, err := os.ReadDir(filepath.Join(dir, "slot-1")) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != "lease.toml" { + t.Errorf("lease dir contents = %v, want exactly lease.toml", entries) + } +} + +func TestGetFreeSlot(t *testing.T) { + info, err := Get(t.TempDir(), "slot-1") + if err != nil { + t.Fatal(err) + } + if info != nil { + t.Errorf("Get on free slot = %+v, want nil", info) + } +} + +func TestAcquireHeld(t *testing.T) { + dir := t.TempDir() + if _, err := Acquire(dir, "slot-1", "first"); err != nil { + t.Fatal(err) + } + _, err := Acquire(dir, "slot-1", "second") + var held *HeldError + if !errors.As(err, &held) { + t.Fatalf("second Acquire error = %v, want *HeldError", err) + } + if held.Slot != "slot-1" || held.Info == nil || held.Info.Branch != "first" { + t.Errorf("HeldError = %+v, want slot slot-1 held for branch first", held) + } +} + +func TestReleaseIsIdempotent(t *testing.T) { + dir := t.TempDir() + mine, err := Acquire(dir, "slot-1", "x") + if err != nil { + t.Fatal(err) + } + if err := Release(dir, "slot-1", mine); err != nil { + t.Fatal(err) + } + if info, err := Get(dir, "slot-1"); err != nil || info != nil { + t.Errorf("Get after Release = %+v, %v; want free", info, err) + } + if err := Release(dir, "slot-1", mine); err != nil { + t.Errorf("second Release: %v", err) + } + if _, err := Acquire(dir, "slot-1", "y"); err != nil { + t.Errorf("Acquire after Release: %v", err) + } +} + +func TestReleaseRefusesAnotherLiveLease(t *testing.T) { + dir := t.TempDir() + // The caller's lease was taken over behind its back (an + // explicit release racing a claim): its cleanup must not + // delete the new holder's live lease. + stale := &Info{ + PID: deadPID(t), + PIDStart: "Mon Jan 2 15:04:05 2006", + Hostname: hostname(t), + Branch: "mine-once", + ClaimedAt: time.Now(), + } + plant(t, dir, "slot-1", live(t, "new-holder")) + err := Release(dir, "slot-1", stale) + var held *HeldError + if !errors.As(err, &held) { + t.Fatalf("Release over another live lease error = %v, want *HeldError", err) + } + if info, gerr := Get(dir, "slot-1"); gerr != nil || info == nil || info.Branch != "new-holder" { + t.Errorf("lease after refused Release = %+v, %v; want the new holder intact", info, gerr) + } +} + +func TestReleaseClearsAStaleLease(t *testing.T) { + dir := t.TempDir() + // A dead holder is provably done: cleanup may clear it even + // without proving ownership. + plant(t, dir, "slot-1", Info{ + PID: deadPID(t), + PIDStart: "Mon Jan 2 15:04:05 2006", + Hostname: hostname(t), + Branch: "crashed", + ClaimedAt: time.Now(), + }) + if err := Release(dir, "slot-1", nil); err != nil { + t.Fatalf("Release of a stale lease: %v", err) + } + if info, err := Get(dir, "slot-1"); err != nil || info != nil { + t.Errorf("Get after Release = %+v, %v; want free", info, err) + } +} + +// deadPID returns a PID guaranteed dead: a child spawned, +// finished, and reaped. +func deadPID(t *testing.T) int { + t.Helper() + cmd := exec.Command("true") + if err := cmd.Run(); err != nil { + t.Fatal(err) + } + return cmd.Process.Pid +} + +// plant writes a lease record as if some other process held slot. +func plant(t *testing.T, dir, slot string, info Info) { + t.Helper() + leaseDir := filepath.Join(dir, slot) + if err := os.MkdirAll(leaseDir, 0o755); err != nil { + t.Fatal(err) + } + raw, err := toml.Marshal(info) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(leaseDir, "lease.toml"), raw, 0o644); err != nil { + t.Fatal(err) + } +} + +func hostname(t *testing.T) string { + t.Helper() + h, err := os.Hostname() + if err != nil { + t.Fatal(err) + } + return h +} + +func TestAcquireStealsDeadPID(t *testing.T) { + dir := t.TempDir() + plant(t, dir, "slot-1", Info{ + PID: deadPID(t), + PIDStart: "Mon Jan 2 15:04:05 2006", + Hostname: hostname(t), + Branch: "crashed", + ClaimedAt: time.Now(), + }) + if _, err := Acquire(dir, "slot-1", "fresh"); err != nil { + t.Fatalf("Acquire over dead lease: %v", err) + } + info, err := Get(dir, "slot-1") + if err != nil { + t.Fatal(err) + } + if info == nil || info.Branch != "fresh" { + t.Errorf("Get after steal = %+v, want branch fresh", info) + } +} + +func TestAcquireStealsReusedPID(t *testing.T) { + dir := t.TempDir() + // A live PID whose recorded start time is someone else's: + // the PID was reused, the original holder is gone (D15). + plant(t, dir, "slot-1", Info{ + PID: os.Getpid(), + PIDStart: "Mon Jan 2 15:04:05 2006", + Hostname: hostname(t), + Branch: "reused", + ClaimedAt: time.Now(), + }) + if _, err := Acquire(dir, "slot-1", "fresh"); err != nil { + t.Fatalf("Acquire over reused-PID lease: %v", err) + } +} + +func TestLiveLeaseIsNeverStaleByAge(t *testing.T) { + dir := t.TempDir() + start, err := processStart(os.Getpid()) + if err != nil { + t.Fatal(err) + } + // Ancient by wall clock, but the process is alive: never stale (D15). + plant(t, dir, "slot-1", Info{ + PID: os.Getpid(), + PIDStart: start, + Hostname: hostname(t), + Branch: "long-running", + ClaimedAt: time.Now().Add(-90 * 24 * time.Hour), + }) + _, err = Acquire(dir, "slot-1", "impatient") + var held *HeldError + if !errors.As(err, &held) { + t.Fatalf("Acquire over live lease error = %v, want *HeldError", err) + } +} + +func TestForeignHostLeaseIsNeverStale(t *testing.T) { + dir := t.TempDir() + // Liveness cannot be verified across hosts; never steal. + plant(t, dir, "slot-1", Info{ + PID: deadPID(t), + PIDStart: "Mon Jan 2 15:04:05 2006", + Hostname: "some-other-host.invalid", + Branch: "remote", + ClaimedAt: time.Now(), + }) + _, err := Acquire(dir, "slot-1", "local") + var held *HeldError + if !errors.As(err, &held) { + t.Fatalf("Acquire over foreign-host lease error = %v, want *HeldError", err) + } +} + +func TestRecordlessLeaseIsReclaimed(t *testing.T) { + dir := t.TempDir() + // A lease directory without a record is a claimer that died + // between mkdir and record write: record writes happen under + // the acquire lock, so no live writer can be mid-flight once + // Acquire holds it. Reclaim rather than wedge the slot. + if err := os.MkdirAll(filepath.Join(dir, "slot-1"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := Acquire(dir, "slot-1", "x"); err != nil { + t.Fatalf("Acquire over a recordless lease: %v", err) + } +} + +func TestGetRecordlessLease(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "slot-1"), 0o755); err != nil { + t.Fatal(err) + } + // Get is read-only and lockless; it reports the unreadable + // record as an error and leaves reclaiming to Acquire. + if _, err := Get(dir, "slot-1"); err == nil { + t.Error("Get on a recordless lease dir: want an error, got nil") + } +} + +func TestCorruptRecordHeldConservatively(t *testing.T) { + dir := t.TempDir() + leaseDir := filepath.Join(dir, "slot-1") + if err := os.MkdirAll(leaseDir, 0o755); err != nil { + t.Fatal(err) + } + err := os.WriteFile(filepath.Join(leaseDir, "lease.toml"), []byte("not = [toml"), 0o644) + if err != nil { + t.Fatal(err) + } + _, aerr := Acquire(dir, "slot-1", "x") + var held *HeldError + if !errors.As(aerr, &held) { + t.Fatalf("Acquire error = %v, want *HeldError", aerr) + } +} + +// TestProcessStartIgnoresCallerTZ pins the false-stale trap: +// ps renders lstart in local time, so without a fixed TZ the same +// live process reads as a different one across sessions with +// different timezones, and its lease would be stolen. +func TestProcessStartIgnoresCallerTZ(t *testing.T) { + t.Setenv("TZ", "UTC") + utc, err := processStart(os.Getpid()) + if err != nil { + t.Fatal(err) + } + t.Setenv("TZ", "America/New_York") + ny, err := processStart(os.Getpid()) + if err != nil { + t.Fatal(err) + } + if utc != ny { + t.Errorf("processStart depends on caller TZ: %q (UTC) vs %q (New York)", utc, ny) + } +} + +func TestProcessStart(t *testing.T) { + first, err := processStart(os.Getpid()) + if err != nil { + t.Fatal(err) + } + if first == "" { + t.Fatal("processStart returned empty for a live process") + } + second, err := processStart(os.Getpid()) + if err != nil { + t.Fatal(err) + } + if first != second { + t.Errorf("processStart not stable: %q then %q", first, second) + } + if _, err := processStart(deadPID(t)); err == nil { + t.Error("processStart on a dead PID: want an error, got nil") + } +} + +// TestSoak interleaves claim/release cycles across goroutines with +// dead leases injected along the way (PLAN.md, pool soak): +// no slot is ever held twice, and no slot is lost. +func TestSoak(t *testing.T) { + dir := t.TempDir() + slots := []string{"slot-1", "slot-2", "slot-3"} + var holders [3]atomic.Int32 + + // Some slots start wedged by a crashed process. + plant(t, dir, "slot-2", Info{ + PID: deadPID(t), + PIDStart: "Mon Jan 2 15:04:05 2006", + Hostname: hostname(t), + Branch: "crashed", + ClaimedAt: time.Now(), + }) + + const workers, rounds = 8, 25 + var wg sync.WaitGroup + for w := range workers { + wg.Add(1) + go func() { + defer wg.Done() + for r := range rounds { + i := (w + r) % len(slots) + mine, err := Acquire(dir, slots[i], "work") + var held *HeldError + if errors.As(err, &held) { + continue + } + if err != nil { + t.Errorf("Acquire(%s): %v", slots[i], err) + continue + } + if n := holders[i].Add(1); n != 1 { + t.Errorf("slot %s held by %d claimers at once", slots[i], n) + } + holders[i].Add(-1) + if err := Release(dir, slots[i], mine); err != nil { + t.Errorf("Release(%s): %v", slots[i], err) + } + } + }() + } + wg.Wait() + + // No slot was lost: every one is acquirable at the end. + for _, s := range slots { + if _, err := Acquire(dir, s, "final"); err != nil { + t.Errorf("slot %s lost after soak: %v", s, err) + } + } +} + +// live returns a lease record of a provably live session: +// this test process itself. +func live(t *testing.T, branch string) Info { + t.Helper() + start, err := processStart(os.Getpid()) + if err != nil { + t.Fatal(err) + } + return Info{ + PID: os.Getpid(), + PIDStart: start, + Hostname: hostname(t), + Branch: branch, + ClaimedAt: time.Now(), + } +} + +func TestRepinTakesTheExpectedLease(t *testing.T) { + dir := t.TempDir() + if _, err := Acquire(dir, "slot-1", "feat"); err != nil { + t.Fatal(err) + } + expect, err := Get(dir, "slot-1") + if err != nil { + t.Fatal(err) + } + if _, err := Repin(dir, "slot-1", "feat", expect); err != nil { + t.Fatalf("Repin over the expected lease: %v", err) + } + got, err := Get(dir, "slot-1") + if err != nil { + t.Fatal(err) + } + if got == nil || got.PID != os.Getppid() || got.Branch != "feat" { + t.Errorf("Get after Repin = %+v, want this session's record for feat", got) + } +} + +func TestRepinRefusesAChangedLiveLease(t *testing.T) { + dir := t.TempDir() + // The caller read a stale lease, but a racing claim has since + // replaced it with a live one: repinning would steal that + // claim mid-flight, the exact race Repin exists to close. + expect := &Info{ + PID: deadPID(t), + PIDStart: "Mon Jan 2 15:04:05 2006", + Hostname: hostname(t), + Branch: "crashed", + ClaimedAt: time.Now(), + } + plant(t, dir, "slot-1", live(t, "racer")) + _, err := Repin(dir, "slot-1", "crashed", expect) + var held *HeldError + if !errors.As(err, &held) { + t.Fatalf("Repin over a changed live lease error = %v, want *HeldError", err) + } + if held.Info == nil || held.Info.Branch != "racer" { + t.Errorf("HeldError.Info = %+v, want the racer's record", held.Info) + } +} + +func TestRepinRefusesALiveLeaseWhenNoneWasExpected(t *testing.T) { + dir := t.TempDir() + plant(t, dir, "slot-1", live(t, "racer")) + _, err := Repin(dir, "slot-1", "x", nil) + var held *HeldError + if !errors.As(err, &held) { + t.Fatalf("Repin(nil expect) over a live lease error = %v, want *HeldError", err) + } +} + +func TestRepinClearsWedgedStates(t *testing.T) { + dir := t.TempDir() + + // Unreadable record: the escape-hatch case. + leaseDir := filepath.Join(dir, "slot-1") + if err := os.MkdirAll(leaseDir, 0o755); err != nil { + t.Fatal(err) + } + err := os.WriteFile(filepath.Join(leaseDir, "lease.toml"), []byte("not = [toml"), 0o644) + if err != nil { + t.Fatal(err) + } + if _, err := Repin(dir, "slot-1", "rescue", nil); err != nil { + t.Fatalf("Repin over an unreadable record: %v", err) + } + + // A stale lease other than the expected one: its holder is + // dead either way, so the repin proceeds. + plant(t, dir, "slot-2", Info{ + PID: deadPID(t), + PIDStart: "Mon Jan 2 15:04:05 2006", + Hostname: hostname(t), + Branch: "crashed-b", + ClaimedAt: time.Now(), + }) + other := &Info{ + PID: deadPID(t), + PIDStart: "Tue Jan 3 15:04:05 2006", + Hostname: hostname(t), + Branch: "crashed-a", + ClaimedAt: time.Now(), + } + if _, err := Repin(dir, "slot-2", "rescue", other); err != nil { + t.Fatalf("Repin over a different-but-stale lease: %v", err) + } + + // No lease at all: repin claims outright (drift healing). + if _, err := Repin(dir, "slot-3", "heal", nil); err != nil { + t.Fatalf("Repin on a free slot: %v", err) + } + if info, err := Get(dir, "slot-3"); err != nil || info == nil { + t.Errorf("Get after free-slot Repin = %+v, %v; want a record", info, err) + } +} + +func TestAcquireRefusesARepinnedSlot(t *testing.T) { + dir := t.TempDir() + if _, err := Repin(dir, "slot-1", "releasing", nil); err != nil { + t.Fatal(err) + } + _, err := Acquire(dir, "slot-1", "eager") + var held *HeldError + if !errors.As(err, &held) { + t.Fatalf("Acquire over a repinned slot error = %v, want *HeldError", err) + } +} diff --git a/internal/lease/lock.go b/internal/lease/lock.go new file mode 100644 index 0000000..f39a2b1 --- /dev/null +++ b/internal/lease/lock.go @@ -0,0 +1,27 @@ +//go:build unix + +package lease + +import ( + "os" + "syscall" +) + +// lockExclusive takes a blocking exclusive flock on path, +// creating the file as needed, and returns the release function. +// Blocking is right here: the guarded section is a few filesystem +// operations, so waiting beats surfacing spurious contention +// errors. The kernel releases the lock if its holder dies, +// so a crash can never wedge later claimers. +func lockExclusive(path string) (release func(), err error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDONLY, 0o644) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + _ = f.Close() + return nil, err + } + // Closing the descriptor releases the flock. + return func() { _ = f.Close() }, nil +} diff --git a/internal/lease/proc.go b/internal/lease/proc.go new file mode 100644 index 0000000..b9c57ce --- /dev/null +++ b/internal/lease/proc.go @@ -0,0 +1,48 @@ +//go:build unix + +package lease + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "syscall" +) + +// alive reports whether pid exists. Signal 0 delivers nothing and +// only checks existence; EPERM means the process exists but is +// someone else's, which still counts as alive. +func alive(pid int) bool { + err := syscall.Kill(pid, 0) + return err == nil || errors.Is(err, syscall.EPERM) +} + +// processStart returns pid's start time as ps prints it +// (e.g. "Mon Jul 20 09:15:02 2026"). The value is only ever +// compared for equality against a recorded copy, so its format +// does not matter, only that a reused PID prints a different one +// and the same process always prints the same one: TZ and locale +// are pinned, because ps renders lstart in local time and a claim +// written under one TZ must not read as a different process under +// another (that misread would steal a live lease). lstart is a +// BSD/GNU extension, not POSIX, but present on both v1 platforms +// (macOS, Linux CI); where ps lacks it the guard degrades to +// PID-liveness alone, which fails safe: never steals. +// Shelling out beats per-OS sysctl/procfs code for a call made a +// handful of times per command. +func processStart(pid int) (string, error) { + cmd := exec.Command("ps", "-o", "lstart=", "-p", strconv.Itoa(pid)) + cmd.Env = append(os.Environ(), "TZ=UTC", "LC_ALL=C") + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("ps -p %d: %w", pid, err) + } + start := strings.TrimSpace(string(out)) + if start == "" { + return "", fmt.Errorf("ps -p %d: no such process", pid) + } + return start, nil +} diff --git a/internal/nav/nav.go b/internal/nav/nav.go index c468381..99c2753 100644 --- a/internal/nav/nav.go +++ b/internal/nav/nav.go @@ -22,10 +22,16 @@ const maxContenders = 5 type Candidate struct { Branch string // checked-out branch; empty when detached Path string // absolute tree root + Slot string // pool slot name when the tree is a claimed slot } // Display names the candidate for human-facing lists. +// A claimed slot leads with the branch: the work is the identity, +// the slot is only its current address (PLAN.md Phase 4). func (c Candidate) Display() string { + if c.Branch != "" && c.Slot != "" { + return c.Branch + " → " + c.Slot + } if c.Branch != "" { return c.Branch } diff --git a/internal/nav/nav_test.go b/internal/nav/nav_test.go index 1786f92..718f425 100644 --- a/internal/nav/nav_test.go +++ b/internal/nav/nav_test.go @@ -12,7 +12,7 @@ func candidates() []Candidate { {Branch: "main", Path: "/repos/acme"}, {Branch: "feature/login", Path: "/repos/acme.trees/feature-login"}, {Branch: "feature/logout", Path: "/repos/acme.trees/feature-logout"}, - {Branch: "", Path: "/repos/acme.trees/pool-3"}, + {Branch: "", Path: "/repos/acme.trees/slot-3"}, } } @@ -31,9 +31,9 @@ func TestResolveExactSanitizedBranchWins(t *testing.T) { } func TestResolveExactBasenameWinsForDetachedTrees(t *testing.T) { - winner, _ := Resolve(candidates(), "pool-3") - if winner == nil || winner.Path != "/repos/acme.trees/pool-3" { - t.Fatalf("Resolve() winner = %v; want the pool-3 tree", winner) + winner, _ := Resolve(candidates(), "slot-3") + if winner == nil || winner.Path != "/repos/acme.trees/slot-3" { + t.Fatalf("Resolve() winner = %v; want the slot-3 tree", winner) } } @@ -66,9 +66,9 @@ func TestResolveIsCaseInsensitive(t *testing.T) { } func TestResolveMatchesDirectoryBasename(t *testing.T) { - winner, _ := Resolve(candidates(), "pool") - if winner == nil || winner.Path != "/repos/acme.trees/pool-3" { - t.Fatalf("Resolve() winner = %v; want the pool-3 tree", winner) + winner, _ := Resolve(candidates(), "slot") + if winner == nil || winner.Path != "/repos/acme.trees/slot-3" { + t.Fatalf("Resolve() winner = %v; want the slot-3 tree", winner) } } @@ -179,8 +179,33 @@ func TestDisplayPrefersBranch(t *testing.T) { } func TestDisplayFallsBackToBasename(t *testing.T) { - c := Candidate{Path: "/trees/pool-3"} - if got := c.Display(); got != "pool-3 (detached)" { - t.Errorf("Display() = %q, want 'pool-3 (detached)'", got) + c := Candidate{Path: "/trees/slot-3"} + if got := c.Display(); got != "slot-3 (detached)" { + t.Errorf("Display() = %q, want 'slot-3 (detached)'", got) + } +} + +// A claimed slot is presented by what the user is working on, +// never by its slot number (PLAN.md Phase 4): the branch is the +// identity, the slot is the address. +func TestDisplayShowsSlotAddress(t *testing.T) { + c := Candidate{Branch: "PROJ-123", Path: "/r/acme.trees/slot-3", Slot: "slot-3"} + if got := c.Display(); got != "PROJ-123 → slot-3" { + t.Errorf("Display() = %q, want %q", got, "PROJ-123 → slot-3") + } +} + +func TestResolveTargetsSlotBranch(t *testing.T) { + cands := []Candidate{ + {Branch: "main", Path: "/r/acme"}, + {Branch: "PROJ-123", Path: "/r/acme.trees/slot-3", Slot: "slot-3"}, + } + winner, _ := Resolve(cands, "PROJ") + if winner == nil || winner.Slot != "slot-3" { + t.Fatalf("Resolve(PROJ) = %+v, want the claimed slot", winner) + } + // The slot address itself still works as an exact spelling. + if w := ResolveExact(cands, "slot-3"); w == nil || w.Branch != "PROJ-123" { + t.Errorf("ResolveExact(slot-3) = %+v, want the claimed slot", w) } } diff --git a/internal/pool/pool.go b/internal/pool/pool.go new file mode 100644 index 0000000..dcabfd2 --- /dev/null +++ b/internal/pool/pool.go @@ -0,0 +1,117 @@ +// Package pool holds the pure mechanics of pool mode (PLAN.md +// Phase 4): slot naming, the pattern guard that makes non-slot +// paths structurally unresettable (D14), and the refresh hash +// behind the lockfile gate (D5). +package pool + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// slotName is the one spelling of a slot: slot-N, N ≥ 1, no +// leading zeros: exactly the names wt itself mints, nothing more. +var slotName = regexp.MustCompile(`^slot-([1-9][0-9]*)$`) + +// SlotName names the i-th pool slot (1-based). +func SlotName(i int) string { + return fmt.Sprintf("slot-%d", i) +} + +// Names lists every slot name of a pool of the given size. +func Names(size int) []string { + names := make([]string, size) + for i := range names { + names[i] = SlotName(i + 1) + } + return names +} + +// IsSlotName reports whether name is a slot wt itself would mint. +// Exact by design: it gates destructive operations, so a name that +// merely resembles a slot must never pass. +func IsSlotName(name string) bool { + return slotName.MatchString(name) +} + +// CollidesWithSlotName reports whether name would occupy a slot's +// directory. Case folds here where IsSlotName will not: on a +// case-insensitive filesystem (macOS by default) a tree named +// Slot-1 sits exactly where slot-1 must go, so the name has to be +// refused even though it is not itself a slot. +func CollidesWithSlotName(name string) bool { + return IsSlotName(strings.ToLower(name)) +} + +// SlotIndex reports the 1-based index behind a slot name wt would +// mint, for callers that must compare a slot against a pool size. +func SlotIndex(name string) (int, bool) { + m := slotName.FindStringSubmatch(name) + if m == nil { + return 0, false + } + i, err := strconv.Atoi(m[1]) + if err != nil { + return 0, false + } + return i, true +} + +// SlotPath reports whether path is a pool slot sitting directly +// inside treesDir, and which one. This is the pattern guard +// (D14): every reset, release, and slot removal must pass here +// first, so the main checkout and personal trees are structurally +// unresettable. Both sides are symlink-resolved before comparing +// (a symlink named like a slot but pointing elsewhere resolves +// elsewhere and is refused), and anything unresolvable fails +// closed. +func SlotPath(treesDir, path string) (string, bool) { + container, err := filepath.EvalSymlinks(treesDir) + if err != nil { + return "", false + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return "", false + } + if filepath.Dir(resolved) != container { + return "", false + } + base := filepath.Base(resolved) + if !IsSlotName(base) { + return "", false + } + return base, true +} + +// Hash fingerprints the named files under root for the refresh +// gate (D5): a claim re-runs hooks.refresh only when the hash +// differs from the recorded one. Name, presence, and content +// length are all bound into the digest, so an edit, an addition, +// a removal, and a rename each change the result, and no +// concatenation of inputs is ambiguous. +func Hash(root string, files []string) (string, error) { + h := sha256.New() + for _, name := range files { + fmt.Fprintf(h, "%s\x00", name) + data, err := os.ReadFile(filepath.Join(root, name)) + if errors.Is(err, fs.ErrNotExist) { + fmt.Fprint(h, "absent\x00") + continue + } + if err != nil { + return "", err + } + fmt.Fprintf(h, "%d:", len(data)) + h.Write(data) + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/pool/pool_test.go b/internal/pool/pool_test.go new file mode 100644 index 0000000..1f80ce4 --- /dev/null +++ b/internal/pool/pool_test.go @@ -0,0 +1,225 @@ +package pool_test + +import ( + "os" + "path/filepath" + "slices" + "testing" + + "github.com/loganthomas/wt/internal/pool" +) + +func TestSlotName(t *testing.T) { + if got := pool.SlotName(3); got != "slot-3" { + t.Errorf("SlotName(3) = %q, want slot-3", got) + } +} + +func TestNames(t *testing.T) { + want := []string{"slot-1", "slot-2", "slot-3"} + if got := pool.Names(3); !slices.Equal(got, want) { + t.Errorf("Names(3) = %v, want %v", got, want) + } +} + +func TestIsSlotName(t *testing.T) { + tests := []struct { + name string + ok bool + }{ + {"slot-1", true}, + {"slot-12", true}, + // Only names wt itself would mint count as slots: + // anything else must never be resettable (D14). + {"slot-0", false}, + {"slot-03", false}, + {"slot-", false}, + {"slot-x", false}, + {"slot-1x", false}, + {"SLOT-1", false}, + {"my-slot-1", false}, + {"slot-1-backup", false}, + {"feature-login", false}, + {"", false}, + } + for _, tt := range tests { + if ok := pool.IsSlotName(tt.name); ok != tt.ok { + t.Errorf("IsSlotName(%q) = %v, want %v", tt.name, ok, tt.ok) + } + } +} + +func TestSlotIndex(t *testing.T) { + tests := []struct { + name string + idx int + ok bool + }{ + {"slot-1", 1, true}, + {"slot-12", 12, true}, + {"slot-0", 0, false}, + {"slot-x", 0, false}, + {"feature-login", 0, false}, + } + for _, tt := range tests { + idx, ok := pool.SlotIndex(tt.name) + if idx != tt.idx || ok != tt.ok { + t.Errorf("SlotIndex(%q) = %d, %v; want %d, %v", tt.name, idx, ok, tt.idx, tt.ok) + } + } +} + +// TestSlotPath drives the pattern guard with the hostile inputs +// from PLAN.md Phase 4: the main checkout, personal trees, and +// symlinks must all be structurally unresettable. +func TestSlotPath(t *testing.T) { + root := t.TempDir() + // Symlink-resolved so expectations match EvalSymlinks output + // (macOS's temp dir is itself a symlink). + root, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + main := filepath.Join(root, "acme") + trees := filepath.Join(root, "acme.trees") + for _, dir := range []string{ + main, + filepath.Join(trees, "slot-1"), + filepath.Join(trees, "slot-1", "slot-2"), // nested decoy + filepath.Join(trees, "feature-login"), // personal tree + filepath.Join(root, "elsewhere", "slot-1"), + } { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + // A symlink inside the container pointing at the main checkout: + // the name matches, the target must not. + if err := os.Symlink(main, filepath.Join(trees, "slot-9")); err != nil { + t.Fatal(err) + } + + tests := []struct { + path string + slot string + ok bool + }{ + {filepath.Join(trees, "slot-1"), "slot-1", true}, + {main, "", false}, + {trees, "", false}, + {filepath.Join(trees, "feature-login"), "", false}, + {filepath.Join(trees, "slot-1", "slot-2"), "", false}, + {filepath.Join(root, "elsewhere", "slot-1"), "", false}, + {filepath.Join(trees, "slot-9"), "", false}, // symlink to main + {filepath.Join(trees, "no-such", "..", "slot-1"), "slot-1", true}, + {filepath.Join(trees, "does-not-exist"), "", false}, // fail closed + {filepath.Join(trees, "slot-999"), "", false}, // fail closed: absent + } + for _, tt := range tests { + slot, ok := pool.SlotPath(trees, tt.path) + if slot != tt.slot || ok != tt.ok { + t.Errorf("SlotPath(trees, %q) = %q, %v; want %q, %v", + tt.path, slot, ok, tt.slot, tt.ok) + } + } + + // A symlinked container still recognizes its own slots: + // git reports physical paths, config may hold the symlink. + link := filepath.Join(root, "trees-link") + if err := os.Symlink(trees, link); err != nil { + t.Fatal(err) + } + if slot, ok := pool.SlotPath(link, filepath.Join(trees, "slot-1")); !ok || slot != "slot-1" { + t.Errorf("SlotPath through symlinked container = %q, %v; want slot-1, true", slot, ok) + } +} + +func writeFile(t *testing.T, root, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestHash(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "pnpm-lock.yaml", "lockfile-v1") + writeFile(t, root, "other.lock", "other") + + base, err := pool.Hash(root, []string{"pnpm-lock.yaml"}) + if err != nil { + t.Fatal(err) + } + if base == "" { + t.Fatal("Hash returned empty") + } + + same, err := pool.Hash(root, []string{"pnpm-lock.yaml"}) + if err != nil { + t.Fatal(err) + } + if same != base { + t.Error("Hash is not deterministic") + } + + writeFile(t, root, "pnpm-lock.yaml", "lockfile-v2") + changed, err := pool.Hash(root, []string{"pnpm-lock.yaml"}) + if err != nil { + t.Fatal(err) + } + if changed == base { + t.Error("content change did not change the hash") + } + + widened, err := pool.Hash(root, []string{"pnpm-lock.yaml", "other.lock"}) + if err != nil { + t.Fatal(err) + } + if widened == changed { + t.Error("adding a file to the list did not change the hash") + } + + // A missing file and an empty file are different states: + // deleting a lockfile must trigger a refresh. + writeFile(t, root, "empty.lock", "") + withEmpty, err := pool.Hash(root, []string{"empty.lock"}) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(root, "empty.lock")); err != nil { + t.Fatal(err) + } + withMissing, err := pool.Hash(root, []string{"empty.lock"}) + if err != nil { + t.Fatal(err) + } + if withEmpty == withMissing { + t.Error("missing file hashes like an empty file") + } +} + +// A case-insensitive filesystem (macOS by default) puts Slot-1 +// exactly where slot-1 must go, so the reservation folds case +// where the destructive-path guard stays exact. +func TestCollidesWithSlotName(t *testing.T) { + tests := []struct { + name string + slot bool + clash bool + }{ + {"slot-1", true, true}, + {"Slot-1", false, true}, + {"SLOT-12", false, true}, + {"slot-0", false, false}, + {"slot-1x", false, false}, + {"feature-login", false, false}, + } + for _, tt := range tests { + if got := pool.IsSlotName(tt.name); got != tt.slot { + t.Errorf("IsSlotName(%q) = %v, want %v", tt.name, got, tt.slot) + } + if got := pool.CollidesWithSlotName(tt.name); got != tt.clash { + t.Errorf("CollidesWithSlotName(%q) = %v, want %v", tt.name, got, tt.clash) + } + } +} diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..553a182 --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,82 @@ +// Package state owns the layout of wt's per-repo state directory +// (PLAN.md D4): lease directories, per-tree refresh hashes, and, +// in later phases, fetch timestamps. Every path under the state +// root is spelled here and nowhere else, so the on-disk layout +// documented in PLAN.md cannot drift piecemeal. +package state + +import ( + "os" + "path/filepath" + "strings" +) + +// Dir is one repository's state root, +// e.g. ~/.local/state/wt/repos/-. +type Dir string + +// LeasesDir is where pool slot leases live; the lease package +// manages its contents. +func (d Dir) LeasesDir() string { + return filepath.Join(string(d), "leases") +} + +// The per-tree files. Named once: this package's whole job is +// keeping the on-disk layout in one place. +const ( + refreshHashFile = "refresh_hash" + provisionedFile = "provisioned" +) + +// RefreshHash returns the hash recorded for tree name at its last +// successful refresh, or "" when none has been recorded. +// Any read failure reads as "no hash": the worst consequence is +// one redundant refresh run, which is always safe. +func (d Dir) RefreshHash(name string) string { + raw, err := os.ReadFile(d.treeFile(name, refreshHashFile)) + if err != nil { + return "" + } + return strings.TrimSpace(string(raw)) +} + +// WriteRefreshHash records the refresh hash for tree name, +// creating the tree's state directory as needed. +func (d Dir) WriteRefreshHash(name, hash string) error { + return d.writeTreeFile(name, refreshHashFile, []byte(hash+"\n")) +} + +// MarkProvisioned records that tree name finished provisioning: +// worktree, copies, setup hook, all of it. Written last, so its +// absence on a registered slot proves the provision died midway. +func (d Dir) MarkProvisioned(name string) error { + return d.writeTreeFile(name, provisionedFile, nil) +} + +// Provisioned reports whether tree name completed provisioning. +func (d Dir) Provisioned(name string) bool { + _, err := os.Stat(d.treeFile(name, provisionedFile)) + return err == nil +} + +// RemoveTree drops all recorded state for tree name; +// absent state is not an error, so cleanup can run unconditionally. +func (d Dir) RemoveTree(name string) error { + return os.RemoveAll(d.treeDir(name)) +} + +func (d Dir) writeTreeFile(name, file string, data []byte) error { + path := d.treeFile(name, file) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +func (d Dir) treeFile(name, file string) string { + return filepath.Join(d.treeDir(name), file) +} + +func (d Dir) treeDir(name string) string { + return filepath.Join(string(d), "trees", name) +} diff --git a/internal/state/state_test.go b/internal/state/state_test.go new file mode 100644 index 0000000..57cf942 --- /dev/null +++ b/internal/state/state_test.go @@ -0,0 +1,95 @@ +package state_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/loganthomas/wt/internal/state" +) + +func TestLeasesDir(t *testing.T) { + d := state.Dir(filepath.Join("some", "root")) + want := filepath.Join("some", "root", "leases") + if got := d.LeasesDir(); got != want { + t.Errorf("LeasesDir() = %q, want %q", got, want) + } +} + +func TestRefreshHashRoundTrip(t *testing.T) { + d := state.Dir(t.TempDir()) + + if got := d.RefreshHash("slot-1"); got != "" { + t.Errorf("RefreshHash on fresh state = %q, want empty", got) + } + if err := d.WriteRefreshHash("slot-1", "abc123"); err != nil { + t.Fatal(err) + } + if got := d.RefreshHash("slot-1"); got != "abc123" { + t.Errorf("RefreshHash = %q, want %q", got, "abc123") + } + + // The on-disk location is part of the documented state layout + // (PLAN.md, State layout): trees//refresh_hash. + onDisk := filepath.Join(string(d), "trees", "slot-1", "refresh_hash") + if _, err := os.Stat(onDisk); err != nil { + t.Errorf("hash not at the documented layout path: %v", err) + } + + if err := d.WriteRefreshHash("slot-1", "def456"); err != nil { + t.Fatal(err) + } + if got := d.RefreshHash("slot-1"); got != "def456" { + t.Errorf("RefreshHash after overwrite = %q, want %q", got, "def456") + } +} + +func TestRefreshHashIsolatesTrees(t *testing.T) { + d := state.Dir(t.TempDir()) + if err := d.WriteRefreshHash("slot-1", "aaa"); err != nil { + t.Fatal(err) + } + if got := d.RefreshHash("slot-2"); got != "" { + t.Errorf("RefreshHash(slot-2) = %q, want empty", got) + } +} + +func TestRemoveTree(t *testing.T) { + d := state.Dir(t.TempDir()) + if err := d.WriteRefreshHash("slot-3", "aaa"); err != nil { + t.Fatal(err) + } + if err := d.RemoveTree("slot-3"); err != nil { + t.Fatal(err) + } + if got := d.RefreshHash("slot-3"); got != "" { + t.Errorf("RefreshHash after RemoveTree = %q, want empty", got) + } + // Removing state that never existed is not an error: + // callers clean up unconditionally. + if err := d.RemoveTree("never-existed"); err != nil { + t.Errorf("RemoveTree on absent state: %v", err) + } +} + +func TestProvisionedMarker(t *testing.T) { + d := state.Dir(t.TempDir()) + if d.Provisioned("slot-1") { + t.Error("Provisioned on fresh state = true, want false") + } + if err := d.MarkProvisioned("slot-1"); err != nil { + t.Fatal(err) + } + if !d.Provisioned("slot-1") { + t.Error("Provisioned after MarkProvisioned = false, want true") + } + if d.Provisioned("slot-2") { + t.Error("marker leaked across trees") + } + if err := d.RemoveTree("slot-1"); err != nil { + t.Fatal(err) + } + if d.Provisioned("slot-1") { + t.Error("Provisioned survived RemoveTree") + } +}