Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .no-mistakes/evidence/fm/treehouse-lease-t4/lease-cli-e2e.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
Treehouse durable lease CLI E2E transcript
source_commit=a22297d2bda85e045324784bcc47504953d171aa
evidence_generated_at=2026-06-22T23:10:18Z

$ go build -o <run-dir>/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' <treehouse-state.json after lease>
{
"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:
<empty>

$ SHELL=<true> treehouse get
stdout:
<empty>
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:
<empty>
leased_path_exists_after_prune=yes

$ printf 'y\n' | treehouse destroy <leased-path>
stdout:
<empty>
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 <leased-path>
stdout:
<empty>
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:
<empty>

$ SHELL=<true> treehouse get # after return
stdout:
<empty>
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.
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -37,14 +37,17 @@ 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
- Prune treats backing-repository-missing linked worktrees as orphans; they are only deletable with explicit `--prune-orphans --yes`, and each candidate warns that content could not be verified
- 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

Expand Down
43 changes: 35 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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? │
Expand Down Expand Up @@ -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.
Expand All @@ -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 |
Expand All @@ -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 <label>` (or set `$TREEHOUSE_LEASE_HOLDER`) to record who holds the lease; `treehouse status` then shows it next to the `leased` state.

Release a lease with `treehouse return <path>`, which clears the lease, terminates any lingering processes, resets the worktree, and returns it to the pool.
When you pass an explicit path, `treehouse return` can run from outside the repository because it resolves the managed pool from that worktree path.

### Pruning stale worktrees and orphans

`treehouse prune` is a dry run by default.
Expand All @@ -176,7 +202,7 @@ Pass `treehouse prune --all` or `treehouse prune --global` to inspect every mana
Global prune reads the user-level config and hooks, derives each worktree's owning repository from git metadata, then fetches and checks merge safety against that repository.
Without `--prune-orphans`, pass `treehouse prune --all --yes` to delete only the globally safe stale candidates.

Prune ignores worktrees that are currently in use or reserved by another lifecycle operation.
Prune ignores worktrees that are currently in use, leased, or reserved by another lifecycle operation.
It skips idle worktrees that are unsafe to remove and prints the skip reason, such as uncommitted tracked or untracked changes, or a HEAD commit that is not merged into the default branch.
Skip output is grouped by reason so large global sweeps stay scannable.
When `origin` exists, prune fetches it and proves each HEAD against the current remote default branch tracking ref.
Expand Down Expand Up @@ -223,6 +249,7 @@ pre_destroy = ["./scripts/teardown.sh"]
```

- `post_create` runs after a worktree is provisioned or reset and right before `treehouse get` hands it to you.
For `treehouse get --lease`, stdout from `post_create` is routed to stderr so stdout remains the leased path.
- `pre_destroy` runs before a worktree is removed by `treehouse destroy`, `treehouse destroy --all`, or prune deletion commands such as `treehouse prune --yes` and `treehouse prune --prune-orphans --yes`.

Commands in each list run sequentially in the worktree directory, via the OS shell (`/bin/sh -c` on Linux/macOS, `%COMSPEC% /c` on Windows).
Expand Down
Loading
Loading