From fd16c1669de682769687aca1643d8983f3376d8b Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 20 Jul 2026 12:11:08 -0700 Subject: [PATCH 1/3] feat: add stable lease identities --- AGENTS.md | 4 +- README.md | 28 ++++- cmd/e2e_test.go | 210 +++++++++++++++++++++++++++++++++++- cmd/get.go | 16 ++- cmd/return_cmd.go | 72 ++++++++++--- cmd/status.go | 50 +++++++++ internal/pool/pool.go | 143 ++++++++++++++++++------ internal/pool/pool_test.go | 109 ++++++++++++++++++- internal/pool/state.go | 13 +++ internal/pool/state_test.go | 29 +++++ 10 files changed, 613 insertions(+), 61 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b90f483..899190a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,9 +37,9 @@ 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`, classified `DestroyLeased` by destroy (removable only when the exact path is named with `--include-leased`, NEVER via `--all`), surfaced by `status` as `StatusLeased`, and cleared by `Release` (`return`) +- Durable leases are a separate, process-independent reservation: `WorktreeEntry.Leased`/`LeaseID`/`LeaseHolder`/`LeasedAt` persist in the state file with `omitempty`. Every acquisition generates a new immutable 128-bit random `LeaseID`; older state without it loads with an empty ID and remains releasable through the legacy unconditional path. A lease is NOT derived from live processes, so it survives with zero processes inside the worktree and `healState` never clears it. Leased worktrees are skipped by `Acquire` and `prune`, classified `DestroyLeased` by destroy (removable only when the exact path is named with `--include-leased`, NEVER via `--all`), surfaced by `status` as `StatusLeased`, and cleared by `Release` (`return`) - `destroy` is safe-by-default and mirrors `prune`: dry-run unless `--yes`, narrow explicit targets (`destroy ` for one worktree; `destroy --all` for that pool only - there is NO cross-pool/global destroy, and `--all` with no pool target is an error). The old blunt `--force` flag is REMOVED (this was the v2.0.0 breaking change); each risk class is its own opt-in: `--include-unlanded` (dirty, unmerged, or unverified), `--include-in-use` (running process or owner reservation; processes terminated cleanly first), `--include-leased` (leased, single named path only). A bare `--all --yes` removes only the disposable set (merged, clean, idle, unleased) and skips the rest with the flag that would include each. Bulk skips exit 0; a single-target skip exits non-zero. Entry points: `pool.DestroyWorktree` (single path, `allowLeased=true`) and `pool.DestroyPool` (bulk, `allowLeased=false`). Both share `classifyForDestroy` in `internal/pool/destroy.go`, which reuses prune's classification primitives (`ownerAlive`, `process.FindProcessesInWorktree`, `backingRepositoryMissing`, `git.IsDirty`, `git.IsHeadMergedIntoRef` against the `resolvePruneDefaultRef` ref) so destroy and prune agree on leased/in-use/unlanded/unverified/disposable. Removal keeps the same two-phase reservation as prune (reserve under flock, run `pre_destroy` hooks, remove only worktrees whose `sameDestroyReservation` still holds), so a worktree re-acquired during its hook is never deleted -- `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 +- `get --lease` (see `getLeaseRunE`) is the non-interactive acquire: it opens no subshell, routes hook output and banners to stderr, and keeps path-only stdout unchanged. `get --lease --json` returns `pool.AcquireLeaseInfo`, and `status --json` exposes the same `lease_id`, holder, and timestamp. Conditional return uses `pool.ReleaseConditional` with `--if-lease-id` and optional `--if-lease-holder`; comparison, caller-side preparation, reset, and final clear share one `WithStateLock`, while return without conditions keeps the legacy path-only behavior - 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 732e791..1f74a24 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ The default treehouse root is `~/.treehouse/`. - **No daemon** - all operations are inline CLI commands. Pool state is a small on-disk file, written under a lock by each command. - **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. +- **Durable leases** - `treehouse get --lease` reserves a worktree as a persistent home without keeping a process inside it. Each acquisition gets an immutable random lease identity, and the lease is recorded in treehouse's own state. 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. - **State recovery** - treehouse writes pool state atomically via a temp file and replacement. If an existing state file is empty or truncated, treehouse warns, rebuilds entries from worktrees still on disk, and marks those entries leased until you verify them with `treehouse status`. - **Dirty detection** - treehouse treats tracked changes and untracked files as dirty, even when repository config hides untracked files from normal `git status` output. @@ -165,8 +165,12 @@ The default treehouse root is `~/.treehouse/`. | --------- | --------- | --------------------------------- | | `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`) | +| `get` | `--json` | Print `path`, `lease_id`, `lease_holder`, and `leased_at` as JSON (requires `--lease`) | | `enter` | `--print-path` | Print only the worktree's absolute path to stdout instead of opening a subshell (for `cd "$(treehouse enter --print-path 1)"`) | +| `status` | `--json` | Print worktree status and lease metadata as JSON | | `return` | `--force` | Clean, reset, and return without prompting | +| `return` | `--if-lease-id` | Return only if the current lease has the expected per-acquisition identity | +| `return` | `--if-lease-holder` | Return only if the current lease has the expected holder | | `prune` | `--yes` | Delete listed prune candidates instead of doing a dry run | | `prune` | `--all` | Sweep every managed pool under the user-level treehouse root | | `prune` | `--global` | Alias for `--all` | @@ -197,9 +201,31 @@ A bulk `treehouse destroy --all` never removes it either; only naming its Pass `--lease-holder