diff --git a/.no-mistakes/evidence/fm/treehouse-lease-t4/lease-cli-e2e.txt b/.no-mistakes/evidence/fm/treehouse-lease-t4/lease-cli-e2e.txt new file mode 100644 index 0000000..7adb285 --- /dev/null +++ b/.no-mistakes/evidence/fm/treehouse-lease-t4/lease-cli-e2e.txt @@ -0,0 +1,90 @@ +Treehouse durable lease CLI E2E transcript +source_commit=a22297d2bda85e045324784bcc47504953d171aa +evidence_generated_at=2026-06-22T23:10:18Z + +$ go build -o /treehouse . +build_exit=0 + +Fixture repo created at /Users/kunchen/.no-mistakes/worktrees/8c0b202f5740/01KVRQHS508TSFYHYAJN944WEG/.no-mistakes/evidence/fm/treehouse-lease-t4/.tmp-lease-e2e-55093/repo with isolated HOME /Users/kunchen/.no-mistakes/worktrees/8c0b202f5740/01KVRQHS508TSFYHYAJN944WEG/.no-mistakes/evidence/fm/treehouse-lease-t4/.tmp-lease-e2e-55093/home and pool root /Users/kunchen/.no-mistakes/worktrees/8c0b202f5740/01KVRQHS508TSFYHYAJN944WEG/.no-mistakes/evidence/fm/treehouse-lease-t4/.tmp-lease-e2e-55093/pool-root. + +$ path=$(treehouse get --lease --lease-holder secondmate-home) +stdout: +/Users/kunchen/.no-mistakes/worktrees/8c0b202f5740/01KVRQHS508TSFYHYAJN944WEG/.no-mistakes/evidence/fm/treehouse-lease-t4/.tmp-lease-e2e-55093/pool-root/.treehouse/repo-5f2bf8/1/repo +stderr: +🌳 Setting up worktree... +🌳 Leased worktree at /Users/kunchen/.no-mistakes/worktrees/8c0b202f5740/01KVRQHS508TSFYHYAJN944WEG/.no-mistakes/evidence/fm/treehouse-lease-t4/.tmp-lease-e2e-55093/pool-root/.treehouse/repo-5f2bf8/1/repo. Run 'treehouse return /Users/kunchen/.no-mistakes/worktrees/8c0b202f5740/01KVRQHS508TSFYHYAJN944WEG/.no-mistakes/evidence/fm/treehouse-lease-t4/.tmp-lease-e2e-55093/pool-root/.treehouse/repo-5f2bf8/1/repo' to release it. +stdout_line_count=1 +stdout_is_absolute_path=yes +stdout_contains_banner=no +leased_path_exists=yes + +$ sed -n '1,120p' +{ + "worktrees": [ + { + "name": "1", + "path": "/Users/kunchen/.no-mistakes/worktrees/8c0b202f5740/01KVRQHS508TSFYHYAJN944WEG/.no-mistakes/evidence/fm/treehouse-lease-t4/.tmp-lease-e2e-55093/pool-root/.treehouse/repo-5f2bf8/1/repo", + "created_at": "2026-06-22T16:10:18.892955-07:00", + "leased": true, + "lease_holder": "secondmate-home", + "leased_at": "2026-06-22T16:10:18.892955-07:00" + } + ] +} +owner_pid_present_after_lease=no + +$ treehouse status +stdout: +1 leased /Users/kunchen/.no-mistakes/worktrees/8c0b202f5740/01KVRQHS508TSFYHYAJN944WEG/.no-mistakes/evidence/fm/treehouse-lease-t4/.tmp-lease-e2e-55093/pool-root/.treehouse/repo-5f2bf8/1/repo (held by secondmate-home) +stderr: + + +$ SHELL= treehouse get +stdout: + +stderr: +🌳 Setting up worktree... +🌳 Entered worktree at /Users/kunchen/.no-mistakes/worktrees/8c0b202f5740/01KVRQHS508TSFYHYAJN944WEG/.no-mistakes/evidence/fm/treehouse-lease-t4/.tmp-lease-e2e-55093/pool-root/.treehouse/repo-5f2bf8/2/repo. Type 'exit' to return. +🌳 Worktree returned to pool. +normal_get_path=/Users/kunchen/.no-mistakes/worktrees/8c0b202f5740/01KVRQHS508TSFYHYAJN944WEG/.no-mistakes/evidence/fm/treehouse-lease-t4/.tmp-lease-e2e-55093/pool-root/.treehouse/repo-5f2bf8/2/repo +normal_get_reused_leased_path=no + +$ treehouse prune --yes +stdout: +🌳 Pruned 1 stale worktree and freed 625 B. +2 625 B /Users/kunchen/.no-mistakes/worktrees/8c0b202f5740/01KVRQHS508TSFYHYAJN944WEG/.no-mistakes/evidence/fm/treehouse-lease-t4/.tmp-lease-e2e-55093/pool-root/.treehouse/repo-5f2bf8/2/repo +stderr: + +leased_path_exists_after_prune=yes + +$ printf 'y\n' | treehouse destroy +stdout: + +stderr: +Destroy worktree /Users/kunchen/.no-mistakes/worktrees/8c0b202f5740/01KVRQHS508TSFYHYAJN944WEG/.no-mistakes/evidence/fm/treehouse-lease-t4/.tmp-lease-e2e-55093/pool-root/.treehouse/repo-5f2bf8/1/repo? [y/N] worktree /Users/kunchen/.no-mistakes/worktrees/8c0b202f5740/01KVRQHS508TSFYHYAJN944WEG/.no-mistakes/evidence/fm/treehouse-lease-t4/.tmp-lease-e2e-55093/pool-root/.treehouse/repo-5f2bf8/1/repo is in use by an agent. Use --force to override +destroy_exit_code=1 +leased_path_exists_after_destroy_rejection=yes + +$ treehouse return +stdout: + +stderr: +🌳 Worktree returned to pool. + +$ treehouse status # after return +stdout: +1 available /Users/kunchen/.no-mistakes/worktrees/8c0b202f5740/01KVRQHS508TSFYHYAJN944WEG/.no-mistakes/evidence/fm/treehouse-lease-t4/.tmp-lease-e2e-55093/pool-root/.treehouse/repo-5f2bf8/1/repo +stderr: + + +$ SHELL= treehouse get # after return +stdout: + +stderr: +🌳 Setting up worktree... +🌳 Entered worktree at /Users/kunchen/.no-mistakes/worktrees/8c0b202f5740/01KVRQHS508TSFYHYAJN944WEG/.no-mistakes/evidence/fm/treehouse-lease-t4/.tmp-lease-e2e-55093/pool-root/.treehouse/repo-5f2bf8/1/repo. Type 'exit' to return. +🌳 Worktree returned to pool. +reused_path_after_return=/Users/kunchen/.no-mistakes/worktrees/8c0b202f5740/01KVRQHS508TSFYHYAJN944WEG/.no-mistakes/evidence/fm/treehouse-lease-t4/.tmp-lease-e2e-55093/pool-root/.treehouse/repo-5f2bf8/1/repo +released_worktree_reused=yes + +All manual lease E2E assertions passed. diff --git a/AGENTS.md b/AGENTS.md index ff564fe..43a9f95 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 @@ -44,7 +46,8 @@ make test - Prune never treats an unreachable origin as a deletable orphan; those worktrees stay skipped because the repository may still be valid - Global prune enumerates managed pool directories under the user-level treehouse root and derives each worktree's owning repository from git metadata instead of relying on the current directory - Global prune loads user-level config and hooks only because it can run without a repository context -- State file tracks pool membership and temporary owner/destroy reservations, not long-term usage status +- State file tracks pool membership, temporary owner/destroy reservations, and explicit durable leases. + It still does not infer long-term usage from processes. - Git operations shell out to `git` (go-git has incomplete worktree support) - Self-healing: stale state entries are auto-removed diff --git a/README.md b/README.md index 74c2927..6c2fccf 100644 --- a/README.md +++ b/README.md @@ -97,10 +97,10 @@ The default treehouse root is `~/.treehouse/`. git fetch origin │ ▼ - ┌────────────────────────────────────┐ - │ Scan pool for available worktree │ - │ (not in-use, not dirty) │ - └──────────┬─────────────────────────┘ + ┌───────────────────────────────────────┐ + │ Scan pool for available worktree │ + │ (not leased, not in-use, not dirty) │ + └──────────┬────────────────────────────┘ │ ┌────┴────┐ │ Found? │ @@ -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,15 +158,39 @@ 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 | | `prune` | `--global` | Alias for `--all` | | `prune` | `--prune-orphans` | Include backing-repository-missing orphans in prune candidates | | `prune` | `--verbose`, `-v` | Show detailed skip diagnostics | -| `destroy` | `--force` | Force destroy even if in-use | +| `destroy` | `--force` | Force destroy even if in-use or leased | | `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