From eb29b0b8340dded40f67ae1fd66d77308198cc9e Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 22 Jun 2026 15:33:10 -0700 Subject: [PATCH 1/5] feat(pool): add durable worktree lease (get --lease) Add a persistent, process-independent worktree reservation so a caller can permanently hold a pooled worktree as a home without keeping a live process inside it. This replaces the fragile process-based hold that consumers like firstmate's persistent secondmate homes relied on. - New WorktreeEntry.Leased/LeaseHolder/LeasedAt persistent state, all omitempty so pre-lease state files keep today's behavior. - `treehouse get --lease` acquires without a subshell, marks the worktree leased, and prints only its absolute path to stdout (all banners and hook output go to stderr), so `path=$(treehouse get --lease)` is clean in scripts. `--lease-holder` / $TREEHOUSE_LEASE_HOLDER records the holder. - Leased worktrees are skipped by get and prune regardless of running processes, require --force to destroy, and healState never clears them. - `treehouse return ` clears the lease and returns the worktree, keeping its existing lingering-process termination behavior. - `treehouse status` shows a distinct `leased` state with the holder. - Concurrency is guarded by the existing WithStateLock (flock); both Acquire and AcquireLease delegate to a shared acquire() core. Tests cover: lease prints only the path to stdout with info on stderr; a leased worktree is skipped by get and prune with no process inside; return releases it; status shows the leased state; concurrent acquires never double-lease. README and AGENTS.md document the flag and the lease concept. --- AGENTS.md | 4 +- README.md | 31 ++++- cmd/e2e_test.go | 141 ++++++++++++++++++++ cmd/get.go | 42 +++++- cmd/status.go | 9 +- internal/pool/pool.go | 81 +++++++++++- internal/pool/pool_test.go | 265 +++++++++++++++++++++++++++++++++++++ internal/pool/prune.go | 2 +- internal/pool/state.go | 10 ++ 9 files changed, 571 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ff564fe..b9eebb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Treehouse is a Go CLI tool that manages a pool of git worktrees for parallel AI ## Project Structure - `main.go` - entry point, calls `cmd.Execute()` -- `cmd/` - CLI commands (cobra): `get`, `return`, `status`, `prune`, `destroy` +- `cmd/` - CLI commands (cobra): `get` (incl. `get --lease`), `return`, `status`, `prune`, `destroy` - `internal/config/` - config file loading (`treehouse.toml`) - `internal/hooks/` - user-configured lifecycle hook command execution - `internal/pool/` - pool manager (acquire, release, list, destroy, prune) + state file @@ -37,6 +37,8 @@ make test - No daemon - all operations are inline CLI commands - Detached HEAD worktrees reset to whichever of local or origin default branch is further ahead (prefers origin on divergence) - In-use detection uses process scanning plus short-lived persisted owner reservations for lifecycle operations +- Durable leases are a separate, process-independent reservation: `WorktreeEntry.Leased`/`LeaseHolder`/`LeasedAt` persist in the state file (all `omitempty`, so pre-lease state files keep today's behavior). A lease is NOT derived from live processes, so it survives with zero processes inside the worktree and `healState` never clears it (it only clears dead owner reservations). Leased worktrees are skipped by `Acquire` and `prune`, treated as in-use by `worktreeInUse` (so non-force `destroy` rejects them), surfaced by `status` as `StatusLeased`, and cleared by `Release` (`return`) +- `get --lease` (see `getLeaseRunE`) is the non-interactive acquire: it implies the durable lease, opens no subshell, routes post-create hook stdout and all banners to stderr, and prints ONLY the worktree path to stdout so `path=$(treehouse get --lease)` is clean. `--lease-holder`/`$TREEHOUSE_LEASE_HOLDER` set the recorded holder. `pool.AcquireLease` is the entry point; both it and `Acquire` delegate to the shared `acquire(..., acquireOptions)` core, and `markAcquired` stamps either a lease or an owner reservation. Concurrency safety comes from the existing `WithStateLock` (flock) around all pool mutation - Dirty checks include untracked files even when repository config hides them from normal `git status` output - Prune deletes only idle managed worktrees that are clean and whose HEAD is merged into the default branch; dry run is the default - Prune reports unsafe idle worktrees in grouped, stable categories and keeps raw git diagnostics for verbose output instead of default output diff --git a/README.md b/README.md index 74c2927..836c512 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ The default treehouse root is `~/.treehouse/`. - **Detached HEAD** — worktrees use detached HEAD mode, reset to whichever of the local or remote default branch is further ahead, avoiding branch name conflicts entirely. - **No daemon** — all operations are inline CLI commands. No background processes, no state to get corrupted. - **In-use detection** — treehouse scans running processes and short-lived owner reservations to determine which worktrees are in-use. Reservations are persisted only while `get`, `destroy`, and `prune` lifecycle work is running. +- **Durable leases** — `treehouse get --lease` reserves a worktree as a persistent home without keeping a process inside it. The lease is recorded in treehouse's own state, so the worktree is never handed out by a later `get` and never removed by `prune` until you release it with `treehouse return`. Unlike process-based in-use detection, a lease survives with zero processes running inside the worktree. - **Dirty detection** - treehouse treats tracked changes and untracked files as dirty, even when repository config hides untracked files from normal `git status` output. - **Safe pruning** - By default, `treehouse prune` removes only idle managed worktrees whose HEAD is already merged into the default branch and whose working tree is clean. `treehouse prune --all` applies the same safety checks across every managed pool under the user-level treehouse root. @@ -144,8 +145,9 @@ The default treehouse root is `~/.treehouse/`. | -------------------------- | ---------------------------------------------------- | | `treehouse` | Get a worktree and open a subshell (alias for `get`) | | `treehouse get` | Acquire a worktree from the pool | -| `treehouse status` | Show pool status (highlights your current worktree) | -| `treehouse return [path]` | Terminate lingering worktree processes and return it to the pool | +| `treehouse get --lease` | Durably lease a worktree without a subshell; print its path | +| `treehouse status` | Show pool status (highlights leased and current worktrees) | +| `treehouse return [path]` | Release any lease, terminate lingering worktree processes, and return it to the pool | | `treehouse prune` | Dry-run removal of stale idle worktrees in the current repo pool | | `treehouse prune --all` | Dry-run removal of stale idle worktrees across every managed pool | | `treehouse destroy [path]` | Remove a worktree from the pool | @@ -156,6 +158,8 @@ The default treehouse root is `~/.treehouse/`. | Command | Flag | Description | | --------- | --------- | --------------------------------- | +| `get` | `--lease` | Durably lease the worktree without opening a subshell; print only its path to stdout | +| `get` | `--lease-holder` | Optional label recorded as the lease holder (defaults to `$TREEHOUSE_LEASE_HOLDER`) | | `return` | `--force` | Clean, reset, and return without prompting | | `prune` | `--yes` | Delete listed prune candidates instead of doing a dry run | | `prune` | `--all` | Sweep every managed pool under the user-level treehouse root | @@ -165,6 +169,27 @@ The default treehouse root is `~/.treehouse/`. | `destroy` | `--force` | Force destroy even if in-use | | `destroy` | `--all` | Destroy all worktrees in the pool | +### Leasing a worktree (no subshell) + +`treehouse get` normally opens an interactive subshell whose lifetime is the hold: when the shell exits, the worktree returns to the pool. +That is awkward for callers that need a worktree to persist as a permanent home with no long-lived process inside it. + +`treehouse get --lease` is the non-interactive, durable alternative: + +```sh +path=$(treehouse get --lease) +# $path is the leased worktree's absolute path; all banners went to stderr. +``` + +It acquires a worktree exactly like `get`, but instead of opening a subshell it marks the worktree **leased** in treehouse's persistent state and prints only the worktree's absolute path to stdout (every human-facing message goes to stderr, so command substitution stays clean). + +A leased worktree is never handed out by a later `get` and never removed by `prune`, regardless of whether any process runs inside it, until the lease is explicitly released. +It also requires `--force` to `destroy`. + +Pass `--lease-holder