diff --git a/Cargo.lock b/Cargo.lock
index 0fd58bc..ff73908 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -537,6 +543,17 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
+[[package]]
+name = "flate2"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+ "zlib-rs",
+]
+
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1740,6 +1757,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "miniz_oxide"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
[[package]]
name = "nix"
version = "0.31.3"
@@ -2223,6 +2250,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
[[package]]
name = "slab"
version = "0.4.12"
@@ -2303,6 +2336,16 @@ dependencies = [
"windows",
]
+[[package]]
+name = "tar"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
+dependencies = [
+ "filetime",
+ "libc",
+]
+
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -2529,6 +2572,7 @@ version = "0.1.2"
dependencies = [
"chrono",
"fd-lock",
+ "flate2",
"humantime",
"nix",
"owo-colors",
@@ -2537,11 +2581,13 @@ dependencies = [
"serde_json",
"sha2",
"sysinfo",
+ "tar",
"tempfile",
"thiserror",
"toml",
"tru",
"windows-sys 0.61.2",
+ "zip",
]
[[package]]
@@ -2561,6 +2607,12 @@ dependencies = [
"vergen-gix",
]
+[[package]]
+name = "typed-path"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
+
[[package]]
name = "typenum"
version = "1.20.1"
@@ -2965,6 +3017,20 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "zip"
+version = "8.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
+dependencies = [
+ "crc32fast",
+ "flate2",
+ "indexmap",
+ "memchr",
+ "typed-path",
+ "zopfli",
+]
+
[[package]]
name = "zlib-rs"
version = "0.6.7"
@@ -2976,3 +3042,15 @@ name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+
+[[package]]
+name = "zopfli"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
diff --git a/Cargo.toml b/Cargo.toml
index cfc46ca..9f7d868 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,6 +23,16 @@ owo-colors = { version = "4.3.0", features = ["supports-colors"] }
rand = "0.9"
humantime = "2.1.0"
sha2 = "0.10"
+# Release-asset pipeline behind `treehouse update` (crates/treehouse-core/src/updater.rs):
+# gzip + tar for the unix assets the release workflow builds, zip for the Windows
+# asset. Both are declared with default features OFF — the workflow produces
+# plain DEFLATE tar.gz / zip, and the default feature sets drag in xattr (tar)
+# and aes-crypto/zstd/xz/lzma/bzip2/ppmd (zip) that we never call. `zip` is NOT
+# target-gated to Windows: gating it would leave the extraction path unbuildable
+# and therefore untested on the Linux/macOS CI that actually runs the suite.
+flate2 = "1.1.10"
+tar = { version = "0.4.46", default-features = false }
+zip = { version = "8.6.0", default-features = false, features = ["deflate"] }
# TOON encoder (toon_rust). Package name is `tru` (v0.2.2), lib name `toon` —
# imports use `use toon::...`. MSRV 1.88, edition 2024, license MIT +
# OpenAI/Anthropic Rider (NOT plain MIT — see docs/rust-port-plan.md §3/§5.4).
diff --git a/MISSING.md b/MISSING.md
new file mode 100644
index 0000000..1e61ab6
--- /dev/null
+++ b/MISSING.md
@@ -0,0 +1,1352 @@
+# MISSING.md — Gap audit: Rust port vs Go upstream
+
+**Ngày audit:** 2026-10-01
+**Đối tượng:** `treehouse_rust` @ `main` (v0.1.2, commit `c3e587c`)
+**Baseline đối chiếu:** `kunchenguid/treehouse` @ v3.1.0 (commit `cb7fb26`), clone tại `/tmp/th-baseline/treehouse`
+**Baseline mà port plan tự nhận:** Go **v2.1.1** (`docs/rust-port-plan.md:5`)
+
+---
+
+## 0. Đọc file này
+
+Audit chạy bằng workflow 67 agent: 6 agent theo domain đọc song song cả hai repo, sau đó **mọi finding đều qua 2 lens đối kháng** (một lens kiểm tra tồn tại, một lens kiểm tra impact) trước khi giữ lại.
+
+- **29 finding đã verify** (4 CRITICAL / 11 HIGH / 14 MEDIUM) — phần chính của file này.
+- **1 finding bị refute** — xem §7.
+- **54 lead chưa verify** — severity thấp hơi ngưỡng, xem §8. Coi là **gợi ý, không phải kết luận**.
+
+**Bucket** phân loại gốc rễ:
+
+| Bucket | Nghĩa | Ai chịu trách nhiệm |
+|---|---|---|
+| `P0_PARITY` | Go baseline v2.1.1 **đã có**, Rust không tương đương | Port bug — đây là hợp đồng của chính plan |
+| `UPSTREAM_DRIFT` | Upstream thêm **sau** v2.1.1, Rust chưa có | Lag hợp lệ, không phải bug |
+| `BUG_RUST` | Rust có khái niệm nhưng implement sai | Bug port |
+| `P1_UPGRADE` | Chính `docs/rust-port-plan.md` đã hứa | Nợ triển khai |
+
+**ID** `M-0NN` là số thứ tự nên làm. Thứ tự trong file = thứ tự đề xuất fix.
+
+---
+
+## 1. Verdict
+
+Port **đúng ở pool core**: layout thư mục pool, `short_hash`, `next_name`, `owner_alive` chống PID-reuse, `ClassSet`/`missing_flags`, two-phase destroy reservation + `restore_original`, và lock 10s (`lock.rs:25,61-74` — đã deliver đúng promise P1-A của plan). **Phần này đừng đụng vào.**
+
+Nhưng port **fail-open đúng ở những chỗ nguy hiểm nhất**. Điểm nặng nhất: `confirm()` trả `true` khi stdin EOF, nên `treehouse return
< /dev/null` **hard-reset và xoá sạch thay đổi chưa commit, exit 0** — trong khi Go từ chối.
+
+**Port đã trễ 6 release** (v2.2.0 → v3.1.0), trong đó v3.0.0 chiếm 12/16 mục drift. Nhưng các mục drift **không độc lập**: 6 phụ thuộc cùng một thứ là **VCS seam** mà Go đã tách ra. Đó là **một lần viết lại, không phải sáu gap** — xem M-028.
+
+**Cái đáng sửa đầu tiên:** M-001.
+
+---
+
+## 2. Bảng triage
+
+| ID | Sev | Bucket | Vấn đề |
+|---|---|---|---|
+| M-001 | CRITICAL | P0_PARITY | Make the dirty-return confirmation fail closed on stdin EOF instead of defaulting to yes |
+| M-002 | CRITICAL | UPSTREAM_DRIFT | Refuse to reset/detach a pool slot that has lost its .git marker |
+| M-003 | HIGH | BUG_RUST | Refresh the process table before every scan; never trust a pool-lifetime snapshot for safety decisions |
+| M-004 | HIGH | BUG_RUST | Re-scan with a live process table after termination and fail closed when the scan errors |
+| M-005 | HIGH | BUG_RUST | Stop swallowing unknown subcommands into `run`, and release the lease when child spawn fails |
+| M-006 | HIGH | P0_PARITY | Reject `destroy --all` with no pool path instead of silently sweeping the current repository's pool |
+| M-007 | HIGH | P0_PARITY | A treehouse.toml that omits `max_trees` is a hard parse error instead of keeping the default 16 |
+| M-008 | HIGH | BUG_RUST | Global `--env-path` flag is a no-op — the pool still lands in `~/.treehouse` |
+| M-009 | HIGH | P0_PARITY | prune's execute phase re-verifies only the reservation, never re-classifies, so a slot that dirtied between plan and delete is destroyed |
+| M-010 | HIGH | P0_PARITY | No parent-directory fsync after rename — a crash can lose the state file entirely on ext4/XFS |
+| M-011 | MEDIUM | P0_PARITY | Return phase-1 skips from execute_destroy instead of dropping them |
+| M-012 | CRITICAL | UPSTREAM_DRIFT | Pool slot reuse has no clone-identity scoping, so a second clone of the same origin gets another clone's worktree reset |
+| M-013 | CRITICAL | UPSTREAM_DRIFT | acquire reclaims a clean slot holding unlanded commits without any merge/HEAD safety check |
+| M-014 | HIGH | UPSTREAM_DRIFT | return/get never terminate lingering processes, so a slot is reset while a writer is still in it |
+| M-015 | HIGH | UPSTREAM_DRIFT | No `recoverMissingStateEntries`: a missing state file leaves on-disk worktrees invisible, so `next_name` reissues an occupied slot name |
+| M-016 | MEDIUM | UPSTREAM_DRIFT | Resolve `return ` as a slot name, not only as a filesystem path |
+| M-017 | MEDIUM | UPSTREAM_DRIFT | Add `return --all` to reclaim every held worktree in a pool |
+| M-018 | MEDIUM | P1_UPGRADE | Honor `--format json|toon` on destroy/prune/return/gc instead of silently printing human text |
+| M-019 | MEDIUM | UPSTREAM_DRIFT | Emit exit status 3 when a dirty worktree is left unreturned (`get` and `return`) |
+| M-020 | MEDIUM | UPSTREAM_DRIFT | Prune and destroy resolve one repo root for the whole pool instead of per-worktree, so a shared pool corrupts other clones' worktrees |
+| M-021 | MEDIUM | UPSTREAM_DRIFT | acquire has no markerless-slot fail-closed: a slot whose .git is gone is read through the enclosing repository |
+| M-022 | MEDIUM | UPSTREAM_DRIFT | Corrupt-state recovery aborts the entire pool on ONE unreadable slot, instead of recovering the healthy slots around it |
+| M-023 | MEDIUM | UPSTREAM_DRIFT | Restore the global `--root` flag and the `TREEHOUSE_ROOT` environment variable |
+| M-024 | MEDIUM | UPSTREAM_DRIFT | Restore the seven acquisition flags on `get` and the matching AcquireOptions fields |
+| M-025 | MEDIUM | P0_PARITY | No background update check is ever spawned, so the cache is never written and the notice never fires |
+| M-026 | MEDIUM | UPSTREAM_DRIFT | Implement the RequireOwnedByCaller / RequireUnleased / RefuseRecovered release preconditions |
+| M-027 | HIGH | P0_PARITY | `treehouse update` never downloads or replaces the binary but reports success |
+| M-028 | ARCH | — | **VCS seam** — tách backend, mở đường cho jj / APFS / seeding / unique-leaf |
+
+**Quy mô:** 4 CRITICAL · 11 HIGH · 14 MEDIUM — theo bucket: 8 `P0_PARITY`, 4 `BUG_RUST`, 16 `UPSTREAM_DRIFT`, 1 `P1_UPGRADE`.
+
+---
+
+## 3. ⚠️ Cần bạn quyết trước khi ai sửa
+
+Ba mục dưới đây **làm đổi hành vi quan sát được** của command đã release. Không phải bug — nhưng cũng không phải fix an toàn mặc định.
+
+**M-019 — exit code 3 khi worktree dirty chưa được return.**
+Go ship nó là BREAKING CHANGE ở v3.0.0 với lý do: *"Callers that read exit 0 as 'the slot was released' were reading the bug this fixes; scripts under `set -e`, or using `treehouse return "$p" && next-step`, will now stop or branch differently at a dirty abort."*
+Lưu ý mâu thuẫn: `docs/rust-port-plan.md` Appendix B đang ghi **exit 0 là chuẩn**. Làm M-019 nghĩa là **override chính contract của plan**.
+→ Đề xuất: release-note entry + `TREEHOUSE_EXIT_STRICT=1` opt-in trong một minor.
+
+**M-018 — wiring `--format json|toon` cho destroy/prune/return/gc.**
+Hiện tại `--format json` trên các lệnh đó **parse được nhưng in ra prose**. Wiring nó sẽ đổi output cho bất kỳ ai đang truyền flag. Rủi ro thấp (không ai parse prose), nhưng vẫn là behavior change.
+→ Hoặc wire thật, hoặc bỏ `global = true` để tổ hợp không hỗ trợ **fail loud**.
+
+**M-012 — clone-identity scoping.**
+Pool key theo hash của `remote_url`, root mặc định `$HOME/.treehouse` ⇒ **hai clone cùng origin dùng chung pool**. Đây là điều khiến các script chạy song song ở nhiều clone trở nên nguy hiểm. Nhưng nếu chưa ai dùng multi-clone thì có thể defer.
+
+---
+
+## 4. Tier 0 — vá nhỏ, không cần quyết định thiết kế
+
+> Không dependency mới, không đổi hợp đồng CLI. 12 patch, mỗi cái vài dòng.
+
+### M-001 — Make the dirty-return confirmation fail closed on stdin EOF instead of defaulting to yes
+
+| | |
+|---|---|
+| **Severity** | CRITICAL |
+| **Bucket** | **P0_PARITY** — port bug: Go baseline v2.1.1 đã có, Rust không tương đương |
+| **Domain** | CLI surface — output routing / confirmation prompts |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> Verified by running the built binary: a worktree with `M a.txt` holding `PRECIOUS UNCOMMITTED DATA` was hard-reset and returned by `treehouse return < /dev/null` with EXIT=0; `git status --porcelain` came back empty and a.txt reverted to `hi`. Every non-TTY caller — CI, a Makefile, a hook, an agent script, a cron job — that runs `treehouse return "$p"` on a dirty worktree silently destroys uncommitted work and reports success. Go refuses, leaves the slot dirty, and exits 3 telling the caller to use `--force`. This is unrecoverable data loss on the tool's single most destructive happy path.
+
+**Rust — hiện trạng.**
+
+> crates/treehouse/src/main.rs:721-728 confirm(): `std::io::stdin().read_line(&mut input)?; let input = input.trim().to_lowercase(); Ok(input.is_empty() || input == "y" || input == "yes")` — a 0-byte read at EOF leaves input empty and returns true, discarding the fact that it was an EOF. Consumed at main.rs:168 (get) and main.rs:227 (return).
+
+**Go — tham chiếu.**
+
+> cmd/return_cmd.go:399-418 confirmWorktreeReturn: `ok, err := ui.Confirm("Worktree has uncommitted changes. Clean and return?", true); if err != nil { return errReturnAbortedNonTTY }; if !ok { return errReturnAborted }` — the caller treats ANY read error as an abort, ignoring the bool. internal/ui/prompt.go:46-50: `line, err := promptReader().ReadString('\n'); input := strings.TrimSpace(...); if err != nil && input == "" { return defaultYes, err }` — the error is propagated, not swallowed.
+
+**Cách sửa.**
+
+> Change `confirm` to return `Result>` (or add a distinct Err) and propagate io::Error from `read_line`; on `Err`/`Ok(0)` return `Err` so main.rs:227 takes the Go `errReturnAbortedNonTTY` branch (abort + exit 3 + `--force` hint). Keep empty-without-error (bare Enter) meaning "default yes".
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> VERIFIED — I attempted to refute and could not. Both citations are accurate, and the finding is real.
+>
+> GO SIDE CONFIRMED. /tmp/th-baseline/treehouse/cmd/return_cmd.go:399-418 matches the quoted confirmWorktreeReturn verbatim; internal/ui/prompt.go:46-50 matches verbatim. The caller checks `err != nil` BEFORE the bool, so any read error aborts and the bool is ignored.
+>
+> THE CLAIM IS STRONGER THAN STATED — it holds at v2.1.1, the port plan's declared baseline, which makes it P0_PARITY and not UPSTREAM_DRIFT. At v2.1.1 Go is STRICTER: `git show v2.1.1:internal/ui/prompt.go:19-22` propagates on ANY read error (`if err != nil { return defaultYes, err }`), and `git show v2.1.1:cmd/return_cmd.go:104-107` combines the checks (`if err != nil || !ok`). The v2.1.1 contract the plan claims to preserve already failed closed on EOF.
+>
+> RUST SIDE CONFIRMED, and the mechanism is worse than "discards the error." The `?` at main.rs:725 can NEVER fire for EOF. I compiled a standalone repro: at EOF `std::io::stdin().read_line()` returns Ok(0), NOT Err. So the `?` is dead code, `input` stays empty, and main.rs:727 evaluates `input.is_empty()` -> true -> Ok(true). Verified empirically, not from memory.
+>
+> NO GUARD UNDER ANY OTHER NAME. Grepped all of crates/ for eof|non.?tty|not a terminal|is_terminal|isatty|atty: zero hits. Exactly one stdin() call site exists in the entire workspace (main.rs:725), so no alternate read path exists. Both consumers confirmed at main.rs:168 (get) and main.rs:227 (return). No test covers confirm().
+>
+> IMPACT IS REAL DATA LOSS, not a wrong exit code. confirm()=true falls through to pool.release() -> release_conditional (pool.rs:357-359) -> reset_worktree (shell.rs:339-349), which runs `git checkout --detach --force`, `git reset --hard`, and `git clean -fd`. Uncommitted changes AND untracked files destroyed irreversibly. Go protects this with a dedicated sentinel (return_cmd.go:29), a distinct exit code ExitNotReturned, and an explicit "worktree not returned ... uncommitted changes were kept" message (return_cmd.go:120-122). Rust silently wipes and prints "Worktree returned to pool."
+>
+> BUCKET CORRECTION (BUG_RUST -> P0_PARITY): the v2.1.1 evidence above places this squarely in P0_PARITY ("behavior the Go v2.1.1 baseline already had; Rust never matched it"). The distinct errReturnAbortedNonTTY exit code and its dedicated message are a later v3.x refinement and would be separate, lower-severity drift — but the fail-closed-on-EOF behavior itself is baseline. Severity CRITICAL stands: irreversible destruction of uncommitted work with no confirmation and no warning.
+
+
+
+---
+
+### M-002 — Refuse to reset/detach a pool slot that has lost its .git marker
+
+| | |
+|---|---|
+| **Severity** | CRITICAL |
+| **Bucket** | **UPSTREAM_DRIFT** — upstream thêm sau v2.1.1, Rust chưa có (lag hợp lệ) |
+| **Domain** | VCS / destructive-op safety |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> A pool slot whose `.git` marker was deleted, or which was never a real worktree, can have git walk up to the parent repository and hard-reset it. `reset_worktree` resolves `repo_root` via `rev-parse --show-toplevel` and, when that fails, silently falls back to `worktree.to_path_buf()` and proceeds with `checkout --detach --force` / `reset --hard` / `clean -fd` — no marker check at any point. Because in-project pools are a supported Rust configuration (config.rs:160-177 nests the pool under the repo when `root` is relative), the enclosing repository in that case is the user's actual working tree, and `reset --hard` + `clean -fd` there destroys uncommitted work. This is the highest-severity item in this domain. Note the guard is a v2.3.0 upstream fix rather than v2.1.1 behavior, so it is classified as drift — but the consequence in Rust is real today.
+
+**Rust — hiện trạng.**
+
+> ABSENT — `grep -rn "marker"` across crates returns 4 hits, none of them a guard (two are an unrelated stdout marker in main.rs:815-829, one is a doc comment, one is a state-recovery constant). `reset_worktree` (crates/treehouse-core/src/git/shell.rs:329-351) and `detach_worktree` (shell.rs:353-359) run `checkout`/`reset --hard`/`clean -fd` with cwd set to the worktree and no marker validation. `release_conditional` (crates/treehouse-core/src/pool.rs:323-377) calls `reset_worktree` at pool.rs:357-359 with only a lock-held reservation as protection.
+
+**Go — tham chiếu.**
+
+> vcs.go:513-525 `destructiveBackendForWorktree` returns `fmt.Errorf("refusing to modify %s: it holds no .git or .jj marker", path)` when `slotMarkerBackend` yields nil, with the rationale at vcs.go:513-519: falling back to the configured backend "in an in-project pool would resolve — and rewrite — the repository ENCLOSING the pool". Wrapped around every destructive entry point: `ResetWorktree` (vcs.go:531-537), `ResetWorktreeWithSeededPaths` (540-546), `ResetWorktreeToRef` (562-568), `ResetWorktreeToRefWithSeededPaths` (571-577), `DetachWorktree` (587-593). The commit that added it is c88b53e (v2.3.0, "markerless fail-closed hardening", #110).
+
+**Cách sửa.**
+
+> In `GitBackend::reset_worktree` and `detach_worktree` (or better, in a `Pool`-level guard mirroring Go's `destructiveBackendForWorktree`), `lstat` `/.git` before running any destructive git command and return `GitErrorKind::Other` with Go's message when it is absent or its `gitdir:` target does not resolve. The check is a pure precondition and needs no new plumbing.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> Could not refute; the gap is real and the citations are exact. GO: `destructiveBackendForWorktree` at vcs.go:520-525 emits the fail-closed error at vcs.go:524, backed by `slotMarkerBackend` (vcs.go:444-453) and `WorktreeBackendNameChecked` (vcs.go:459-480), and it wraps all five destructive entry points at exactly the cited lines (531-537, 540-546, 562-568, 571-577, 587-593). BUCKET UPSTREAM_DRIFT confirmed: `git show v2.1.1:internal/vcs/vcs.go` contains no `destructiveBackendForWorktree`/`refusing to modify`, so the guard postdates the plan's stated baseline (docs/rust-port-plan.md:5); c88b53e is listed at CHANGELOG.md:57 under `## [2.3.0]` (line 53) and `git tag --contains c88b53e` returns v2.3.0/v3.0.0/v3.0.1/v3.1.0. RUST: aggressive synonym grep across all of crates/ (`marker`, `slot_marker`, `flavor`, `worktree_backend`, `destructive`, `guard`, `refuse`, `jj` case-insensitive) yields no guard; `jj` returns literally nothing, so the port is git-only — but that does not neutralize the bug. `reset_worktree` (shell.rs:329-351) runs checkout/reset --hard/clean -fd with cwd = the worktree; `releasable_worktree` (pool.rs:598-614) checks only state membership, `destroying`, and lease identity; `heal_state` (state.rs:272-275) drops entries whose path vanished, so a markerless-but-present slot stays in the pool. SEVERITY CRITICAL confirmed and reachability is broader than the claim states: in-project pools are supported (config.rs:178 `repo_root.join(expanded).join(".treehouse")`), so `git` with cwd = markerless slot walks up to the ENCLOSING repo, where `reset --hard` discards uncommitted tracked changes and `clean -fd` deletes untracked files in the user's real repository — data loss per the stated rubric. The finder cited only pool.rs:357-359 (release); a second, more reachable site exists at pool.rs:221-223 (acquire step 3), reachable because `is_dirty` at pool.rs:490 also resolves to the enclosing repo and reports it clean, marking the slot available. Refutation attempts that failed: (a) destroy.rs:220 `backing_repository_missing` reads like a marker guard but returns false when `.git` is entirely absent (destroy.rs:222-224) — the opposite of fail-closed — and is a destroy-classification input, not a reset guard; (b) state.rs:232 is a marker check but only in the corrupt-state recovery scan; (c) Go's caller-level pre-gate `IsWorktreeSafeToReset` (vcs.go:582) has no Rust counterpart at all. Note also that Rust has no `reset_to_ref`/`seeded_paths` equivalents, so four of the five guarded Go entry points have no Rust function to guard in the first place.
+
+
+
+---
+
+### M-003 — Refresh the process table before every scan; never trust a pool-lifetime snapshot for safety decisions
+
+| | |
+|---|---|
+| **Severity** | HIGH |
+| **Bucket** | **BUG_RUST** — Rust có khái niệm nhưng implement sai so với Go |
+| **Domain** | Safety invariants — process detection |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> Every in-use / owner-liveness decision in the port reads a process snapshot taken when `Pool::open` ran. Two distinct data-loss consequences: (1) DESTROY DELETES A LIVE WORKTREE — `classify_for_destroy` (destroy.rs:173-215) sees no processes for any PID that started after pool open, so a worktree with a running agent is classified `Disposable` and the two-phase protocol deletes it. (2) A LIVE OWNER'S RESERVATION IS SILENTLY HEALED AWAY — `owner_alive` returns false for any PID absent from the stale snapshot, so `heal_state` (called at destroy.rs:298, pool.rs:229, pool.rs:398) zeroes `owner_pid`/`owner_started_at` and clears `Destroying` on a worktree an agent is actively using, handing the slot to a concurrent acquire/destroy. Verified empirically: a table built before a child existed reported 0 processes in that child's cwd; the same table after `refresh()` reported 2. The port's own doc comment (process.rs:33-37) claims "re-enumerate on demand"; it never does.
+
+**Rust — hiện trạng.**
+
+> crates/treehouse-core/src/process.rs:43-62 (`ProcessTable::new()` performs the ONE and ONLY `refresh_processes_specifics`); crates/treehouse-core/src/process.rs:65-72 (`refresh()` exists but has ZERO callers — `grep -rn refresh crates/ | grep -v src/process.rs` → 0 hits); crates/treehouse-core/src/pool.rs:97 (`process: Arc` held for the Pool's lifetime), built once at crates/treehouse-core/src/pool.rs:127 and :157; every consumer reads the frozen snapshot: crates/treehouse-core/src/destroy.rs:174-178, crates/treehouse-core/src/destroy.rs:412-414, crates/treehouse-core/src/pool.rs:407-410; crates/treehouse-core/src/reservation.rs:151-159 (`owner_alive` → `process.started_at(pid)`) and crates/treehouse-core/src/state.rs:272-286 (`heal_state`)
+
+**Go — tham chiếu.**
+
+> internal/process/detect.go:45 (`procs, err := process.Processes()` — fresh enumeration on every call); internal/process/detect.go:33-40 (`StartedAt` → `process.NewProcess(pid)` + `proc.CreateTime()`, a live syscall per call, never cached)
+
+**Cách sửa.**
+
+> Call `self.process.refresh()` at the top of `find_in_worktree`, `started_at`, and `parent_pid` (or once at the head of `classify_for_destroy`, `status`, and each `execute_destroy` phase). Cheapest correct change: have `Pool::destroy`/`status` call `self.process.refresh()` before each classification sweep, since the table is per-Pool and each command does a bounded number of scans.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> CONFIRMED, severity downgraded CRITICAL -> HIGH, bucket BUG_RUST upheld.
+>
+> Both cited Go lines are accurate. internal/process/detect.go:45 does a fresh `process.Processes()` enumeration on every call, and detect.go:33-40 does a live `process.NewProcess(pid)` + `proc.CreateTime()` syscall per invocation. Go has NO process-table/caching object at all — every caller re-enumerates (internal/pool/destroy.go:309, internal/pool/prune.go:596,611,636, internal/pool/pool.go:502,1405,1429,1441, internal/process/terminate.go:23,52).
+>
+> Rust DID introduce a caching object and never refreshes it. ProcessTable::new() (process.rs:43-62) is the sole enumeration; refresh() (process.rs:65-72) is dead code — the token "refresh" appears NOWHERE in crates/ outside process.rs itself, and every production ProcessTable::new() is either pool construction (pool.rs:127, pool.rs:157) or inside #[cfg(test)] (destroy.rs:540, reservation.rs:189-283, process.rs:371-395). The Arc is held for the Pool's lifetime (pool.rs:97). So the core factual claim is correct.
+>
+> WHY NOT CRITICAL — the refutation: the finding implies a long-lived daemon acting on a permanently frozen table. That is not what happens. sweep_pools (discovery.rs:202-208) invokes ctx_factory PER POOL PER CYCLE, and sweep_all_pools (main.rs:395-435) defines that factory as Pool::open_at. Therefore `watch --interval` constructs a brand-new Pool — and a brand-new ProcessTable — on every single sweep cycle. The staleness window is one pool's sweep duration, not the daemon's lifetime. Go's TOCTOU window is microseconds (classify->remove); Rust's is one command duration. That is a materially widened race, not deterministic data loss, so CRITICAL is not supportable.
+>
+> WHAT I FOUND THAT IS STRONGER THAN THE CLAIM (keeps it real, at HIGH): the frozen table makes `destroy --include-in-use` deterministically fail, not merely racy. After terminate_with_grace (destroy.rs:392-394), the post-kill re-validation at destroy.rs:411-414 re-reads the SAME frozen snapshot, which still lists the just-killed pids, so `!is_empty()` is always true and destroy always skips with "worktree processes still running after termination". The identical defect neuters CleanupGuard::run() (run.rs:170-175): cmd_run opens the pool at main.rs:786 BEFORE the child is spawned, then blocks in wait_child (run.rs:86) for the child's entire (potentially hours-long) lifetime, so the termination step operates on a table enumerated before the child ever existed and can never see the agent or its descendants.
+>
+> BUCKET: BUG_RUST, not P0_PARITY. The distinguishing test is the fix shape — P0_PARITY means "add a capability the baseline had"; here refresh() already exists and is correctly implemented, only the wiring is absent, so the fix is to call self.refresh() before scans. Note also that `run` has no Go counterpart at all (Go has no cmd/run.go; no "run" command exists upstream), so the run-path manifestation is a Rust-only-feature defect rather than a parity gap.
+>
+> Confidence HIGH: I read both trees and grepped exhaustively for every synonym (refresh/refresh_all/refresh_processes/RefreshKind/sysinfo) across all of crates/.
+
+
+
+---
+
+### M-004 — Re-scan with a live process table after termination and fail closed when the scan errors
+
+| | |
+|---|---|
+| **Severity** | HIGH |
+| **Bucket** | **BUG_RUST** — Rust có khái niệm nhưng implement sai so với Go |
+| **Domain** | Safety invariants — process termination |
+| **Confidence** | HIGH |
+| **Verify** | 1/2 lens đồng ý (đã sửa bucket) |
+
+**Triệu chứng / tác động.**
+
+> Two compounding failures on the only gate between "we killed the processes" and "we delete the directory". (a) Because the re-scan re-reads the pre-kill snapshot, the just-SIGKILLed PIDs are still present, so `--include-in-use` destroy can NEVER remove a worktree that actually had processes — it always skips with "worktree processes still running after termination". The opt-in flag is silently a no-op. (b) If the scan returns Err, `unwrap_or_default()` yields `vec![]`, `is_empty()` is true, and the code proceeds straight to `worktree_remove` + `remove_dir_all` — deleting a worktree whose liveness could not be established. Go refuses in exactly that case.
+
+**Rust — hiện trạng.**
+
+> crates/treehouse-core/src/destroy.rs:396-399 (`terminate_with_grace` scans and kills) followed by crates/treehouse-core/src/destroy.rs:412-417 (`find_in_worktree(...).unwrap_or_default().is_empty()` — the SAME frozen snapshot; no `refresh()` anywhere in between). The `unwrap_or_default()` also swallows a scan error into an empty vec, i.e. FAIL-OPEN where Go fails closed.
+
+**Go — tham chiếu.**
+
+> internal/pool/destroy.go:504-510 (`findProcessesInWorktree` is a FRESH gopsutil enumeration; on error it restores the reservation, sets Detail "could not verify worktree processes stopped", and SKIPS — never deletes); internal/pool/destroy.go:511-517 (non-empty survivors ⇒ restore + skip)
+
+**Cách sửa.**
+
+> Refresh the table immediately before the survivor re-scan, and replace `.unwrap_or_default().is_empty()` with a `match` that restores the reservation and skips on `Err` — mirroring destroy.go:504-510. Fixing the table staleness (finding 1) resolves (a); (b) needs the explicit error branch.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> CONFIRMED on both sides; only the severity label is overstated.
+>
+> VERIFIED GO: destroy.go:498 calls terminateWorktreeProcesses, then :504 calls findProcessesInWorktree(path); on error it restores the reservation, sets Detail "could not verify worktree processes stopped" (:506) and skips — never deletes. :511 non-empty survivors likewise restore + skip. That function is process.FindProcessesInWorktree (destroy.go:52), which calls process.Processes() at detect.go:44-46 — a FRESH gopsutil enumeration per call. I confirmed via `git show v2.1.1:internal/pool/destroy.go` that this block existed at lines 451-460 in the stated v2.1.1 baseline, so it is NOT upstream drift.
+>
+> VERIFIED RUST: destroy.rs:396-399 terminates, :412-417 re-checks with find_in_worktree(...).unwrap_or_default().is_empty(). Both calls route into ProcessTable::find_in_worktree (process.rs:77-112), which iterates self.system — a Mutex populated once in ProcessTable::new() (process.rs:51-61).
+>
+> DECISIVE PROOF: ProcessTable::refresh() is defined at process.rs:65 and has ZERO callers — `grep -rn "\.refresh()"` across crates/ and tests/ returns nothing, so it is dead code. sysinfo prunes exited processes only inside refresh_processes_specifics(..., remove_dead_processes=true, ...) (system.rs:377-378), so SIGKILLed PIDs persist in the cached map forever. The re-scan provably reads the same frozen snapshot where Go re-enumerates. The safety invariant "verify processes stopped before deleting the worktree" is genuinely non-functional.
+>
+> CORRECTION TO THE CLAIM'S REASONING: the unwrap_or_default() fail-open is real in code shape but near-unreachable — find_in_worktree returns Err only when absolute_and_resolve returns None (process.rs:78-79), which requires std::env::current_dir() to fail on a RELATIVE path (process.rs:259-268); state paths are absolute. The dominant defect is the frozen snapshot, not the error swallow.
+>
+> WHY HIGH, NOT CRITICAL: the deterministic outcome of the stale snapshot is FAIL-SAFE, not destructive. Processes alive at pool-open are in the map, get SIGKILLed, still remain in the stale map, so the survivors check always trips and destroy --include-in-use always skips — the feature is broken but never deletes the wrong worktree. The data-loss direction (a process started AFTER ProcessTable::new(), invisible to every Rust scan, followed by remove_dir_all_guarded at destroy.rs:436) requires a race, most plausibly a pre-destroy hook spawning a background process; that hook runs outside the lock at destroy.rs:338-350, after the snapshot. A wrong-worktree-deleted path therefore exists but is a narrow race, not the common case — "safety invariant missing / core command broken" = HIGH.
+
+
+
+---
+
+### M-005 — Stop swallowing unknown subcommands into `run`, and release the lease when child spawn fails
+
+| | |
+|---|---|
+| **Severity** | HIGH |
+| **Bucket** | **BUG_RUST** — Rust có khái niệm nhưng implement sai so với Go |
+| **Domain** | CLI surface — command dispatch |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> Verified: `treehouse statuss` printed `io: spawning command "statuss"` and exited 1 — but slot 4 had already been acquired and durably leased with holder `run:30130`, and `treehouse status` still showed `4 leased (held by run:30130)`. Same for `treehouse lease 1` (the unported Go subcommand): it leased slot 1 then failed to spawn. Every typo or not-yet-ported verb permanently burns a pool slot (24h TTL, never reclaimable while the lease is valid, and `get` refuses to hand out a leased slot), so a handful of mistakes starves a 16-slot pool with no way to see why beyond `status`.
+
+**Rust — hiện trạng.**
+
+> crates/treehouse/src/cli.rs:63-65 `#[command(external_subcommand)] Run(Vec)` — clap routes ANY unmatched subcommand (including a bare typo) into `Run`. crates/treehouse/src/main.rs:740-807 cmd_run acquires a lease, then crates/treehouse-core/src/run.rs:65-68 does `pool.get(&AcquireOptions{ lease: Some(..), .. })` and run.rs:75 does `let child = spawn_child(...)?;` — the `?` returns BEFORE the `CleanupGuard` is constructed at run.rs:78, so the just-taken lease has no owner to release it.
+
+**Go — tham chiếu.**
+
+> cmd/root.go:24-58 declares rootCmd and `init()` registers exactly nine commands (get.go:105, enter.go:39, return_cmd.go:144, status.go:155, prune.go:104, destroy.go:70, lease.go:45, init.go:67, update.go:42). There is no `external_subcommand`/catch-all anywhere in cmd/*.go, so cobra rejects an unknown verb with `unknown command "statuss" for "treehouse"` and acquires nothing.
+
+**Cách sửa.**
+
+> Two changes. (1) In run.rs, construct the CleanupGuard immediately after the acquire, before spawn_child, so a spawn failure unwinds through Drop. (2) Replace `external_subcommand` with a real `run` subcommand carrying `#[arg(last = true)] cmd: Vec` (or `trailing_var_arg`), so clap errors on unknown verbs the way cobra does — and restore the Go-rejection path rather than silently leasing.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> Could not refute — both halves verified by reading and by running both binaries. (1) cli.rs:64-65 attaches #[command(excommand)]-style clap external_subcommand to Run(Vec), so clap routes any unmatched verb into cmd_run. Rust's own --help omits `run` entirely, proving it is only a catch-all. Built the Go baseline (cb7fb26) and ran both on the same input: Go prints `unknown command "statuss" for "treehouse"` + `Did you mean this? status` and acquires nothing (cobra legacyArgs, no catch-all among the nine AddCommand registrations); Rust prints `io: spawning command "statuss"` after having already acquired a worktree. (2) run.rs:65 takes the lease, run.rs:75 does `spawn_child(...)?` which returns BEFORE CleanupGuard is constructed at run.rs:78. I captured the resulting on-disk state: leased:true, lease_holder:"run:71664" (dead pid), expires_at +24h — a leaked worktree + lease. This breaks the file's own documented invariant (run.rs:2-8 "cleaned up on EVERY exit path") and plan section 6.5 / P1-D "cleanup ALWAYS". Correcting severity from CRITICAL to HIGH: the CRITICAL band is data loss / wrong worktree deleted / silent corruption / security; none apply. Cleanup never runs, so nothing is deleted or corrupted, and the lease is TTL-bounded and gc-reclaimable (plan 6.5: "a lease can never block a pool slot longer than TTL + next gc sweep"). This is a missing safety invariant plus a wrong exit path on a typo — the HIGH band. Minor citation drift: the finder cited status.go:155 and return_cmd.go:144; the actual AddCommand lines are status.go:154 and return_cmd.go:143.
+
+
+
+---
+
+### M-006 — Reject `destroy --all` with no pool path instead of silently sweeping the current repository's pool
+
+| | |
+|---|---|
+| **Severity** | HIGH |
+| **Bucket** | **P0_PARITY** — port bug: Go baseline v2.1.1 đã có, Rust không tương đương |
+| **Domain** | CLI surface — safety guards |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> `treehouse destroy --all --yes` run from any repository now wipes that repository's entire pool. In Go this is a hard error that forces the operator to name the pool, which is the last stop before a mass deletion. Removing it removes the only confirmation that the target is deliberate — a muscle-memory `destroy --all --yes` from the wrong directory deletes the wrong pool.
+
+**Rust — hiện trạng.**
+
+> crates/treehouse/src/main.rs:579-583 `let spec = if all { DestroyTargetSpec::All } else { DestroyTargetSpec::Single(args.path.clone().unwrap()) };` — `args.path` is `Option` (cli.rs:202) and `All` is built with no pool argument; main.rs:571-577 only rejects `--all` when a path IS given and looks like a worktree. Verified: `treehouse destroy --all` printed a dry-run enumeration of `/Users/…/.treehouse/th-probe2-0b9ac1` and exited 0 with no path argument.
+
+**Go — tham chiếu.**
+
+> cmd/destroy.go:39 documents the invariant "There is no cross-pool or global destroy; --all without a pool path is an error", and destroy.go:81-83 enforces it: `if destroyAll { if len(args) == 0 { return errors.New("--all requires a pool path; name the pool to clear, e.g. 'treehouse destroy . --all'") } … }`.
+
+**Cách sửa.**
+
+> Make `path` required when `all` is set: `if all && args.path.is_none() { return Err(anyhow!("--all requires a pool path; name the pool to clear, e.g. 'treehouse destroy . --all'")) }`, and resolve the named pool through an `is_pool_dir` check mirroring Go's `resolveDestroyPoolFromTarget` (destroy.go:190-211) rather than the current string-suffix heuristic at main.rs:571-574.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> Could not refute — every citation checks out and the behavior reproduces. Go v3.1.0 cmd/destroy.go:39 documents the invariant and cmd/destroy.go:81-83 enforces it; I also verified via `git show v2.1.1:cmd/destroy.go` that the identical guard existed at v2.1.1 line 93, so this predates the port baseline and is NOT upstream drift. Rust main.rs:571-577 only guards the case where a path IS supplied ("--all takes a pool path, not a worktree path"); nothing rejects the no-path case, and main.rs:579-583 builds DestroyTargetSpec::All from args.path (Option, cli.rs:202) without ever checking None. Grepping crates/ for "requires a pool", "pool path", "cross-pool", "global destroy" yields only comments (treehouse-core/src/destroy.rs:5, main.rs:567) — no enforcing code. Empirically reproduced in a fresh temp repo with one pooled worktree: `treehouse destroy --all` printed "Dry run: would destroy 1 worktree(s) in .../.treehouse/repo-c32f61" and exited 0. Two pieces of evidence the finder missed that strengthen it: (1) docs/rust-port-plan.md:55 states the same invariant as the design contract ("No cross-pool/global destroy exists; `destroy --all` without a pool path is an error"), so Rust violates its own plan; (2) the comment at main.rs:567-568 ("Go ` --all` and `--all` from the repo") is a misreading — Go has no bare --all mode. Separately, bare `treehouse destroy` with no path and no --all panics at main.rs:582 (`Option::unwrap()` on None, exit 101) where Go returns a clean error. BUCKET CORRECTED BUG_RUST -> P0_PARITY: the rubric's defining test is behavior the Go v2.1.1 baseline already had and Rust never matched — Rust has no partial/misfiring version of THIS guard to call "wrongly implemented"; the guard it does have is a different check. SEVERITY CONFIRMED HIGH, not CRITICAL: destroy still requires explicit --yes and still honors the disposable/include-*/never-lease-by-bulk gates (treehouse-core/src/destroy.rs:101,122), so no silent corruption or unflagged data loss — but a documented safety invariant is missing and `destroy --all --yes` wipes the entire ambient pool with no pool ever named.
+
+
+
+---
+
+### M-007 — A treehouse.toml that omits `max_trees` is a hard parse error instead of keeping the default 16
+
+| | |
+|---|---|
+| **Severity** | HIGH |
+| **Bucket** | **P0_PARITY** — port bug: Go baseline v2.1.1 đã có, Rust không tương đương |
+| **Domain** | config |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> Verified empirically: a repo treehouse.toml containing only `root = "pools"` yields `failed to parse config file .../treehouse.toml: TOML parse error ... missing field 'max_trees'`. Go loads it fine with max_trees=16. Every treehouse command in that repo then fails at config load — status, get, prune, destroy — because resolve_repo_ctx propagates the error (cli.rs:224). The same applies to ~/.config/treehouse/config.toml containing only a [hooks] table, which is the documented shape for hook-only user config.
+
+**Rust — hiện trạng.**
+
+> crates/treehouse-core/src/config.rs:23 `pub max_trees: u32` has no #[serde(default)], and :76 / :120 / :307 / :325 all do `toml::from_str(&text)` into a FRESH TreehouseConfig. The Rust test at config.rs:611-615 even carries the workaround comment "must include max_trees for TOML parse" — the bug was worked around in the test rather than fixed in the loader.
+
+**Go — tham chiếu.**
+
+> internal/config/config.go:157 `cfg := DefaultConfig()` (MaxTrees 16, config.go:97-101) then :163 `toml.DecodeFile(repoPath, &cfg)` decodes ONTO the pre-populated struct, so a key absent from the document keeps its default. Same shape for the user config at :205/:211 (loadUser).
+
+**Cách sửa.**
+
+> Add #[serde(default = "default_max_trees")] returning DEFAULT_MAX_TREES, or decode into a toml::Value and apply fields one at a time so absent keys leave the defaulted struct untouched — the latter matches Go's decode-onto-default semantics exactly and protects every future optional key too.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> Could not refute — the gap is real and every citation is accurate. I verified BOTH sides by compiling and executing probes, not by reading alone.
+>
+> GO SIDE (ground truth confirmed): internal/config/config.go:97-101 DefaultConfig() returns MaxTrees:16; :157 `cfg := DefaultConfig()` then :163 `toml.DecodeFile(repoPath, &cfg)` decodes ONTO the pre-populated struct, so an absent key retains its default. Same for user config at :205/:211 (loadUser). I ran `go test ./internal/config/` with a probe writing a treehouse.toml of only `root = "~/wt"` → output `PROBE_OK max_trees=16 root="~/wt"`. Critically, `git show v2.1.1:internal/config/config.go` shows the IDENTICAL DefaultConfig+DecodeFile pattern (v2.1.1 lines 23-40), so this is v2.1.1 baseline behavior, not post-baseline upstream drift.
+>
+> RUST SIDE (confirmed): config.rs:23 `pub max_trees: u32` has NO #[serde(default)], while sibling fields at :24, :26, :29 all DO have #[serde(default)] — the omission is specific to this one field, not a struct-level decision. All four decode sites (:76, :120, :307, :325) call `toml::from_str::` into a FRESH struct, discarding the default_config() established at :69/:111. I compiled and ran a probe: `missing field 'max_trees'` hard parse error.
+>
+> REACHABILITY (not theoretical): the upstream Go README lines 720-723 documents a `[hooks]`-only user config containing NO max_trees — exactly what users are told to paste into ~/.config/treehouse/config.toml. I tested that literal snippet against Rust's load_with_env and it hard-fails; a repo treehouse.toml with only `root = "."` (Go's documented in-project storage) also hard-fails. Since TreehouseConfig::load runs on every repo-scoped command, one such file breaks the entire tool.
+>
+> PROCESS NOTE: my first user-config probe was invalid — I mistakenly called the real TreehouseConfig::load (reads actual $HOME) instead of load_with_env, yielding a misleading max_trees=64. I discarded it and re-ran correctly; the corrected probe reproduces the error. Both probe files were removed; git status is clean on both trees.
+>
+> The config.rs:611 comment "must include max_trees for TOML parse" confirms the claimed workaround-in-tests-instead-of-fix pattern. BUCKET P0_PARITY correct (v2.1.1 had it; not upstream drift, not a P1 plan item — the plan's config.rs line only specifies the file exists, not serde defaults). SEVERITY HIGH correct: fails loudly with no data loss, silent corruption, or security impact (so not CRITICAL), but it breaks every command in an affected repo, clearing the HIGH bar.
+
+
+
+---
+
+### M-008 — Global `--env-path` flag is a no-op — the pool still lands in `~/.treehouse`
+
+| | |
+|---|---|
+| **Severity** | HIGH |
+| **Bucket** | **BUG_RUST** — Rust có khái niệm nhưng implement sai so với Go |
+| **Domain** | config |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> README.md:225 and :244 document `--env-path ` as "Custom pool root (overrides treehouse.toml root and ~/.treehouse); available on every command". It overrides nothing. Verified empirically: `treehouse --env-path $HOME/CUSTOMPOOL get --lease` printed a path under $HOME/.treehouse/repo-/1/repo and $HOME/CUSTOMPOOL was never created. Anyone using it for per-project or CI-isolated pools silently shares the default pool, so two checkouts of the same repo collide in one slot namespace.
+
+**Rust — hiện trạng.**
+
+> crates/treehouse/src/cli.rs:31-32 declares the flag; main.rs:67-70 routes to open_pool_with_env_path; cli.rs:246-315 builds a CliEnv { pool_root: env_path } and passes it to Pool::open_with_env; but pool.rs:121 inside that function calls resolve_pool_dir(...) — the NON-_with_env variant — which reads opts.config.root or home_dir() from process env. TreehouseEnv::pool_root() is never consulted. Grep confirms resolve_pool_dir_with_env (config.rs:235) and resolve_pool_root_with_env (config.rs:260) have no production callers, only unit tests and the custom_env example.
+
+**Go — tham chiếu.**
+
+> internal/config/config.go:103-118 ResolveRoot(flag, cfg) — flag > TREEHOUSE_ROOT > config.root; config.go:252-269 ResolvePoolRoot then resolves it. Locked by resolve_test.go:119 TestResolveRoot_Precedence and :170 TestResolveRoot_FlagDotSelectsInProject.
+
+**Cách sửa.**
+
+> In pool.rs:121 call resolve_pool_dir_with_env(repo_root, opts.config.root.as_deref(), remote_url, env.as_ref()) so the injected env is honored, and in main.rs:67 pass --env-path as the root override ahead of config.root (mirroring Go's flag > env > config precedence) rather than as a separate env channel.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> I tried to refute this and could not; I confirmed it empirically, not just by reading. `Pool::open_with_env` (pool.rs:108-118) accepts the injected `CliEnv { pool_root: env_path }` from cli.rs:302-303 but then calls the NON-env resolver `resolve_pool_dir(repo_root, opts.config.root.as_deref(), remote_url)` at pool.rs:121. pool.rs:20 imports only `resolve_pool_dir` — the `_with_env` variant is never imported into the module. The correct functions exist and are correct (`config.rs:235 resolve_pool_dir_with_env`, `config.rs:260 resolve_pool_root_with_env`, where line 267 is exactly `env.pool_root()`), but grep proves they are dead in production: the only references are unit tests at config.rs:552-585 and the custom_env/in_memory_env examples. No `pool_root()` consumer sits in the Pool::open path — discovery.rs:47/59 and lib.rs:93 serve the `--all` sweep and test harness respectively.
+>
+> EMPIRICAL PROOF: I built the CLI and ran it against a throwaway git repo with HOME redirected to a fresh temp dir:
+> env HOME=/tmp/th-home2.z3ETcz treehouse --env-path /tmp/th-verify.p7oWdP/custom status
+> The pool was created at /tmp/th-home2.z3ETcz/.treehouse/repo-9e4b59/ (with treehouse-state.json and .lock), and the --env-path target directory remained completely EMPTY. The flag changed nothing.
+>
+> BLAST RADIUS: all nine pool-touching commands (get, enter, return, status, prune, gc, destroy, run, doctor) route through main.rs:65-71 open_pool_for_cli — call sites at main.rs:107, 181, 211, 244, 267, 352, 565, 786, 812. The flag is documented as working at README.md:225 and README.md:244.
+>
+> BUCKET BUG_RUST is correct, not UPSTREAM_DRIFT or P1_UPGRADE. Go's equivalent is `--root` (different name, same concept) declared at cmd/root.go:62, threaded through config.ResolveRoot(rootFlag, cfg) into ResolvePoolDir at cmd/get.go:152, cmd/status.go:56, cmd/enter.go:55, cmd/lease.go:64, cmd/prune.go:51/79, cmd/destroy.go:210, cmd/return_cmd.go:564. The concept predates the v2.1.1 baseline, so this is a port bug.
+>
+> ONE CORRECTION to the claim's citation: ResolveRoot is at internal/config/config.go:110, not 103 — lines 103-109 are its doc comment. The rest of the Go citation checks out, including resolve_test.go:119 (TestResolveRoot_Precedence) and resolve_test.go:170 (TestResolveRoot_FlagDotSelectsInProject).
+>
+> TWO THINGS THE CLAIM UNDERSTATED: (1) TREEHOUSE_ROOT is absent from the entire Rust tree (grep -rn TREEHOUSE_ROOT crates/ returns no matches), so the middle tier of Go's precedence (flag > env > config > default) is missing entirely, not just the flag. (2) Severity stays HIGH rather than escalating to CRITICAL because there is a working escape hatch: config.root from treehouse.toml IS read at pool.rs:121, so a user can still isolate a pool. The failure mode is "documented flag silently ignored, falls back to the real ~/.treehouse pool" — not "wrong pool destroyed."
+
+
+
+---
+
+### M-009 — prune's execute phase re-verifies only the reservation, never re-classifies, so a slot that dirtied between plan and delete is destroyed
+
+| | |
+|---|---|
+| **Severity** | HIGH |
+| **Bucket** | **P0_PARITY** — port bug: Go baseline v2.1.1 đã có, Rust không tương đương |
+| **Domain** | pool semantics — prune safety re-verification |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> A worktree that goes dirty (or gets a process standing in it) between the dry-run plan and the execute phase is deleted without ever being re-checked. The destroy engine DOES re-classify (destroy.rs:379-383), so the two engines disagree on "disposable" under exactly the race the plan's §2.2 two-phase contract is supposed to close. The pre-destroy hook running outside the lock widens that window arbitrarily.
+
+**Rust — hiện trạng.**
+
+> /Users/tranquangdang21/Projects/treehouse_rust/crates/treehouse-core/src/prune.rs:350-409 — phase 2 checks only `reservation.matches(&state.worktrees[idx])` (line 364) and then goes straight to `self.git.worktree_remove` (line 375) and `std::fs::remove_dir_all` (line 387). No `analyze_idle_worktree` re-invocation, no `is_dirty`, no `is_worktree_in_use`, no merge check.
+
+**Go — tham chiếu.**
+
+> /tmp/th-baseline/treehouse/internal/pool/prune.go:605-625 (finalPruneSafetyCheck: re-runs IsWorktreeInUse → analyzeIdleWorktree under the state lock immediately before the removal) and prune.go:626-651 (finalOrphanPruneSafetyCheck). Called from executePrune at prune.go:838-847. The full classification (dirty + merge into default ref + size) is deliberately recomputed inside the deleting lock, not trusted from the plan.
+
+**Cách sửa.**
+
+> Inside the phase-2 lock, after `reservation.matches`, re-run `analyze_idle_worktree` (or at minimum `is_dirty` + `is_worktree_in_use` + the merge check) against the freshly-read state; on any skip, call `reservation.restore_original` and push a `PruneSkipped` with the right category, exactly as Go's `finalPruneSafetyCheck` does.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> REFUTATION FAILED on all five angles. (1) Not wrong function: v2.1.1 baseline itself has it — `git show v2.1.1:internal/pool/prune.go` shows finalPruneSafetyCheck at :565, called at :482 inside the deleting WithStateLock, re-running IsWorktreeInUse (:569) + analyzeIdleWorktree (:579). This kills the UPSTREAM_DRIFT reading and fixes the bucket at P0_PARITY. (2) No alternate module: grepping the entire execute_prune body (prune.rs:295-418) for analyze_idle_worktree / is_dirty / is_worktree_in_use / is_head_merged returns NONE FOUND. The only phase-2 gate is reservation.matches at prune.rs:364, which validates identity (path + destroying + owner_pid + owner_started_at) not state. Dirty (prune.rs:205) and merge (prune.rs:233) run only in the plan phase, outside any lock (prune.rs:118-141). (3) No feature flag gates it. (4) The finder's Go call-site citation 838-847 is WRONG (that range is linkedWorktreeGitDir) and 605-625 is off by two (actual 607-625), but these are line-number slips — the named functions exist and are called from executePrune at prune.go:519/521, so the substance stands. (5) No defense-in-depth elsewhere — I found the gap is actually UNDERSTATED: Go's delete is vcs.RemoveCleanWorktree -> gitvcs.go:694-697 = `git worktree remove` WITHOUT --force, so git itself refuses a dirty worktree. Rust calls worktree_remove (prune.rs:375) = shell.rs:304-310 WITH --force. The non-forced remove_clean_worktree variant exists (shell.rs:312-318, trait git/mod.rs:95) but has ZERO call sites in the entire crate — dead code. Rust lost both the Go re-classification and the non-forced guard. Additionally the port plan itself (docs/rust-port-plan.md:56) explicitly promises phase 3 "re-verifies sameDestroyReservation ... re-classifies with live state, then deletes" — the first half is satisfied, the second is not. Severity held at HIGH (top of band) rather than CRITICAL: the outcome is permanent loss of uncommitted work, but it is gated on winning a race, since the plan phase runs outside all locks and pre_destroy hooks execute outside all locks (prune.rs:330-343), leaving the window open for the hook's full duration. That is a "safety invariant missing" per the rubric; a grader weighting "data loss" unconditionally would call it CRITICAL.
+
+
+
+---
+
+### M-010 — No parent-directory fsync after rename — a crash can lose the state file entirely on ext4/XFS
+
+| | |
+|---|---|
+| **Severity** | HIGH |
+| **Bucket** | **P0_PARITY** — port bug: Go baseline v2.1.1 đã có, Rust không tương đương |
+| **Domain** | atomic write / durability |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> `rename(2)` is atomic with respect to readers but NOT with respect to a power loss: without fsyncing the parent directory, the rename may not be durable. After a hard crash / power cut the pool can come back with a zero-length or absent `treehouse-state.json`, which Rust then treats as CORRUPT and recovers every on-disk worktree as leased — or worse, the old directory entry reappears and the last write is silently lost (a lease or owner reservation is forgotten while a live process still holds the worktree). docs/rust-port-plan.md §2.3 spells the required dance out verbatim: "fsync → rename+dir-sync (POSIX) / ReplaceFileW/MoveFileEx MOVEFILE_WRITE_THROUGH (Windows)".
+
+**Rust — hiện trạng.**
+
+> crates/treehouse-core/src/state_file.rs:46-48 commits with `// Atomic commit: unix rename / Windows MoveFileExW+REPLACE.` then `tmp.persist(path).map_err(|e| e.error)?; Ok(())` — return immediately, no directory sync. Enumerated every fsync in the file: only :43 `tmp.as_file().sync_all()?` (the temp file itself). No `File::open(dir)` + `sync_all()` anywhere. The doc comment at :26-27 asserts "On Windows the parent-directory fsync is skipped (Go's syncDirectory is POSIX-only) — accepted for P0 parity" — that is self-contradictory: the code skips it on POSIX too, where Go has had it since v2.0.1.
+
+**Go — tham chiếu.**
+
+> /tmp/th-baseline/treehouse/internal/pool/state_file_posix.go:10-15 `func commitStateFile(tmpPath, path string, _ bool) error { if err := os.Rename(tmpPath, path); err != nil { return err }; return syncDirectory(filepath.Dir(path)) }`; :17-24 `func syncDirectory(dir string) error { f, err := os.Open(dir); ...; return f.Sync() }`. Identical in the v2.1.1 baseline (`git show 939cb59:internal/pool/state_file_posix.go`) — this is NOT drift, it is a P0 invariant the port never matched. Windows side: state_file_windows.go:32-46 `ReplaceFileW` with `replaceFileWriteThrough = 0x1`, and :58 `MoveFileEx(..., MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH)`.
+
+**Cách sửa.**
+
+> After `tmp.persist(path)?`, add a `#[cfg(unix)]` block that opens `path.parent()` and calls `File::open(dir)?.sync_all()?`, propagating the error. Correct the misleading doc comment at state_file.rs:26-27 to say the gap is POSIX-wide, not Windows-only. On Windows, `tempfile`'s persist maps to MoveFileEx without MOVEFILE_WRITE_THROUGH, so add that flag explicitly for parity with state_file_windows.go:58.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> Could not refute; every material citation verified against both trees.
+>
+> GO SIDE (confirmed): state_file_posix.go:10-15 commitStateFile (os.Rename then syncDirectory) and :17-24 syncDirectory (os.Open + f.Sync) match verbatim. Proved NOT drift by checking the actual v2.1.1 baseline: `git show 939cb59:internal/pool/state_file_posix.go` is byte-identical to HEAD cb7fb26. It is live code, not dead: state.go:493 calls commitStateFile from atomicWriteFile. Go's own doc comment at state.go:444-447 states the contract: "syncs it, commits it with the platform's replacement primitive, and syncs the parent directory where the platform supports that."
+>
+> RUST SIDE (confirmed absent; synonym sweep done): state_file.rs:47-48 persists then returns immediately. Tree-wide grep for sync_all|sync_data|fsync|sync_dir|syncDirectory|sync_parent|fdatasync|F_FULLFSYNC returns exactly ONE code hit: state_file.rs:43, the temp file. Separate sweep for directory File::open/OpenOptions and for helper crates (fs2, sync-all, SyncAllExt, dunce, same-file in both Cargo.toml) found nothing -- every OpenOptions hit is the pool options struct, not std::fs::OpenOptions. No alternate write path exists: all 12 production callers use write_state (the un-synced one) across pool.rs/gc.rs/destroy.rs/prune.rs/discovery.rs, and DefaultEnv::write_file at env.rs:122-127 is a bare std::fs::write with no sync at all.
+>
+> SELF-CONTRADICTION IS REAL: doc comment state_file.rs:26-27 frames the skip as Windows-only, but no #[cfg] guards any sync in the function -- the POSIX path returns at :48 identically.
+>
+> INDEPENDENT CORROBORATION THE FINDER DID NOT CITE: the port plan itself promises the dir sync. docs/rust-port-plan.md:60 ("rename+dir-sync (POSIX)") and :99 ("state_file.rs # atomic_write_file (tempfile persist + dir sync + mode preservation)"), listed as P0 at :561/:572 ("atomic+recoverable state"). So this is an unimplemented design-contract element, not a deliberate accepted deviation.
+>
+> SEVERITY: considered CRITICAL, rejected. A lost rename reverts the state file to a VALID prior version -- not corrupt/truncated -- so recover_corrupt_state (state.rs:201) never fires. And gc::analyze_gc_candidate (gc.rs:146) iterates &WorktreeEntry sourced from state, so untracked worktrees are invisible to gc and leak rather than being wrongly deleted. That keeps it out of the "wrong worktree deleted" tier. It is squarely the rubric's "safety invariant missing" and is durable in both Go and the port plan -- HIGH stands.
+>
+> Fix is ~6 lines mirroring syncDirectory, #[cfg(unix)]-gated, plus correcting the stale :26-27 comment (Windows already gets write-through via persist's MoveFileExW, so the skip is harmless only on Windows -- the inverse of what the comment claims).
+
+
+
+---
+
+### M-011 — Return phase-1 skips from execute_destroy instead of dropping them
+
+| | |
+|---|---|
+| **Severity** | MEDIUM |
+| **Bucket** | **P0_PARITY** — port bug: Go baseline v2.1.1 đã có, Rust không tương đương |
+| **Domain** | Safety invariants — two-phase destroy |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> A worktree that flips to non-removable between the plan phase and phase 1 — because a concurrent destroy reserved it, or because it became dirty/in-use/leased — is neither destroyed nor reported. It vanishes from the command's output entirely. The user's `treehouse destroy --all` shows fewer targets than exist, with no indication that two were held back, so the skip is invisible and unactionable.
+
+**Rust — hiện trạng.**
+
+> crates/treehouse-core/src/destroy.rs:300 (`let mut skips = Vec::new();`) and crates/treehouse-core/src/destroy.rs:309-315 (pushes the "reserved by another destroy" skip) — but the closure returns `Ok::<_, PoolError>(reserved)` at crates/treehouse-core/src/destroy.rs:334, so `skips` is a dead local that is never propagated. crates/treehouse-core/src/destroy.rs:317-319 (`if self.allows(...).is_err() { continue; }`) likewise discards the skip Go records at v2.1.1 destroy.go:384.
+
+**Go — tham chiếu.**
+
+> v2.1.1 internal/pool/destroy.go:379 (`skips = append(skips, DestroySkip{Target: current})` for "reserved by another destroy") and v2.1.1 internal/pool/destroy.go:384 (`skips = append(skips, skip)` when `opts.allows` fails); both are returned by `executeDestroy` and merged into `DestroyResult.Skipped` (HEAD destroy.go:544, planAndDestroy destroy.go:245)
+
+**Cách sửa.**
+
+> Change the phase-1 closure to return `(reserved, skips)` instead of `reserved`, and extend them into `result.skipped` alongside `exec_skips` at destroy.rs:164-168. Also capture the `allows` error and push it, mirroring Go's line 384.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> Could not refute — verified real on both sides. Go v2.1.1 internal/pool/destroy.go:376-380 appends a DestroySkip{Target: current} ("reserved by another destroy") and :381-385 appends the skip when opts.allows fails; both live in `skips`, returned at :491 and merged into result.Skipped at :226. Rust crates/treehouse-core/src/destroy.rs:300 declares `let mut skips = Vec::new()` and pushes at :309-315, but the phase-1 lock closure returns only `Ok::<_, PoolError>(reserved)` at :334, so `skips` never escapes; :317-319 `if self.allows(...).is_err() { continue; }` discards the Err(DestroySkip) payload outright. Phase 2 (:359) iterates only `&reserved`, so a phase-1-skipped target never re-enters, and :168 merges only exec_skips (phase 2). No recovery mechanism: grep for "reserved by another destroy" across crates/ returns exactly one hit (destroy.rs:313), and `cargo build -p treehouse-core` is warning-free because `skips` counts as used (pushed to), so the drop is silent. Two corrections to the claim. (1) Citation off-by-one: the Go v2.1.1 appends are at destroy.go:378 and :383, not :379 and :384. (2) Severity should be MEDIUM, not HIGH. HIGH was justified as "safety invariant missing," and that is not what breaks — the skip still `continue`s at :315/:318, so the gate that PREVENTS deletion works exactly as in Go: no data loss, no wrong worktree removed, no silent corruption. What degrades is the reporting layer: `skipped` comes up short in human output (crates/treehouse/src/format.rs:152) and in JSON (crates/treehouse-core/src/result.rs:99,130), and in the TOCTOU single-target case `destroy --yes` exits 0 with "Destroyed 0 worktree(s)" where Go exits 1 via cmd/destroy.go:215-238, because the exit-1 gate at crates/treehouse/src/main.rs:601-614 reads r.skipped, which no longer contains the phase-1 entry. That is a wrong exit code plus a short JSON array over a narrow race window — the rubric's MEDIUM, not HIGH. Bucket P0_PARITY holds: the behavior existed at the v2.1.1 baseline and Rust never matched it, and it is additionally promised by docs/rust-port-plan.md:235 ("Destroying+live owner -> skip 'reserved by another'") and listed in the destroy --json contract at :427.
+
+
+
+---
+
+---
+
+## 5. Tier 1 — cần một quyết định, rồi vài ngày
+
+> Cần thêm plumbing hoặc chạm wire format. M-012 và M-013 là CRITICAL, **không nên đợi Tier 2**.
+
+### M-012 — Pool slot reuse has no clone-identity scoping, so a second clone of the same origin gets another clone's worktree reset
+
+| | |
+|---|---|
+| **Severity** | CRITICAL |
+| **Bucket** | **UPSTREAM_DRIFT** — upstream thêm sau v2.1.1, Rust chưa có (lag hợp lệ) |
+| **Domain** | pool semantics — clone identity / reuse |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> Pools are keyed by the origin remote URL (config.rs:139-157, matching Go config.go:230-247), so two clones of the same origin share one pool dir. Clone A's `treehouse get` will happily pick up clone B's slot and run `checkout --detach --force` + `reset --hard` + `clean -fd` on it — destroying whatever clone B's agent was doing, and handing A a worktree whose gitdir points into B's object store. Neither side is told this happened.
+
+**Rust — hiện trạng.**
+
+> /Users/tranquangdang21/Projects/treehouse_rust/crates/treehouse-core/src/pool.rs:478-498 — the reuse loop has no identity comparison. Proof of absence: `grep -rn --include='*.rs' -i 'clone_identity|CommonGitDir|SameFile|same_file' crates/` returns 0 hits; `common_dir` appears 29 times but only as the `GitRepo` struct field name (git/mod.rs:20), never as a comparison.
+
+**Go — tham chiếu.**
+
+> /tmp/th-baseline/treehouse/internal/pool/pool.go:365-372 (acquisitionCommonGitDir + os.SameFile) and 536-548 (`if identityErr != nil { unverifiedClone++; continue }` / `if !os.SameFile(candidateDir, commonDir) { otherClone++; continue }`), with the dedicated error at pool.go:641-646. Feature 1185dc6 "pool: reuse a pooled worktree only for the clone that owns it (#145)"; test file internal/pool/clone_identity_jj_test.go.
+
+**Cách sửa.**
+
+> Add `common_git_dir(path) -> Result` to GitBackend (resolve `.git/commondir`, canonicalize), compute the requester's once in `Pool::get`, and in acquire_locked `continue` on any slot whose canonicalized common git dir differs or cannot be resolved — counting the two skip reasons so the PoolFull message names them, as Go does.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> Could not refute — confirmed on both sides. GO: the guard exists verbatim at pool.go:489-501 (`if identityErr != nil { unverifiedClone++; continue }` / `acquisitionCommonGitDir(wt.Path)` error -> `unverifiedClone++` / `if !os.SameFile(candidateDir, commonDir) { otherClone++; continue }`), with counters at pool.go:462-463 and the dedicated pool-full error at pool.go:632-639 ("A worktree is reused only by the clone it belongs to"). acquisitionCommonGitDir at pool.go:365-371 is cited exactly. Version check: `git tag --contains 1185dc6` -> v3.0.0/v3.0.1/v3.1.0 and `git merge-base --is-ancestor 1185dc6 v2.1.1` -> NOT in v2.1.1, so it postdates the port plan's v2.1.1 baseline: UPSTREAM_DRIFT is correct, not P0_PARITY. RUST: proof of absence holds — `grep -rn --include='*.rs' -iE 'same_file|clone_identity|common_git_dir' crates/` returns 0 hits; the only file-identity code in the tree is state_file.rs:57 (MetadataExt), unrelated. The reuse loop pool.rs:478-506 gates solely on destroying/leased/owner_alive, is_worktree_in_use, and is_dirty, then returns the path at 499-505 with no identity comparison, and WorktreeEntry (state.rs:91-114) carries no clone/identity field to compare against. My strongest refutation attempt was that cross-clone pool sharing may be opt-in in Rust, but that failed: resolve_pool_dir (config.rs:139-157) keys the pool name on a hash of the remote_url (lines 144-147) and defaults the pool root to $HOME/.treehouse (config.rs:160-167), so two clones of the same origin share one pool directory BY DEFAULT with no opt-in — exactly the case Go's comment at pool.go:485-488 describes. The consequence is destructive, not cosmetic: get calls reset_worktree on the reused path (pool.rs:219-223), and shell.rs:329-351 runs `git checkout --detach --force [`, `git reset --hard ][`, and `git clean -fd` inside that directory, where git resolves the worktree's .git file into the other clone's git dir — forcing clone B to hard-reset and clean clone A's checkout. CRITICAL holds: `clean -fd` deletes untracked files in an actively used checkout and the reset discards committed-but-unlanded work (silent corruption / data loss). Sole correction to the report: two Go line ranges are off (guard is 489-501 not 536-548; dedicated error is 632-639 not 641-646); the 365-371 citation is exact.
+
+]
+
+---
+
+### M-013 — acquire reclaims a clean slot holding unlanded commits without any merge/HEAD safety check
+
+| | |
+|---|---|
+| **Severity** | CRITICAL |
+| **Bucket** | **UPSTREAM_DRIFT** — upstream thêm sau v2.1.1, Rust chưa có (lag hợp lệ) |
+| **Domain** | pool semantics — acquire/release safety |
+| **Confidence** | HIGH |
+| **Verify** | 1/2 lens đồng ý (đã sửa bucket) |
+
+**Triệu chứng / tác động.**
+
+> A crashed or rebooted owner leaves owner_pid=0 (heal_state clears it) while its worktree still holds committed-but-unpushed commits. `git status --porcelain` is empty for that tree, so acquire_locked marks the slot available and `reset --hard` + `clean -fd` silently discards every one of those commits. The user gets a fresh worktree with no warning and no backup. This is exactly the data-loss class the property was written to prevent (#79/#104).
+
+**Rust — hiện trạng.**
+
+> /Users/tranquangdang21/Projects/treehouse_rust/crates/treehouse-core/src/pool.rs:478-498 — availability loop is only `destroying || leased || owner_alive` → `is_worktree_in_use` → `git.is_dirty`. No merge check exists anywhere: `grep -rn -i 'safe_to_reset|SafeToReset|head_merged|is_merged' crates/` returns 0 hits for the acquire path. The destructive reset fires at pool.rs:221-223 (`reset_worktree` = `checkout --detach --force` + `reset --hard` + `clean -fd`, shell.rs:329-353) OUTSIDE the lock, and the post-reset re-validation at pool.rs:227-237 only checks that the name still exists in state — it never re-checks HEAD or dirtiness.
+
+**Go — tham chiếu.**
+
+> /tmp/th-baseline/treehouse/internal/pool/pool.go:500-522 (IsWorktreeSafeToReset + headMergedIntoRecordedBase guard); the fix landed as 04d8725 "fix(get): skip reclaiming a pool slot that holds unlanded work (#104)", which replaced `dirty, _ := vcs.IsDirty(wt.Path); if dirty { continue }` with the safe-to-reset guard and pinned the verified commit so the guard and the reset cannot disagree. Go v2.1.1 (the Rust plan's declared baseline) did not have it either, so this is drift, not a port bug — but it is the highest-consequence drift item in this domain.
+
+**Cách sửa.**
+
+> Add `is_worktree_safe_to_reset(path, branch) -> (safe, reset_ref, head)` to the GitBackend trait (git: `merge-base --is-ancestor HEAD ` plus a dirty re-check), call it in the acquire_locked availability loop and `continue` on error/unsafe, and thread the captured `reset_ref`+`head` into the post-lock reset so the guard and the reset target the same verified commit.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> The gap is real but the bucket is wrong. EXISTENCE CONFIRMED: crates/treehouse-core/src/pool.rs:478-498 is the entire availability loop and contains only `destroying || leased || owner_alive` (pool.rs:481) -> `is_worktree_in_use` (pool.rs:484-486) -> `git.is_dirty` (pool.rs:490). `grep -c is_head_merged_into_ref crates/treehouse-core/src/pool.rs` returns 0. The merge primitive DOES exist in the Rust tree (git/mod.rs:115 trait method, git/shell.rs:361-378 impl running `merge-base --is-ancestor HEAD [`) so the finder did not miss a synonym — it is simply never called from pool.rs. Its only three call sites are gc.rs:248, destroy.rs:185 and prune.rs:233 (all DELETE paths), never the acquire/reset path. An aggressive synonym sweep of pool.rs for `merge|ancestor|safe|rev-list|revparse|contain` returns only unrelated `ABA-safe` prose at pool.rs:318 and :362. Additionally state.rs:91-114 shows `WorktreeEntry` has NO `base_branch` field, so Go's `headMergedIntoRecordedBase` second reading (pool.go:849-862) is structurally impossible to port as-is — reinforcing the absence. The destructive reset is confirmed: shell.rs:329-351 issues `checkout --detach --force ][`, `reset --hard ][`, `clean -fd`, fired from pool.rs:221-223 OUTSIDE the lock, and the LOCK #2 re-validation at pool.rs:227-237 only checks that the name still exists in state, never HEAD or dirtiness. The Go side citation is correct: at v3.1.0 pool.go:511-521 has the `IsDirty` then `vcs.IsWorktreeSafeToReset(wt.Path, branch)` (line 516) then `if !safe && !headMergedIntoRecordedBase(wt, branch, head)` (line 520) guard, with the helper defined at pool.go:849-862. BUCKET CORRECTION: P0_PARITY is refuted. The Rust port plan declares Go v2.1.1 as its baseline (docs/rust-port-plan.md:5). At that exact tag, `git show v2.1.1:internal/pool/pool.go` line 125 reads `dirty, _ := git.IsDirty(wt.Path)` and line 130 `git.ResetWorktree(wt.Path, branch)` — character-for-character the Rust behavior, i.e. Rust faithfully matches the contract it was given. The guard landed in 04d8725 "fix(get): skip reclaiming a pool slot that holds unlanded work (#104)"; `git merge-base --is-ancestor v2.1.1 04d8725` proves it is strictly after v2.1.1, and CHANGELOG.md:63 files it under release 3.0.0. Therefore this is legitimate upstream lag, UPSTREAM_DRIFT, not a port bug. The finder's own GO EVIDENCE text concedes this ("this is drift, not a port bug") while its BUCKET field claims P0_PARITY — a self-contradiction. SEVERITY UNCHANGED at CRITICAL: when the path does fire, `reset --hard` plus `clean -fd` orphans committed-but-unmerged commits, which is silent data loss under the stated rubric, independent of bucket.
+
+]
+
+---
+
+### M-014 — return/get never terminate lingering processes, so a slot is reset while a writer is still in it
+
+| | |
+|---|---|
+| **Severity** | HIGH |
+| **Bucket** | **UPSTREAM_DRIFT** — upstream thêm sau v2.1.1, Rust chưa có (lag hợp lệ) |
+| **Domain** | pool semantics — return path safety |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> `treehouse return` runs `checkout --detach --force` + `reset --hard` + `clean -fd` on a worktree that may still have a live agent, editor, or dev server writing into it, then hands the slot straight back to the pool. The next `get` inherits a half-written tree and a foreign live process — the exact contamination the fix was written for. Because `detach_and_return` is also a stub, the non-zero-shell-exit branch of `get` returns a slot still carrying whatever HEAD state it had.
+
+**Rust — hiện trạng.**
+
+> /Users/tranquangdang21/Projects/treehouse_rust/crates/treehouse/src/main.rs:209-241 (cmd_return) — checks dirtiness, then calls `pool.release_conditional(&path, &preconditions, None)`. The `before_reset` argument is literally `None`. Same in cmd_get at main.rs:194-197 (`pool.release(&path.to_string_lossy())?`, and `detach_and_return` at main.rs:198-201 is an empty stub `let _ = path; Ok(())`). `terminate_with_grace` (process.rs:152-170) exists and is correct, but grep shows its only callers are destroy.rs:398 and run.rs:176 — never the return path.
+
+**Go — tham chiếu.**
+
+> /tmp/th-baseline/treehouse/cmd/get.go:331-356 (killLingeringProcesses: terminate, then re-scan via UnprotectedProcessesInWorktree, error out with "worktree still has live processes after termination" if any survive) wired in as ReleaseConditional's beforeReset callback at get.go:248-254, so the kill-and-verify runs under the state lock immediately before the destructive reset. Fix 978da87 "fix(return): refuse to reset a worktree that still has live writers (#103)", v2.2.1.
+
+**Cách sửa.**
+
+> Pass a `before_reset` closure from both cmd_return and cmd_get that calls `terminate_with_grace` then re-scans with `find_in_worktree` filtered by the caller's protected chain, returning an error if survivors remain; and implement `detach_and_return` to call `detach_worktree` (skipped for a markerless path) before the release.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> The existence claim is TRUE — I could not refute it. Go's killLingeringProcesses genuinely exists at cmd/get.go:331 and does kill-then-re-verify, wired as ReleaseConditional's beforeReset at cmd/get.go:251-254 so it runs under the state lock immediately before the destructive reset. The Rust return/get path genuinely passes `None` for before_reset at every call site, and the required enumeration function does not exist anywhere in crates/ (swept for lingering, unprotected, survivor, live_processes, "still has live", in_use, live_ across the whole tree — no hidden equivalent under another name). Rust also has an empty `detach_and_return` stub at main.rs:731-734, so the nonzero-exit path is a no-op too.
+>
+> The BUCKET is wrong, and the finder's own evidence self-contradicts: it cites fix 978da87 and even writes "v2.2.1" in the evidence, yet buckets P0_PARITY. CHANGELOG.md:71 places that entry under the `## [2.2.1]` header at CHANGELOG.md:66, which is three releases AFTER the port plan's stated v2.1.1 baseline. P0_PARITY requires behavior the v2.1.1 baseline already had; this landed later. Per the task's own definition this is legitimate lag — UPSTREAM_DRIFT, not a port bug.
+>
+> Severity HIGH is upheld: the Rust path performs a destructive reset_worktree with zero liveness check, so a detached writer (e.g. an opencode server ignoring SIGHUP — the exact scenario in the Go doc comment) can be clobbered mid-write, and Rust emits no warning at all, whereas Go refuses the reset and leaves the slot in place. Not CRITICAL because it is upstream-lag rather than a regression against the design contract, and the worktree is one the user explicitly asked to return.
+>
+> One correction to the finder's supporting details: the Go wiring is at get.go:251-254 (function body), not 248-254, and the hook infrastructure in Rust is NOT missing — release_conditional accepts before_reset and invokes it correctly under LOCK #1; it is simply never supplied by any return-path caller.
+
+
+
+---
+
+### M-015 — No `recoverMissingStateEntries`: a missing state file leaves on-disk worktrees invisible, so `next_name` reissues an occupied slot name
+
+| | |
+|---|---|
+| **Severity** | HIGH |
+| **Bucket** | **UPSTREAM_DRIFT** — upstream thêm sau v2.1.1, Rust chưa có (lag hợp lệ) |
+| **Domain** | corrupted / partial state recovery |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> Two distinct failures. (a) Crash-between-worktree_add-and-state-write: the worktree exists on disk but is absent from state, so Rust's `acquire_locked` (pool.rs:478-506) never sees it, and `next_name` (pool.rs:649-659) computes `max(name)+1` over the truncated list — it will happily re-issue the SAME slot name and run `git worktree_add` into a directory that already holds a real worktree, or overwrite. (b) A missing state file makes the pool read as empty, so `status` under-reports and `destroy --all` reports nothing to clean. Go covers both by scanning. The Rust plan §6.3 table lists "Corrupt/truncated state" but not the missing-state case at all.
+
+**Rust — hiện trạng.**
+
+> crates/treehouse-core/src/state.rs:156-167 `read_state` returns `Ok(State::default())` on NotFound (and :177 identically for the env variant) with NO pool-dir scan; on successful parse it returns `Ok(s)` directly with no scan. Grep for `recover_missing|recoverMissingStateEntries` across crates/ returns nothing. Empirically verified: a pool dir containing `1/myrepo/.git` and no state file yielded `0 entries`.
+
+**Go — tham chiếu.**
+
+> state.go:118-129 `ReadState`: on `os.IsNotExist` it first stats poolDir and, if the dir EXISTS, calls `recoverMissingStateEntries(poolDir, State{})` rather than returning empty. :309-342 that function scans `//`, and for each path not already in state appends `recoverOneWorktree(...)` — i.e. quarantined-as-leased. It is invoked on the SUCCESSFUL-parse path too (:164 `return recoverMissingStateEntries(poolDir, s)`), covering the window where `git worktree add` succeeded but the state write failed. Pinned by state_test.go:31-49 `TestReadState_RecoversWorktreeMissingFromValidState`. Added in 0e87c62/ee8652b — after v2.1.1.
+
+**Cách sửa.**
+
+> Add `recover_missing_state_entries(pool_dir, &mut state)` mirroring state.go:309-342 and call it (a) when the state file is missing but poolDir exists, and (b) after a successful parse. Make `next_name` additionally probe the filesystem for the highest existing `/` directory so a name is never reissued regardless of state contents.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> Adversarially verified on both sides; I could not refute it. Every citation is accurate.
+>
+> GO SIDE CONFIRMED at all cited lines. /tmp/th-baseline/treehouse/internal/pool/state.go:118-129 ReadState stats poolDir on os.IsNotExist and calls recoverMissingStateEntries(poolDir, State{}) when the dir exists; :309-342 scans //, skipping known paths and appending recoverOneWorktree for the rest; :164 shows the success-parse path also returns recoverMissingStateEntries(poolDir, s). quarantineEntry at :359-370 marks recovered entries Leased=true + RecoveredLeaseHolder; recoverOneWorktree at :393-403. state_test.go:31-49 TestReadState_RecoversWorktreeMissingFromValidState confirmed verbatim.
+>
+> RUST SIDE CONFIRMED ABSENT, no hidden equivalent. crates/treehouse-core/src/state.rs:160 and :177 return Ok(State::default()) on NotFound with no pool-dir scan; :164 and :181 return Ok(s) directly on successful parse. recover_corrupt_state (state.rs:201) is reachable ONLY from the parse-error arm, so it never covers the missing-file or valid-parse-missing-entry cases. heal_state (state.rs:272-286) moves entries the opposite direction — drops entries whose path vanished, never adds from disk. The only worktrees.push outside acquire is pool.rs:534. Aggressive sweep for recover_missing|reconcile|adopt|backfill|scan_|existing_worktrees|quarantin|discover_existing across all crates/ found only the recover_corrupt_state path. Empirically reproduced with the built binary: deleting only the state file makes `treehouse status --json` return [] for a pool with a live worktree.
+>
+> BUCKET UPSTREAM_DRIFT CONFIRMED. `git show v2.1.1:internal/pool/state.go` returns State{}, nil on IsNotExist with no scan — v2.1.1 did NOT have this. It arrived in 0e87c62 (2026-09-05) and ee8652b (2026-09-13); v2.1.1 is tagged 2026-07-30. The only related port-plan line (docs/rust-port-plan.md:610) concerns the 0-byte CORRUPT case, which Rust does implement, so P1_UPGRADE does not apply.
+>
+> ONE CORRECTION TO THE HARM CHAIN. The claimed mechanism (next_name reissues an occupied slot name) is true — reproduced live: after deleting only the state file, `get` recomputes slot 1 and git fails with "fatal: '.../pool/1/myrepo' already exists", exit 128. But the implied danger is overstated: user content in that worktree (IMPORTANT.txt) SURVIVED intact. I separately verified git refuses both a registered-worktree path and a dir containing untracked user content, always preserving contents. So this is a loud, non-destructive failure — NOT silent corruption, NOT data loss, NOT a wrong worktree deleted.
+>
+> SEVERITY STAYS HIGH, but for different reasons than the claim implies. The primary command is permanently wedged: because state is empty on every subsequent load, next_name recomputes "1" each time, so acquire can never succeed without manual filesystem surgery. Meanwhile status/gc/prune/destroy all silently under-report a pool holding live worktrees — gc never reclaims the orphan and destroy cannot target it by name, leaving it unmanageable through the CLI. A core command broken with no in-tool recovery path fits HIGH; MEDIUM would understate the operational impact.
+
+
+
+---
+
+### M-016 — Resolve `return ` as a slot name, not only as a filesystem path
+
+| | |
+|---|---|
+| **Severity** | MEDIUM |
+| **Bucket** | **UPSTREAM_DRIFT** — upstream thêm sau v2.1.1, Rust chưa có (lag hợp lệ) |
+| **Domain** | CLI surface — command surface |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> `treehouse return ` — the form the Go help text tells users to use, and the only form that works from inside the repo when a `worktree_path` template puts slots outside the pool layout — is broken in Rust. A caller that took the name from `treehouse status` gets "not managed by treehouse" and no hint that the name form exists, so it falls back to reconstructing absolute paths.
+
+**Rust — hiện trạng.**
+
+> crates/treehouse/src/main.rs:213-217 `let path = args.path.clone().or_else(|| std::env::var("TREEHOUSE_DIR").ok()).ok_or_else(|| anyhow!("no worktree path specified"))?;` then main.rs:226 treats the string as a path unconditionally. `grep -rn 'find_by_name|FindByName|by_name' crates/` → zero hits. Verified: `treehouse return 1` → `worktree 1 is not managed by treehouse`, EXIT=1, despite slot 1 existing.
+
+**Go — tham chiếu.**
+
+> cmd/return_cmd.go:441-495 resolveReturnTarget / resolveReturnTargetByName: the argument is read as a PATH first and only then as a slot NAME via `pool.FindByName`; `couldBeWorktreeName` (return_cmd.go:467-475) rejects anything containing `/` or `\`. Missing names produce `unknownWorktreeNameError` (return_cmd.go:502-515) which lists the pool's actual names. Documented in the command's own Long help at return_cmd.go:53-58. Added in v3.0.0 (#143).
+
+**Cách sửa.**
+
+> Add `Pool::find_by_name` and make cmd_return try the path reading first (preserving Go's ordering so nothing that resolves today changes), then fall back to name resolution against the repo's pool when the path reading fails and the arg has no separator.
+
+**Ghi chú.** `return ` và `return --all` là hai finding riêng (`cmd/return_cmd.go:140,258-332`, #143) nhưng cùng một PR. Lưu ý: `cmd_enter` đã có sẵn resolver tại `main.rs:183` — `cmd_return` chỉ chưa gọi.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> Claim survives adversarial refutation on all axes; both sides' citations verified exact. GO: cmd/return_cmd.go:441 resolveReturnTarget resolves PATH first and only falls through to slot NAME on errReturnWorktreeUnmanaged (lines 451-457); :466 couldBeWorktreeName rejects anything with `/` or `\`; :480 resolveReturnTargetByName calls pool.FindByName; :501 unknownWorktreeNameError lists actual pool names. Long help at :53-58 documents the three-way addressing. CHANGELOG.md:30 places "return a worktree by name and add return --all (#143)" under v3.0.0, AFTER the port plan's stated v2.1.1 baseline (docs/rust-port-plan.md:5) — so UPSTREAM_DRIFT is the correct bucket, not P0_PARITY.
+>
+> RUST: genuinely absent from `return`. The finder's absence-proof grep (`find_by_name|FindByName|by_name` -> zero hits) was UNDER-INCLUSIVE but the conclusion still holds: Rust HAS a working name->path resolver, spelled differently and wired to a different command. crates/treehouse/src/main.rs:183 in cmd_enter does exactly what Go's pool.FindByName does (`statuses.iter().find(|s| s.name == args.name)`), with a matching error string at main.rs:186. cmd_return (main.rs:209) never calls it — it reads args.path at main.rs:213-217 and passes it unconditionally to git_is_dirty/release_conditional as a filesystem path. ReturnArgs (crates/treehouse/src/cli.rs:102-115) declares only force/if_lease_id/if_lease_holder/path. So this is a per-command wiring gap, not a missing subsystem.
+>
+> EMPIRICAL PROOF: built both binaries (cargo build + `go build` of HEAD cb7fb26) and ran the identical scenario in isolated HOMEs with a real pool where slot "1" exists. Go v3.1.0 `return 1` -> "Worktree returned to pool.", EXIT=0. Rust `return 1` -> "worktree 1 is not managed by treehouse", EXIT=1. Go's unknown-name path yields the richer diagnostic the finder cited ("no worktree named \"bogus-name\" in pool (available: 1), and it is not a treehouse-managed worktree path either"); Rust has no equivalent.
+>
+> SEVERITY CORRECTED HIGH -> MEDIUM. The Rust failure is safe, not destructive: it errors and exits 1 before releasing anything — no worktree deleted, no data loss, no wrong target touched. Path-based `return` still works (verified EXIT=0). Under the stated rubric this is a "feature gap" (one of three addressing modes missing), not a "core command broken or safety invariant missing."
+>
+> Incidental: the same v3.0.0 commit also added `return --all` (return_cmd.go:57-64); Rust ReturnArgs has no --all — a separate gap from the same PR, not part of this finding.
+
+
+
+---
+
+### M-017 — Add `return --all` to reclaim every held worktree in a pool
+
+| | |
+|---|---|
+| **Severity** | MEDIUM |
+| **Bucket** | **UPSTREAM_DRIFT** — upstream thêm sau v2.1.1, Rust chưa có (lag hợp lệ) |
+| **Domain** | CLI surface — command surface |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> There is no way to bulk-release a pool in Rust. An agent harness that crashed mid-run leaves every slot in-use, and recovery requires scripting `treehouse return ` per slot with no way to enumerate-and-return atomically — so a pool that Go reclaims with one command must be reclaimed slot-by-slot in Rust, and any slot missed keeps the pool short.
+
+**Rust — hiện trạng.**
+
+> crates/treehouse/src/cli.rs:101-114 `struct ReturnArgs` has only `force`, `if_lease_id`, `if_lease_holder`, `path`. `grep -rn 'return_all|returnAll|held_worktrees|returnable_status' crates/` → zero hits. Verified: `treehouse return --all` fails with clap `error: unexpected argument '--all' found`, EXIT=2.
+
+**Go — tham chiếu.**
+
+> cmd/return_cmd.go:140 `returnCmd.Flags().BoolVar(&returnAll, "all", false, "Return every held worktree in this repository's pool …")`; dispatch at return_cmd.go:92-101 (rejects a positional arg and rejects combining with `--if-lease-*`); implementation `returnHeldWorktrees()` at return_cmd.go:258-332 with per-slot `bulkReturnPreconditions` (return_cmd.go:193-203) and `returnableStatus` (return_cmd.go:230-241). Added in v3.0.0 (#143).
+
+**Cách sửa.**
+
+> Add `all: bool` to ReturnArgs; implement cmd_return_all mirroring returnHeldWorktrees — list the pool, keep leased/in-use/dirty/unverified, skip available/damaged/HeldOnlyByCwd, pin each slot to the lease the listing saw, and keep the summary line `🌳 Returned %d of %d held worktree(s); %d skipped; %d not held.` on stderr.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> I tried to refute on four fronts and failed on all four; the gap is genuine.
+>
+> (1) Go citations are exact, not approximate. /tmp/th-baseline/treehouse/cmd/return_cmd.go:140 registers the flag with the quoted help string; :92-101 is the dispatch that rejects a positional arg and rejects combining with --if-lease-*; :193 bulkReturnPreconditions, :230 returnableStatus, :258-332 returnHeldWorktrees. Every cited line lands on the claimed symbol (HEAD cb7fb26, v3.1.0).
+>
+> (2) Rust genuinely lacks it — no synonym hunt hit. ReturnArgs (crates/treehouse/src/cli.rs:101-114) has exactly force/if_lease_id/if_lease_holder/path. The --all flags that DO exist belong to other commands: PruneArgs cli.rs:129-130, GcArgs cli.rs:174-175, DestroyArgs cli.rs:187-188, dispatching to cmd_prune_all (main.rs:265) and cmd_gc_all (main.rs:350) — never to cmd_return (main.rs:209-236, which takes args.path verbatim). Grep for return_all/returnAll/held_worktree/returnable_status/bulk_return across crates/ returns zero hits for return. No cfg(feature) escape (only two in the tree, both `toon`, format.rs:189,295) and no env-var hatch. Runtime check on the built binary: `./target/debug/treehouse return --all` → `error: unexpected argument '--all' found`, usage `treehouse return [OPTIONS] [PATH]`.
+>
+> (3) Bucket UPSTREAM_DRIFT confirmed by checking the baseline tag directly: `git show v2.1.1:cmd/return_cmd.go` registers only --force, --if-lease-id, --if-lease-holder — no --all. CHANGELOG.md:30 dates `return --all` to v3.0.0 (#143), after the plan's declared baseline (docs/rust-port-plan.md:5). It is NOT P1_UPGRADE: plan §5 line 508 contemplates only `return `, and P1-A/B/C/D (plan:562-565) never mention it. Nor BUG_RUST — Rust has no buggy bulk-return concept, it has none.
+>
+> (4) I DISPUTE THE SEVERITY: HIGH is wrong, MEDIUM is right. The rubric reserves HIGH for "core command broken or safety invariant missing" — neither applies. `return` works correctly for its v2.1.1 contract (single target, all three baseline flags, conditional release via ReleasePreconditions). No invariant is missing, and no wrong worktree can be deleted: the bulk path Go adds is precisely the CONSERVATIVE one — bulkReturnPreconditions (return_cmd.go:193-203) sets RefuseRecovered and RequireUnleased, and returnableStatus (:230-241) skips held-by-cwd, available and damaged slots — so its absence removes a convenience, not a guard. That also rules out CRITICAL. The leak scenario the finder invokes already has a partial remedy it overlooked: Rust ships `gc`/`gc --all` (cli.rs:174-175, main.rs:350) to reclaim expired-lease and dead-owner worktrees. A missing convenience flag on an otherwise-correct command is a feature gap → MEDIUM.
+>
+> Adjacent observation, NOT part of this finding: the other half of #143 is also absent — Go resolves `return ` via resolveReturnTargetByName (return_cmd.go:480) but Rust cmd_return feeds args.path straight into pool.release_conditional with no name lookup, despite `enter` supporting names (main.rs:186). That is a separate finding and should not be folded into this one.
+
+
+
+---
+
+### M-018 — Honor `--format json|toon` on destroy/prune/return/gc instead of silently printing human text
+
+| | |
+|---|---|
+| **Severity** | MEDIUM |
+| **Bucket** | **P1_UPGRADE** — chính port plan đã hứa nhưng chưa giao |
+| **Domain** | CLI surface — output routing |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> The flag parses cleanly, so an agent that does `--format json` gets exit 0 and prose it cannot parse — the worst failure mode for an automation contract, because it is silent rather than a clean rejection. The plan explicitly promised these schemas (`return --json`, `prune --json`, `destroy --json`, `gc --json` in §5.3) and listed them as a P1-B gate; the flag surface exists without any of the implementation behind it.
+
+**Rust — hiện trạng.**
+
+> crates/treehouse/src/cli.rs:26-28 declares `--format` `global = true`, so clap accepts it on every subcommand. But crates/treehouse/src/main.rs:598 hardcodes `format::render(format::OutputFormat::Human, …)` for destroy, main.rs:342 hardcodes Human for prune, `render_gc` (main.rs:521) uses bare `println!` with no formatter at all, and cmd_return (main.rs:209-239) never builds a CommandResult. Verified: `treehouse destroy . --format json --all` printed the human 🌳 Dry-run table; `treehouse prune --format json` printed `🌳 No stale worktrees to prune.`; `treehouse return --format json ` was accepted and printed an error string.
+
+**Go — tham chiếu.**
+
+> n/a — Go has no JSON for these commands; the promise is the port contract: docs/rust-port-plan.md:415 "**Commands that accept `--format`:** `get`, `return`, `status`, `prune`, `destroy`, `gc`, `doctor`, `run`", and the P1-B DoD at docs/rust-port-plan.md:585 "— `--format human | json | toon` on get/status/return/prune/destroy/doctor/gc/run".
+
+**Cách sửa.**
+
+> Thread `cli.format` into cmd_prune/cmd_destroy/cmd_gc/cmd_return and dispatch on it: Human → the existing renderers, Json/Toon → `format::render(fmt, &CommandResult::X(result), &mut out, &mut err)`. Either reject `--format` on init/update/enter per plan line 416, or drop `global = true` and declare it per-subcommand so an unsupported combination fails loudly.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> CLAIM CONFIRMED, severity corrected down. I read both trees and reproduced. The port plan promises `--format` on return/prune/destroy/gc at docs/rust-port-plan.md:415 and in the P1-B DoD at :585 (still an unchecked `- [ ]`). Go has no `--format` at all — the only machine flag is `--json` on get/status/lease (cmd/status.go:153, cmd/get.go:97, cmd/lease.go:44) — so the finder's "GO EVIDENCE: n/a" is right and this is a plan promise, not parity. On the Rust side every cited line is accurate: cli.rs:26-28 makes `--format` global so clap accepts it everywhere, while main.rs:598 (destroy) and main.rs:342 (prune) hardcode `format::OutputFormat::Human`, render_gc (main.rs:512-545) uses bare `println!`, cmd_return (main.rs:209-239) never builds a CommandResult, and result.rs:181-188 has no `Gc` variant. I refuted-looked hard for an alternate mechanism (TREEHOUSE_FORMAT env, no_color, other render entry points) and found none: only two `format::render` call sites pass a non-Human value, and both are get/status. Controls prove the plumbing itself is fine — `status --format json` (main.rs:249-257) and `doctor --format json` (main.rs:817) both emit real JSON. Reproduced on a real pool: `destroy --format json` printed the human Dry-run table to stdout, `gc --format json` printed '🌳 Dry run: would reclaim…', `return --format json` printed only the stderr banner, `destroy --format toon` printed the same human table. WHERE I DISSENT: severity HIGH is overstated. All four commands work correctly in human mode with Go-byte-exact output — no data loss, no wrong worktree destroyed, no safety invariant missing. The only consequence is that a script parsing `--format json` gets human text on stdout, i.e. a wrong output shape on a not-yet-shipped P1 item, which the rubric assigns to MEDIUM ("feature gap or wrong exit code/JSON shape"), not HIGH ("core command broken or safety invariant missing"). Further, for prune/destroy the JSON payloads are ALREADY implemented in treehouse-core/src/result.rs:213-218 and format.rs render_json/render_toon already handle those variants — only the call site hardcodes Human, so it is closer to a one-line wiring bug than a missing feature, which reinforces MEDIUM. `return` is likewise merely unwired (result.rs:208 has a Return payload); only `gc` genuinely lacks a CommandResult variant. Bucket P1_UPGRADE stands: promised by the plan, absent upstream, plan checkbox still open.
+
+
+
+---
+
+### M-019 — Emit exit status 3 when a dirty worktree is left unreturned (`get` and `return`)
+
+| | |
+|---|---|
+| **Severity** | MEDIUM |
+| **Bucket** | **UPSTREAM_DRIFT** — upstream thêm sau v2.1.1, Rust chưa có (lag hợp lệ) |
+| **Domain** | CLI surface — exit codes |
+| **Confidence** | HIGH |
+| **Verify** | 1/2 lens đồng ý (đã sửa bucket) |
+
+**Triệu chứng / tác động.**
+
+> Verified: interactive `treehouse get` whose subshell exited 1 leaving a dirty worktree returned EXIT=0 with no diagnostic at all, and the slot stayed `dirty` in `status`. Scripts doing `treehouse get && next-step` proceed as if the slot was recycled; the pool then silently starves because `get` skips dirty slots and `prune` will not reclaim them. This is the exact failure mode the v3.0.0 breaking change was made to close.
+
+**Rust — hiện trạng.**
+
+> crates/treehouse/src/main.rs:58-61 is the only exit path for failures: `eprintln!("{e:#}"); std::process::exit(1);`. Grep for `exit(3)` / `ExitNotReturned` / `NOT_RETURNED` across crates/ returns nothing. crates/treehouse/src/main.rs:169-172 (`eprintln!("Worktree left dirty. …"); return Ok(())`) and main.rs:227-230 (`eprintln!("Aborted."); return Ok(())`) both exit 0.
+
+**Go — tham chiếu.**
+
+> cmd/exit.go:9-20 defines `ExitNotReturned = 3`; exit.go:44-53 maps a tagged error to it. Used at cmd/get.go:227-230 (`return withExitCode(ExitNotReturned, ...)` when the subshell leaves the worktree dirty and the prompt is declined) and cmd/return_cmd.go:119-128 (both abort arms) and return_cmd.go:327-330 (`return --all` aborts). CHANGELOG.md v3.0.0 lists this as a BREAKING CHANGE with the rationale "Callers that read exit 0 as 'the slot was released' were reading the bug this fixes; scripts under `set -e`, or using `treehouse return "$p" && next-step`, will now stop or branch differently at a dirty abort."
+
+**Cách sửa.**
+
+> Add an `ExitCode` enum (Failure=1, NotReturned=3) threaded through the anyhow error as a downcastable marker, and have `main` map it to `process::exit(3)`; make the get-dirty-bail and the return-abort arms return the tagged error instead of `Ok(())`.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> CONFIRMED on both sides, with two corrections. Go: cmd/exit.go:19 defines ExitNotReturned=3, ExitCode() at exit.go:44-53 unwraps the tagged error, main.go:33 applies it; used at cmd/get.go:227, cmd/return_cmd.go:120, :125, :327. CHANGELOG.md:22 (v3.0.0 BREAKING CHANGES) carries the quoted rationale verbatim. Crucially, `git show v2.1.1:cmd/exit.go` fails with "path exists on disk, but not in v2.1.1", and at v2.1.1 get.go:100 and return_cmd.go:75 both `return nil` (plain exit 0) — so this is genuinely post-baseline and UPSTREAM_DRIFT is the right bucket, not P0_PARITY. Rust: the only error exit is main.rs:59-60, an unconditional exit(1); main.rs:802 is `run`'s child-status passthrough and :846 is `doctor`, both unrelated. An aggressive synonym grep across all of crates/ for exit.?code|exit.?status|not.?returned|ExitFailure|code == 3 found no exit-3 concept under any alias or feature flag. main.rs:169-171 (get dirty bail) and main.rs:228-229 (return abort) both return Ok(()) → exit 0. CORRECTION 1: one cited Go site is N/A — return_cmd.go:327 is the `return --all` arm, and Rust's ReturnArgs (crates/treehouse/src/cli.rs:102-114) has no `--all` flag at all, so that arm cannot be exercised; the live gap is the other three sites. CORRECTION 2: severity overstated. docs/rust-port-plan.md:618 explicitly specifies the current Rust behavior ("Special cases: `return`/`get` dirty-prompt declined → exit 0"), so Rust is faithfully implementing its own design contract rather than erring; and the defect is strictly a wrong exit code — `return` performs the correct action on disk. The rubric grades "wrong exit code/JSON shape" as MEDIUM and reserves HIGH for a core command broken or a safety invariant missing, neither of which holds. The automation hazard (scripts doing `treehouse return "$p" && next-step` proceeding past a leaked slot) is real but belongs to the MEDIUM class.
+
+
+
+---
+
+### M-020 — Prune and destroy resolve one repo root for the whole pool instead of per-worktree, so a shared pool corrupts other clones' worktrees
+
+| | |
+|---|---|
+| **Severity** | MEDIUM |
+| **Bucket** | **UPSTREAM_DRIFT** — upstream thêm sau v2.1.1, Rust chưa có (lag hợp lệ) |
+| **Domain** | pool semantics — prune/destroy reclaim scope |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> On a pool shared by two clones, `treehouse prune --yes` or `destroy --all` runs `git worktree remove` from the wrong repository root. Git either errors out (leaving the stale registration to accumulate) or, worse, deregisters/rewrites bookkeeping belonging to a different clone. For the `--all` sweeps `self.root` is not even a git repo, so the removal always fails and prune silently degrades to "records a CleanupError, keeps the entry" forever.
+
+**Rust — hiện trạng.**
+
+> /Users/tranquangdang21/Projects/treehouse_rust/crates/treehouse-core/src/prune.rs:106-110 (`main_repo_root(&self.root)` once) then prune.rs:368-375 passes that single `self.root` to `worktree_remove`. Symmetrically crates/treehouse-core/src/destroy.rs:105-117 resolves one `repo_root` and destroy.rs:430-436 reuses it for every target. When the pool is opened via `Pool::open_at` (prune --all / gc --all path), `self.root` is the pool dir's PARENT, i.e. `~/.treehouse` — not a repository at all.
+
+**Go — tham chiếu.**
+
+> /tmp/th-baseline/treehouse/internal/pool/destroy.go:614-620 (resolvePoolRepoRoot: "A pool may be shared by multiple clones, so callers must resolve every slot independently and must not apply one slot's root to another"), used at destroy.go:588 and prune.go:405; the resolver is worktreePruneContextResolver() (prune.go:401-418) with a per-slot context cache. Feature 94c092f "pool: let prune and destroy reclaim worktrees from other clones in a shared pool (#146)".
+
+**Cách sửa.**
+
+> Add a per-worktree repo-root resolver (mirror `resolvePoolRepoRoot`: `main_repo_root(&wt.path)`) and call it inside the analyze loop and again in the execute phase for each reserved worktree, memoized by resolved root. Thread the resolved root into `worktree_remove` and into `default_branch_merge_ref` per slot.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> The gap is real and the bucket is correct, but the severity rationale is wrong. Rust genuinely lacks per-worktree root resolution: prune.rs:106-110 resolves main_repo_root(&self.root) once, prune.rs:368-371 passes common_dir: self.root.clone() to worktree_remove for every slot, and destroy.rs:105-117 / destroy.rs:430-436 plus gc.rs:102-106 / gc.rs:351-354 are symmetric. Only pool.rs:335 (release_conditional) resolves per-worktree. Go has the opposite: destroy.go:614-620 resolvePoolRepoRoot (with an explicit doc comment that callers "must not apply one slot's root to another", used at destroy.go:588) and prune.go:394-398 singleRepoPruneContextResolver which discards the caller-supplied repoRoot and delegates to the per-slot worktreePruneContextResolver at prune.go:401-418. Bucket UPSTREAM_DRIFT confirmed: `git tag --contains 94c092f` yields only v3.0.0, v3.0.1, v3.1.0 — strictly after the v2.1.1 port baseline. WHAT I REFUTE is the claimed "corrupts other clones' worktrees" / data-loss framing. I tested it directly: `git -C worktree remove --force ` returns "fatal: '' is not a working tree" (exit 128, target untouched), and `git -C worktree remove` returns "fatal: not a git repository". Git validates the target is a registered worktree of the invoking repo, so a foreign clone's worktree is never a candidate. Rust handles that error safely too: prune.rs:375-385 sets cleanup_ok=false, which skips the remove_dir_all at prune.rs:387 and retains the state entry for retry; destroy.rs:437-449 restores the reservation and pushes a skip before ever reaching remove_dir_all_guarded at destroy.rs:451. So there is no silent corruption, no wrong worktree deleted, and no data loss. The actual impact is a loud failure — affected slots land in errors[]/skipped[] instead of being reclaimed — which is precisely the scope of upstream feature #146. Worst case is functional: with Pool::open_at (pool.rs:163-166 sets self.root to the pool dir's parent, i.e. ~/.treehouse, not a repository) the `prune --all` and `gc --all` paths at main.rs:290 and main.rs:402 cannot reclaim anything and record a git_worktree_remove error per slot. That is a genuine feature gap in a cleanup command, not a safety invariant violation, so MEDIUM rather than HIGH.
+
+
+
+---
+
+### M-021 — acquire has no markerless-slot fail-closed: a slot whose .git is gone is read through the enclosing repository
+
+| | |
+|---|---|
+| **Severity** | MEDIUM |
+| **Bucket** | **UPSTREAM_DRIFT** — upstream thêm sau v2.1.1, Rust chưa có (lag hợp lệ) |
+| **Domain** | pool semantics — acquire slot eligibility |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> With an in-project pool (`root = "."`, supported by config.rs:174-178), a slot whose `.git` file has been deleted makes `is_dirty` and `is_worktree_in_use` run with cwd inside the pool, which git resolves upward to the ENCLOSING repository. Those checks then vouch for the enclosing repo — which is clean and merged — and the slot is marked available. `reset_worktree` (pool.rs:221) then runs `checkout --detach --force` + `reset --hard` + `clean -fd` in the enclosing repository, rewriting the user's actual working tree and deleting its untracked files. This is the worst-case outcome in this whole domain.
+
+**Rust — hiện trạng.**
+
+> /Users/tranquangdang21/Projects/treehouse_rust/crates/treehouse-core/src/pool.rs:478-498 — the reuse loop has no marker/flavor check of any kind. Proof: `grep -rn --include='*.rs' -i 'flavor|marker|WorktreeBackendName' crates/treehouse-core/src/pool.rs` returns 0 hits, and the GitBackend trait (git/mod.rs:60-127) exposes no backend-name accessor at all. The classification that exists in worktree.rs:193-197 (`backing_repo_missing` → Unverified) is never consulted on the acquire path.
+
+**Go — tham chiếu.**
+
+> /tmp/th-baseline/treehouse/internal/pool/pool.go:499-518 (the `vcs.WorktreeBackendName(wt.Path) == ""` branch, which `continue`s and counts `otherFlavor`) and pool.go:640-646 (the "reuse a pooled worktree only for the clone that owns it" comment naming the in-project-pool hazard). Feature c88b53e (#110), v2.3.0.
+
+**Cách sửa.**
+
+> Add a `worktree_backend_name(path) -> String` to GitBackend (reads the `.git`/`.jj` marker, no fallback), and in acquire_locked `continue` on an empty result before any per-slot check runs — same as Go. Also skip in `status` and classify Damaged, as Go does.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> CONFIRMED on the Rust side after five refutation attempts. (1) Grepped all of crates/ for flavor|marker|backend_name|WorktreeBackendName — only unrelated hits (main.rs:815,824,829 JSON output markers; state.rs:187 unrelated doc comment). (2) The GitBackend trait (git/mod.rs:62-127) has 18 methods and exposes no backend-name accessor. (3) heal_state (state.rs:272-286) checks only Path::new(&wt.path).exists() at line 275 — a directory that exists with .git removed survives into the reuse loop. (4) The .git marker check at state.rs:232 is inside recover_corrupt_state, which runs only when the state FILE fails to parse (line 205) and hard-codes leased:true (line 242), which acquire_locked rejects at pool.rs:481 — unreachable from reuse. (5) Go pool.go:468-480 is verbatim as cited, and `git show v2.1.1:internal/pool/pool.go | grep WorktreeBackendName` returns 0 hits, confirming the guard postdates the Rust baseline. The Rust hazard chain is complete: pool.rs:490 is_dirty -> shell.rs:320-327 runs `git status` with cwd = the markerless slot (git walks UP to the enclosing repo) -> pool.rs:221-223 -> shell.rs:329-351 reset_worktree resolves repo_root via `rev-parse --show-toplevel` (shell.rs:180, also walks up) then runs checkout --detach --force + reset --hard + clean -fd against that enclosing repo — exactly the hazard the Go comment names.
+>
+> TWO CORRECTIONS TO THE CLAIM. (a) The second Go citation is WRONG: pool.go:640-646 is the templated-path comment, not the clone-identity guard. The real clone guard is pool.go:485-501 with the quoted text in the error string at pool.go:633, and it is a SEPARATE feature (commit 1185dc6, #145, tagged v3.0.0~5) versus the markerless guard (c88b53e, #110, v2.3.0~1). The finder bundled two releases' work into one finding. (b) Severity HIGH is overstated: Rust's default pool root is home_dir() (config.rs:159-163), out-of-project, so in the default configuration a markerless slot has no enclosing repo, `git status` fails, and pool.rs:490's map_err(PoolError::Git)? aborts the whole get loudly — no corruption. Silent corruption requires the pool root pointed inside a repo, reachable via the env-configurable root (config.rs:553-555) but non-default, and the in-project pool mode the Go comment defends against (fdb3f99, v2.2.0) is itself unported (0 hits for info/exclude|in-project). Bucket UPSTREAM_DRIFT is correct. A reviewer who weights "configurable pool root inside a monorepo" as ordinary could argue HIGH; on the shipped default it is a hard error, so MEDIUM.
+
+
+
+---
+
+### M-022 — Corrupt-state recovery aborts the entire pool on ONE unreadable slot, instead of recovering the healthy slots around it
+
+| | |
+|---|---|
+| **Severity** | MEDIUM |
+| **Bucket** | **UPSTREAM_DRIFT** — upstream thêm sau v2.1.1, Rust chưa có (lag hợp lệ) |
+| **Domain** | corrupted / partial state recovery |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> One broken slot (dangling `.git` symlink, EACCES on the marker, an ELOOP loop) makes EVERY pool command fail with `StateError::RecoverScan` — status, get, return, destroy, prune, gc all brick. Go recovers the healthy slots and quarantines only the broken one. Because `read_state` is on the path of every operation, this is a denial-of-service on the whole pool from a single stray symlink that any process could create inside a slot.
+
+**Rust — hiện trạng.**
+
+> crates/treehouse-core/src/state.rs:232-236 `match std::fs::metadata(wt_path.join(".git")) { Ok(_) => {} Err(e) if e.kind() == NotFound => continue, Err(e) => return Err(StateError::RecoverScan(state_path.clone(), e)) }` — the non-NotFound arm returns Err for the WHOLE scan. Empirically verified: pool with one healthy slot + one slot whose `.git` is a self-referential symlink (ELOOP) returned `Err: ... recovery could not scan: Too many levels of symbolic links (os error 62)` — zero entries recovered.
+
+**Go — tham chiếu.**
+
+> state.go:393-403 `recoverOneWorktree` is the single shared resolver: on marker read failure it prints a WARNING and returns `quarantineEntry(slotName, wtPath, err.Error()), true` — recovered, not fatal. state.go:437-439 calls it per-slot inside `recoverCorruptState`; state.go:336 calls the same function inside `recoverMissingStateEntries`, which is why recovery_symmetry_test.go:45-90 can assert both paths behave identically. Pinned by recovery_skip_test.go:41-70 (`TestReadState_RecoverySkipsUnreadableMarkerSlot` — "recovery must not abort because of one broken slot") and :75-92 for the missing-state half. The commit that fixed this asymmetry is 0e87c62/ee8652b-era, i.e. AFTER the v2.1.1 baseline.
+
+**Cách sửa.**
+
+> Extract a `recover_one_worktree(slot_name, wt_path) -> Option` mirroring state.go:393-403, and replace the `Err(e) => return Err(...)` arm with a quarantine entry carrying the error text. To match Go's `RecoveryError` field you also need that field on `WorktreeEntry`; short term, reuse `RECOVERED_LEASE_HOLDER` and log the reason to stderr as Go does at state.go:396.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> CONFIRMED on both sides; the bucket is right, the severity is not.
+>
+> GO SIDE — every citation verified. `recoverOneWorktree` at internal/pool/state.go:393-403 is the single shared resolver: on marker read failure it prints a WARNING to stderr and returns `quarantineEntry(slotName, wtPath, err.Error()), true` at line 397 — i.e. recovered-as-damaged, not fatal. It is called per-slot inside `recoverCorruptState` at state.go:435 (claim said 437-439; the call is 435, append 436 — immaterial) and inside `recoverMissingStateEntries` at state.go:336, which is why `recovery_symmetry_test.go:45-90` can assert both paths agree. `RecoveryError` is a real field at state.go:59-67. `recovery_skip_test.go:41-70` (`TestReadState_RecoverySkipsUnreadableMarkerSlot`) pins the corrupt half with the explicit string "recovery must not abort because of one broken slot"; :72-91 pins the missing-state half.
+>
+> RUST SIDE — gap is real. `recover_corrupt_state` at crates/treehouse-core/src/state.rs:232-236 returns `Err(StateError::RecoverScan(...))` for the WHOLE scan on any non-NotFound metadata error. I reproduced the exact arm logic standalone against a healthy slot plus a self-referential `.git` symlink: slot 1 recovered=true, slot 2 aborts the entire scan with "Too many levels of symbolic links (os error 62) (FilesystemLoop)". Zero entries recovered. Two aggravating facts the finder missed: (1) `WorktreeEntry` (state.rs:87-115) has NO `recovery_error` field, so Rust cannot represent Go's damaged-slot state even if it wanted to — `grep -rn "recovery_error\|RecoveryError" crates/` returns nothing; (2) `recoverMissingStateEntries` does not exist anywhere in crates/ — the missing-state path at state.rs:160 just returns `State::default()`, so orphans are never recovered there either. So the two-path symmetry Go guarantees is absent on both halves.
+>
+> WHY UPSTREAM_DRIFT (not P0_PARITY) — the decisive fact. The Rust code is a FAITHFUL port of the v2.1.1 baseline the plan targets. At `git show v2.1.1:internal/pool/state.go:123` Go did precisely the same thing: `os.Stat(.git)` and on `!os.IsNotExist(err)` it `return State{}, fmt.Errorf(...recovery could not inspect %s...)` — fail-closed, identical semantics. `git log -S recoverOneWorktree` shows it was introduced by ee8652b (PR #134), and tag counts confirm v2.1.1=0, v2.2.0=0, v2.2.1=0, v2.3.0=0, v3.0.0=4. So the refinement landed after the port's stated baseline. Legitimate lag, not a port bug. (Minor correction to the claim's prose: the commit is ee8652b, not 0e87c62 — 0e87c62 is the unrelated .worktreeinclude feature; the "after v2.1.1" conclusion still holds.)
+>
+> WHY DOWNGRADED TO MEDIUM. The port plan at docs/rust-port-plan.md:61 and :533 explicitly specifies "fail closed if the scan can't complete" / "fail closed if scan fails" — the Rust behavior is the PROMISED CONTRACT, implemented as designed, not a defect. The failure is loud, not silent: it surfaces as `PoolError::State` and exits non-zero (main.rs:328-330 for prune; every command in pool.rs/destroy.rs/prune.rs/gc.rs/doctor.rs does the same `.map_err(PoolError::State)`). It fails in the SAFE direction — refusing to return partial state rather than guessing ownership. It requires a compound precondition: the state file must ALREADY be corrupt/truncated AND a slot marker must be unreadable. No data loss, no wrong worktree deleted, nothing destructive — the user gets an actionable error. A command that correctly refuses to act unsafely is not "broken"; this is a robustness/feature gap in a rare path (missing per-slot quarantine + RecoveryError reporting), which is exactly MEDIUM.
+
+
+
+---
+
+### M-023 — Restore the global `--root` flag and the `TREEHOUSE_ROOT` environment variable
+
+| | |
+|---|---|
+| **Severity** | MEDIUM |
+| **Bucket** | **UPSTREAM_DRIFT** — upstream thêm sau v2.1.1, Rust chưa có (lag hợp lệ) |
+| **Domain** | CLI surface — flags |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> Root redirection by flag or env is the documented way to keep a pool off `$HOME`, put it in-project (`--root .`), or point a shared pool at a specific disk. In Rust the only lever is the differently-named `--env-path`, and any user or CI image that sets `TREEHOUSE_ROOT` gets it silently discarded — the pool is written to the default location with no warning, which can fill a disk the user explicitly tried to keep clear.
+
+**Rust — hiện trạng.**
+
+> `grep -rn 'TREEHOUSE_ROOT|root_flag|ResolveRoot' crates/` → zero hits; the only env vars the Rust tree reads are TREEHOUSE_DIR, TREEHOUSE_LEASE_HOLDER, TREEHOUSE_LEASE_TTL, TREEHOUSE_NO_UPDATE_CHECK. crates/treehouse/src/cli.rs:30-32 offers `--env-path ` instead. Verified: `--root /tmp/whatever` → `error: unexpected argument '--root' found`; `TREEHOUSE_ROOT=/tmp/th-probe3-poolroot treehouse status` silently ignored the variable and created nothing there.
+
+**Go — tham chiếu.**
+
+> cmd/root.go:62-63 `rootCmd.PersistentFlags().StringVar(&rootFlag, "root", "", "Worktree root directory, overriding TREEHOUSE_ROOT and config; relative paths (e.g. \".\" for an in-project pool) resolve from the repo root")`, consumed through `config.ResolveRoot(rootFlag, cfg)` by every repo-scoped command (get.go:152, enter.go:55, status.go:56, lease.go:64, prune.go:79, return_cmd.go:565). Added in v2.2.0 (#93).
+
+**Cách sửa.**
+
+> Add a global `--root ` alongside (or renaming) `--env-path`, give it the Go precedence (`flag > TREEHOUSE_ROOT > treehouse.toml root > ~/.treehouse`), and resolve relative values like `.` against the repo root rather than the process cwd.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> Attempted refutation failed on every axis; the gap is confirmed and in fact worse than claimed.
+>
+> GO SIDE (all citations verified by direct read): cmd/root.go:62-63 defines the persistent `--root` flag with the exact help string quoted. internal/config/config.go:63 declares `const RootEnvVar = "TREEHOUSE_ROOT"`; config.go:110-117 implements ResolveRoot with flag -> env -> config precedence, matching the documented contract. All claimed consumption sites exist: get.go:152, enter.go:55, status.go:56, lease.go:64, prune.go:79, return_cmd.go:564, plus destroy.go:210 (which the claim omitted). Only nit: the claim cites return_cmd.go:565 but the actual call is on line 564.
+>
+> RUST SIDE (absence proven by aggressive grep, not inference): `grep -rhoE 'TREEHOUSE_[A-Z_]+' crates/*/src/` returns only TREEHOUSE_DIR, TREEHOUSE_LEASE_HOLDER, TREEHOUSE_LEASE_ID, TREEHOUSE_LEASE_TTL, TREEHOUSE_NO_UPDATE_CHECK. TREEHOUSE_ROOT appears nowhere in the entire repo (excluding target/). Full flag inventory of cli.rs shows no `--root` and no visible/hidden alias; the only global args are --format and --env-path. Empirical: `treehouse status --root /tmp/x` exits with clap usage error code 2; `TREEHOUSE_ROOT=/tmp/thv/envroot treehouse status` created nothing at that path.
+>
+> STRONGER THAN CLAIMED: the implied substitute `--env-path` is inert, not merely differently named. cli.rs:246-315 constructs a CliEnv overriding pool_root(), but pool.rs:121 calls the NON-env `resolve_pool_dir`, which hardcodes home_dir() at config.rs:163-166. The env-aware resolve_pool_root_with_env/resolve_pool_dir_with_env have only test callers (config.rs:552-578), so there is no production path that honors an injected pool root. Verified empirically: `--env-path /tmp/thv2/pool1 get --lease` still wrote to $HOME/.treehouse/repo-63812c and /tmp/thv2/pool1 was never created. Net effect: the Rust port has NO working per-invocation pool-root override at all; only the static treehouse.toml `root` key works (I confirmed both absolute and relative "." cases create the expected pool).
+>
+> BUCKET: UPSTREAM_DRIFT is correct and I confirm it independently. CHANGELOG.md:73-78 places the feature in v2.2.0, after the v2.1.1 baseline named in docs/rust-port-plan.md:5, so it is not P0_PARITY. I also checked it is not P1_UPGRADE: zero matches for --root / TREEHOUSE_ROOT / env-path in docs/rust-port-plan.md, so the plan never promised it.
+>
+> SEVERITY CORRECTED HIGH -> MEDIUM. Per the stated rubric, HIGH requires a broken core command or a missing safety invariant; neither applies. Every command works correctly at the default location, nothing crashes, no wrong worktree is deleted, and prune/destroy safeguards are untouched. This is an override-surface feature gap, which is MEDIUM by definition. The one argument behind HIGH -- an env var silently ignored -- fails toward the safe, well-tested default rather than toward corruption, so it does not reach HIGH.
+
+
+
+---
+
+### M-024 — Restore the seven acquisition flags on `get` and the matching AcquireOptions fields
+
+| | |
+|---|---|
+| **Severity** | MEDIUM |
+| **Bucket** | **UPSTREAM_DRIFT** — upstream thêm sau v2.1.1, Rust chưa có (lag hợp lệ) |
+| **Domain** | CLI surface — flags |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+| **Gộp từ** | gộp cả `--root` (`cmd/root.go:62-63`, #93) |
+
+**Triệu chứng / tác động.**
+
+> Every acquisition-shape control from Go is gone. Concretely broken in Rust: cutting from a non-default branch (`--base`/`base_branch`, v3.0.0 #119), branch-per-agent workflows (`-b`, #150), offline/air-gapped acquires (`--no-fetch`, v2.2.0), seeding ignored files from `.worktreeinclude` or `--include-file` (#129/#131), `--unique-leaf` (#126), a templated `worktree_path` (#140), and APFS copy-on-write sharing (#153). An agent fleet script written against Go cannot be ported by flag rename — the features do not exist.
+
+**Rust — hiện trạng.**
+
+> crates/treehouse/src/cli.rs:76-90 `struct GetArgs` has only `lease`, `lease_holder`, `ttl`, `json`. crates/treehouse-core/src/pool.rs:49-54 `struct AcquireOptions { branch, lease }` — and `branch` is documented "Override the branch to reset to", i.e. it is Go's `BaseBranch`, not Go's `-b` branch creation. crates/treehouse/src/main.rs:144 hardcodes `pool.get(&AcquireOptions::default())`, so even that field is unreachable from the CLI. Verified: every one of `--branch --no-fetch --base --unique-leaf --worktree-path --include-file --apfs-sharing` is rejected with `error: unexpected argument`.
+
+**Go — tham chiếu.**
+
+> cmd/get.go:94-105 registers `--lease`, `--lease-holder`, `--json`, `--no-fetch`, `--branch/-b`, `--base`, `--include-file`, `--unique-leaf`, `--worktree-path`, `--apfs-sharing`, each wired into `pool.AcquireOptions{SkipFetch, Branch, BaseBranch, WorktreePath, IncludeManifest, UniqueLeaf, APFSSharing}` at get.go:161-169.
+
+**Cách sửa.**
+
+> Extend `GetArgs` and `AcquireOptions` with `no_fetch: bool`, `base: Option`, `branch_name: Option` (distinct from the existing reset-to `branch`), `include_file: Option`, `unique_leaf: bool`, `worktree_path: Option`, `apfs_sharing: Option`, plus the Go guard strings (`--json requires --lease`, `--apfs-sharing requires off or fresh`, `--branch requires a non-empty branch name`) and the config/env fallbacks TREEHOUSE_UNIQUE_LEAF / TREEHOUSE_WORKTREE_PATH / TREEHOUSE_APFS_SHARING.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> Attempted refutation on four axes; all failed to overturn the finding. (1) Go citations are exact: cmd/get.go:98-104 registers all seven flags, cmd/get.go:161-169 wires them into pool.AcquireOptions{SkipFetch, Branch, BaseBranch, WorktreePath, IncludeManifest, UniqueLeaf, APFSSharing}, struct at internal/pool/pool.go:96-118. (2) Rust genuinely lacks it, not a naming miss: grepping all of crates/ for no_fetch|no-fetch|SkipFetch, unique_leaf|unique-leaf, apfs, include_file|worktreeinclude|includeManifest, and base_branch|BaseBranch returns ZERO hits. No config-layer fallback either — TreehouseConfig (config.rs:22-31) has only max_trees, root, hooks, lease_ttl_secs. The worktree_path hits in Rust are unrelated (the acquired worktree's own path in run.rs:49, process.rs:78). Runtime confirmed: `treehouse get --branch foo` -> "error: unexpected argument '--branch' found". (3) The claim's nuanced assertion is correct: Rust AcquireOptions.branch (pool.rs:198-201) resolves the branch used to reset the worktree (pool.rs:224-226), which is Go's BaseBranch (pool.go:100-103), not Go's Branch (pool.go:104-106, "creates and checks out a new Git branch"). Rust therefore has neither field under its correct name. (4) BUCKET IS CORRECT AND DECISIVE: `git show v2.1.1:cmd/get.go` registers ONLY --lease, --lease-holder, --json (lines 42-44), and Rust's GetArgs (cli.rs:76-90) has all three plus --ttl — so every v2.1.1 flag is present and P0_PARITY is ruled out. `git log -S` dates them all after the baseline: --no-fetch in v2.2.0 (164d903), the other six in v3.0.0 (#119 base, #126 unique-leaf, #131 include-file, #140 worktree-path, #150 branch, #153 apfs). The port plan's P1 sections (docs/rust-port-plan.md:502-555) promise stale leases/--ttl, gc, crash recovery, doctor, run, and --format json/toon — none of these flags — so P1_UPGRADE does not apply. UPSTREAM_DRIFT stands. WHERE THE FINDING OVERSTATES: severity HIGH requires "core command broken or safety invariant missing." Neither holds. `treehouse get` still acquires and hands off a worktree correctly via main.rs:144; these are opt-in per-acquisition conveniences. None is a safety invariant: unique_leaf/worktree_path are creation-time naming (Go itself documents them as never moving existing worktrees), apfs_sharing is off by default, include_file seeds ignored files. This is a feature gap, which the rubric places at MEDIUM.
+
+
+
+---
+
+### M-025 — No background update check is ever spawned, so the cache is never written and the notice never fires
+
+| | |
+|---|---|
+| **Severity** | MEDIUM |
+| **Bucket** | **P0_PARITY** — port bug: Go baseline v2.1.1 đã có, Rust không tương đương |
+| **Domain** | updater |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> ~/.treehouse/update-check.json is written only when a user manually runs `treehouse update` (main.rs:650), which additionally does not replace the binary. So the update notice at main.rs:47-53 is unreachable in practice and `treehouse update` degrades to a version-string print. Independently, the Go hardening that keeps the child out of the caller's pooled worktree — so `status` does not report a treehouse-owned process and `return` does not kill it — has no counterpart; adding the spawn naively would reintroduce that hazard.
+
+**Rust — hiện trạng.**
+
+> crates/treehouse/src/main.rs:24-39 implements the `--update-check` CHILD side, but grep over crates/ for `current_exe|setsid|CREATE_NEW_PROCESS|process_group|pre_exec` finds nothing that re-spawns the binary — the only std::process::Command::new in main.rs is line 711, which launches the user's login shell. The consuming notice at main.rs:44-55 depends on ~/.treehouse/update-check.json existing.
+
+**Go — tham chiếu.**
+
+> cmd/root.go:55 `_ = updater.SpawnBackgroundCheck(version)` runs on every invocation. internal/updater/updater.go:170-201 SpawnBackgroundCheck, :209-217 backgroundCheckCommand (inherits TREEHOUSE_NO_UPDATE_CHECK=1), :229-241 detachedWorkingDir, :253-296 outsideEnclosingWorktree/enclosingWorktreeRoot, plus sysproc_unix.go:11-15 (Setsid) and sysproc_windows.go:11-15 (CREATE_NEW_PROCESS_GROUP). Confirmed at the v2.1.1 baseline era: `git show v2.2.0:internal/updater/updater.go` line 168.
+
+**Cách sửa.**
+
+> Add spawn_background_check(): resolve std::env::current_exe() plus fs::canonicalize, spawn with TREEHOUSE_NO_UPDATE_CHECK=1 and Stdio::null(), set setsid via pre_exec on unix and CREATE_NEW_PROCESS_GROUP on Windows, and pick a current_dir() outside the enclosing worktree root before spawning.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> CONFIRMED after aggressive search of both trees. Go side citations are accurate: cmd/root.go:55 `_ = updater.SpawnBackgroundCheck(version)` gated on IsCacheStale, and I additionally verified it existed at the port's stated v2.1.1 baseline (`git show v2.1.1:cmd/root.go` line 50; `git show v2.1.1:internal/updater/updater.go` line 168 = func SpawnBackgroundCheck), so P0_PARITY is correct — this is a port bug, not upstream drift. Rust side genuinely lacks the concept: I read all 317 lines of crates/treehouse-core/src/updater.rs, which exposes only Version::parse, cache_path, is_cache_stale, read_cache, check_latest, write_cache, update_available and their _with_env variants — there is no spawn function of any kind. A grep over the whole crates/ tree for current_exe|setsid|SETSID|CREATE_NEW_PROCESS|process_group|pre_exec|std::process::Command|spawn()|detached|nohup|background_check returns only git subprocesses, the curl call at updater.rs:133, the user login shell at main.rs:711, run.rs (supervising the user's own command, unrelated), and e2e tests; process.rs contains no Command/spawn at all. main.rs:24-39 is the --update-check CHILD handler and is therefore dead code, since no parent ever re-execs the binary with that flag. The is_cache_stale call that does exist (main.rs:644) lives inside cmd_update, not in the PersistentPreRun-equivalent notice path — Go gates the spawn on staleness in PersistentPreRun (cmd/root.go:54-55), while Rust main.rs:44-55 only reads an already-existing cache. TWO CORRECTIONS to the finding: (1) 'the cache is never written' is imprecise — cmd_update at main.rs:650 does call write_cache, but only when the user explicitly runs `treehouse update`; the background/per-invocation path the claim targets genuinely never writes it. (2) Severity is MEDIUM, not HIGH: the background check is a passive advisory notice, not a core command and not a safety invariant, with no data loss, no wrong worktree, and no corruption. It is a real feature gap and is explicitly in the plan's own P0 parity scope (docs/rust-port-plan.md:561 and :571), but does not meet the HIGH bar. Note a distinct and more severe defect surfaced during verification but is NOT part of this claim: cmd_update (main.rs:637-661) is a stub that prints 'Successfully updated' without ever downloading or replacing the binary.
+
+
+
+---
+
+---
+
+## 6. Tier 2 — kiến trúc, tính bằng tuần
+
+### M-026 — Thêm release preconditions **cùng lúc** với `lease` subcommand
+
+| | |
+|---|---|
+| **Severity** | MEDIUM |
+| **Bucket** | **UPSTREAM_DRIFT** — upstream thêm sau v2.1.1, Rust chưa có (lag hợp lệ) |
+| **Domain** | Safety invariants — leases and conditional release |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+| **Gộp từ** | gộp `lease` subcommand (#128) |
+
+**Triệu chứng / tác động.**
+
+> The `get` subshell-exit path releases with no precondition at all. If the slot was taken over while the subshell ran — a `treehouse lease` on a live agent home, or a later acquisition — the exiting session still resets the worktree and clears the new owner's reservation, destroying whatever the new owner was doing. Go added `RequireOwnedByCaller` precisely for this. `RequireUnleased` is also missing, so a bulk `return --all` cannot assert "nobody leased this since I listed it", and there is no `RefuseRecovered` guard.
+>
+> **Gộp thêm:** finding `Restore the standalone lease subcommand that durably leases an existing worktree by name` (`cmd/lease.go:21-45`, `internal/pool/pool.go:259`, #128) là consumer đầu tiên của preconditions này.
+
+**Rust — hiện trạng.**
+
+> crates/treehouse-core/src/pool.rs:591-594 (`ReleasePreconditions` has ONLY `expected_lease_id` and `expected_lease_holder`); crates/treehouse-core/src/pool.rs:616-641 (`validate_release_preconditions_inner` implements only the two identity checks); `grep -rni "RequireOwnedByCaller|owned_by_caller|RequireUnleased|require_unleased|RefuseRecovered|refuse_recovered" crates/` → 0 hits each; crates/treehouse/src/main.rs:173 (`pool.release(&path.to_string_lossy())?` — the get subshell exit path is fully UNCONDITIONAL)
+
+**Go — tham chiếu.**
+
+> internal/pool/pool.go:932-955 (ReleasePreconditions carries ExpectedLeaseID, ExpectedLeaseHolder, RequireUnleased, RequireOwnedByCaller, RefuseRecovered) validated by internal/pool/pool.go:1111-1150 (`validateReleasePreconditions`) including internal/pool/pool.go:1417-1438 (`checkOwnedByCaller`, which refuses a slot that is now durably leased, already released, or reserved by another session). Go's `ValidateReleasePreconditions` doc (pool.go:967-981) explains why the guarded action must run under the same lock. Landed in CHANGELOG v3.0.0 via #128 (RequireOwnedByCaller) and #143 (RequireUnleased).
+
+**Cách sửa.**
+
+> Add the three fields to `ReleasePreconditions`, port `checkOwnedByCaller` into `pool.rs` (compare `owner_pid`+`owner_started_at` against this process, testing `leased` FIRST since the lease path zeroes the owner fields), and pass `require_owned_by_caller: true` from the get-exit call site at crates/treehouse/src/main.rs:173.
+>
+> Làm `lease` và `return --all` mà **không** kèm preconditions là hướng nguy hiểm: hai command đó trao quyền release cho bên thứ ba, còn release path hiện không kiểm tra gì.
+
+**Ghi chú.** **Phải ship cùng M-016 (`return `) và M-017 (`return --all`) trong cùng một thay đổi.** Thêm command mà không có precondition này sẽ biến nó thành CRITICAL: `main.rs:173` hiện release slot một cách vô điều kiện, và slot đó có thể đã thuộc về bên thứ ba.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> CONFIRMED REAL, but severity corrected HIGH → MEDIUM. I tried to refute this and could not on existence; I could on severity.
+>
+> EXISTENCE — every citation is exact, on both sides:
+> - Go pool.go:938/942/946/953/957 declares all five fields (ExpectedLeaseID, ExpectedLeaseHolder, RequireUnleased, RequireOwnedByCaller, RefuseRecovered). validateReleasePreconditions at pool.go:1111-1143 checks all three missing ones (1112 RequireOwnedByCaller → checkOwnedByCaller, 1120 RefuseRecovered, 1123 RequireUnleased). checkOwnedByCaller at pool.go:1418-1438 refuses a slot that is now durably leased (1420-1424), already released (1426-1428), or reserved by another session (1436-1438), comparing both PID and start-time.
+> - Rust pool.rs:591-594 `ReleasePreconditions` has exactly two fields; validate_release_preconditions_inner at pool.rs:616-641 implements only the identity checks and short-circuits at 620 when both are None. I grepped every synonym across crates/ (owned_by_caller, require_unleased, refuse_recovered, caller, reserved_by, owner_of, session_id) — the only owner-reservation logic that exists is `Reservation`/`owner_alive` in reservation.rs, wired into acquire (pool.rs:481) and destroy/gc two-phase, never into release.
+> - main.rs:173 `pool.release(&path.to_string_lossy())?` is indeed the fully unconditional get exit path, as claimed.
+>
+> BUCKET — UPSTREAM_DRIFT is correct, verified by git, not memory: `git show v2.1.1:internal/pool/pool.go | grep -c` returns 0 for all three fields. Each was introduced after the baseline by a specific commit: RequireOwnedByCaller by b227e59 (#128), RequireUnleased by 99f4db2 (#143), RefuseRecovered by 706ae59 (#156). #128/#143 are in CHANGELOG v3.0.0, #156 in v3.0.1. P1_UPGRADE does not apply either — port-plan §2.4 (docs/rust-port-plan.md:64-67) promises only `--if-lease-id`/`--if-lease-holder` conditional release, which Rust does implement.
+>
+> WHY MEDIUM, NOT HIGH — all three consumers are themselves absent from the Rust CLI, so the hazard is latent rather than live:
+> 1. RequireOwnedByCaller defends against a concurrent `treehouse lease` taking over a live agent home. That subcommand does not exist in Rust: the Command enum (cli.rs:46-75) has zero occurrences of "Lease", and `grep -c` over it returns 0. Rust only leases at acquisition time (`get --lease`, main.rs:109+), where the leasing process is itself the owner — there is no third party who can lease a slot someone else holds. Acquire additionally skips leased/owner_alive entries (pool.rs:481), so a second `get` cannot take over a live session's slot either.
+> 2. RequireUnleased/RefuseRecovered are consumed only by bulkReturnPreconditions (return_cmd.go:194-202), which backs `return --all` (#143). Rust's ReturnArgs (cli.rs:101-112) has only force / if_lease_id / if_lease_holder / path — no `--all`, no `--name`.
+> 3. The paired HEAD-move hazard is independently absent: Go's guarded DetachWorktree (get.go:196-206) has no Rust counterpart — `detach_and_return` is a no-op stub (main.rs:731-734).
+>
+> So no current Rust invocation can construct the scenario these preconditions guard. It is a real forward-compatibility gap that must land together with `treehouse lease` and `return --all` (adding those without the preconditions is what would make it CRITICAL), not a live safety-invariant breach. A latent missing-safety-net feature gap is MEDIUM, not HIGH.
+
+
+
+---
+
+### M-027 — `treehouse update` never downloads or replaces the binary but reports success
+
+| | |
+|---|---|
+| **Severity** | HIGH |
+| **Bucket** | **P0_PARITY** — port bug: Go baseline v2.1.1 đã có, Rust không tương đương |
+| **Domain** | updater |
+| **Confidence** | HIGH |
+| **Verify** | 2/2 lens đồng ý |
+
+**Triệu chứng / tác động.**
+
+> A user runs `treehouse update`, sees "🌳 Successfully updated treehouse 0.1.1 -> 0.1.2", and the binary on disk is still 0.1.1. There is no way to self-update and no error ever surfaces. Secondary: the network-failure branch (main.rs:653-656) prints a message and returns Ok(0), where Go returns a wrapped error (exit 1) at cmd/update.go:22-24 — a script gating on exit status reads an unreachable network as success.
+
+**Rust — hiện trạng.**
+
+> crates/treehouse/src/main.rs:635-666 — cmd_update only reads the cache or calls check_latest, then prints "🌳 Successfully updated treehouse {current} -> {latest}". No download, no replace. Grep over crates/ for `sha256|sha2|checksum|atomic_replace|tar::|flate2|zip::|quarantine` returns only the pool-dir hash in git/mod.rs:131 and the doc comment in updater.rs:6 — no implementation.
+
+**Go — tham chiếu.**
+
+> /tmp/th-baseline/treehouse/cmd/update.go:21-37 — updater.CheckLatest(version) then updater.Apply(result); Apply at internal/updater/updater.go:319-370 downloads (downloadToTemp:542), verifies sha256 (verifyChecksum:482-529), extracts (extractTarGz:580 / extractZip:634), removes macOS quarantine (quarantine_darwin.go:8-12), and atomically replaces the exe (atomicReplace:679-733).
+
+**Cách sửa.**
+
+> Port Apply(): download the release asset to a temp file, verify against the release .sha256 sidecar, extract the binary, chmod 0755, then write a temp file in the target's own directory and rename over the target (same-dir temp so the rename stays on one filesystem). Return Err on every failure path.
+
+
+Lý do verify (2 lens đối kháng — giữ nguyên để đối chiếu khi fix)
+
+> The gap is REAL — I confirmed it on both sides, but the claimed bucket and severity are both wrong.
+>
+> RUST SIDE (confirmed absent): cmd_update at crates/treehouse/src/main.rs:635-666 only reads the cache or calls check_latest, then at :660-661 prints "Successfully updated treehouse {current} -> {latest}" with no download and no binary replacement. Aggressive synonym grep over the whole crates/ tree returned ZERO hits for: tar::/flate2/zip::/gzip/Archive::new (0), quarantine/xattr/com.apple (0), atomic_replace (0), and no fn apply/fn download/browser_download_url. sha256/sha2 appears only at git/mod.rs:131-133 (the 6-hex pool-dir hash) and in the doc comment at updater.rs:6. No feature flag gates it (crates/treehouse/Cargo.toml exposes only `toon`; treehouse-core adds only `hardening`), and there is no todo!/unimplemented!/FIXME stub. No tar/flate2/zip/reqwest dep exists in the workspace — the machinery was never written. Note the module doc comment at updater.rs:6-7 PROMISES "update downloads the release asset, verifies sha256, and atomically replaces the executable"; only the version-check/cache half was implemented. The false-success line is reachable, not dead code: live check returns a newer tag -> write_cache at :650 -> update_available true at :660 -> prints success at :661.
+>
+> GO SIDE (citations all verified accurate): cmd/update.go:32 calls updater.Apply(result); Apply at internal/updater/updater.go:319-370 calls downloadToTemp:542, verifyChecksum:482, extractBinary, removeQuarantine (quarantine_darwin.go:8-12, xattr -d com.apple.quarantine), and atomicReplace:679-733. I opened each cited line and confirmed.
+>
+> BUCKET CORRECTION -> P0_PARITY (claimed BUG_RUST is wrong). I checked the port's baseline instead of trusting the finder: docs/rust-port-plan.md:5 declares baseline Go v2.1.1, and `git show v2.1.1:cmd/update.go` contains the full updater.Apply(result) call with the complete download->verify->extract->de-quarantine->atomic-replace pipeline ALREADY PRESENT at v2.1.1. So this is NOT upstream drift and NOT a P1 item. The plan lists `update` in P0 scope twice (line 561 deliverables, line 571 definition-of-done). P0_PARITY is defined as "behavior the Go v2.1.1 baseline already had; Rust never matched it. This is a port bug" — an exact match. BUG_RUST ("has the concept but implements it wrongly/unsafely") does not fit: there is no subtly-wrong implementation to misclassify, the pipeline is simply absent.
+>
+> SEVERITY CORRECTION -> HIGH (claimed CRITICAL is wrong). The stated CRITICAL triggers are data loss, wrong worktree deleted, silent corruption, or security — none apply. It in fact fails SAFE: the binary is never replaced, so there is no path to installing an unverified or corrupt binary (no security exposure). What remains is "core command broken," which the rubric assigns to HIGH. The aggravating factor that keeps it from MEDIUM is that the success message is affirmatively false, so a user believes they are on the latest version when they are not — a misleading report, not corruption.
+>
+> Confidence HIGH: read both codebases, every claim cited and verified, baseline established via git rather than inference.
+
+
+
+---
+
+### M-028 — VCS seam (viết lại kiến trúc)
+
+**Đây là một việc, không phải sáu gap.**
+
+Go đã tách `internal/vcs/` (interface) + `internal/vcs/gitvcs/` + `internal/vcs/jjvcs/` + `internal/fileclone/`, tất cả land cùng một commit `a2e554c` (v2.3.0). Rust vẫn là `GitBackend` phẳng, git-only — `grep -rn "jj\|jujutsu" crates/` → **0 hit**.
+
+Làm seam này thì **một lượt** thu được:
+- markerless/flavor check trong acquire (M-020)
+- hardening markerless (đã vá ở M-002 — khi có seam thì làm đúng cách thay vì vá cục bộ)
+- `--apfs-sharing` (#153)
+- `.worktreeinclude` seeding (#129) + per-acquisition seed manifest (#131)
+- plumbing cho `--include-file` và `--unique-leaf`
+
+Vá từng mục riêng lẻ trên `GitBackend` phẳng nghĩa là phải làm lại `pool.rs`, `prune.rs`, `destroy.rs`, `gc.rs`, `run.rs` **ba lần**.
+
+**Bằng chứng Rust hoàn toàn không có:**
+| Khái niệm | Grep trong `crates/` |
+|---|---|
+| `jj` / `jujutsu` | 0 hit |
+| `apfs` / `clonefile` / `fileclone` | 0 hit |
+| `base_branch` | 0 hit |
+| `unique_leaf` | 0 hit |
+| `worktree_path` | 0 hit |
+| `gitignore` (`.git/info/exclude`, in-project pool) | 0 hit |
+| `.worktreeinclude` | 0 hit |
+
+> 21 hit "seed" trong Rust **không phải** seeding — tất cả là test double `TreehouseEnv::seed_file` tại `env.rs:199`.
+
+---
+
+## 7. Bị refute (đã kiểm tra, không phải gap)
+
+**Claim:** Rust thiếu `version` field trong state file ⇒ Go sẽ quarantine toàn bộ worktree.
+
+**Kết luận: sai.** `internal/pool/state.go:143` có carve-out tường minh:
+`legacy := s.Version == 0 && (keyErr == nil || errors.Is(keyErr, fs.ErrNotExist))` → `if legacy && !hasSeedState(*wt) { setSeedInventory(wt, nil, true); continue }`. Hai disjunct là exhaustive nên luôn fire với file do Rust ghi; khối quarantine tại `:150-161` không bao giờ tới được. Comment tại `:139-142` gọi đúng case này, và `upgrade_test.go:130` (`TestUpgrade_AdoptsTwoXRewriteBesideKey`) ghim nó lại.
+
+Phần còn lại đúng: HMAC seed-inventory guarantee của Go bị vô hiệu trên đúng pool đó — **wire-format nit mức LOW**, không phải CRITICAL.
+
+---
+
+## 8. Lead chưa verify (54) — coi là gợi ý
+
+Tìm được **83 finding**, verify 30 cái top theo severity → 29 sống. 54 cái dưới đây **chưa qua lens đối kháng**. Trước khi fix, hãy tự đọc code.
+
+
+| Sev | Bucket | Lead |
+|---|---|---|
+| CRITICAL | UPSTREAM_DRIFT | No state `version` field: Rust silently downgrades a Go v3 pool to version 0, which makes Go quarantine every worktree as recovered |
+| HIGH | UPSTREAM_DRIFT | install.ps1 cannot replace a running treehouse.exe (the bug class Go fixed in v3.0.0 #121) |
+| MEDIUM | BUG_RUST | Both installers skip checksum verification when the sidecar is missing instead of failing |
+| MEDIUM | BUG_RUST | `Version::parse` rejects partial versions, so the update notice silently never appears |
+| MEDIUM | BUG_RUST | Update check shells out to `curl` with no timeout instead of using an HTTP client |
+| MEDIUM | BUG_RUST | Check release preconditions before prompting to discard uncommitted changes in `return` |
+| MEDIUM | BUG_RUST | Implement the worktree detach in `detach_and_return` instead of returning Ok(()) |
+| MEDIUM | BUG_RUST | Make `treehouse update` actually apply the update instead of printing success |
+| MEDIUM | BUG_RUST | Restore the removableWorktreeContainer guard: pool-containment, canonicalization, and reject "." |
+| MEDIUM | BUG_RUST | get's post-reset re-validation does not actually check the reservation |
+| MEDIUM | P0_PARITY | Port the in-project pool exclusion (.gitignore self-ignore + info/exclude) into get |
+| MEDIUM | P0_PARITY | Fall back to the current directory when `return` is given neither a path nor `$TREEHOUSE_DIR` |
+| MEDIUM | P0_PARITY | Map flag-parsing and argument errors to exit 1, matching Go's reserved-code policy |
+| MEDIUM | P0_PARITY | Report the actual available slot names in `enter`'s not-found error instead of always claiming the pool is empty |
+| MEDIUM | P0_PARITY | Restore destroy's skip hints, the "Skipped N worktrees:" header, and the other-flavor migration framing |
+| MEDIUM | P0_PARITY | Add the missing `--include-leased` + `--all` refusal on destroy |
+| MEDIUM | P0_PARITY | Restore the branch/recovery columns in `status` human output and the base-branch header line |
+| MEDIUM | P0_PARITY | Match Go's `status --json` schema: add the always-present `branch` key and Go's field order |
+| MEDIUM | P0_PARITY | Match Go's `init` message and emit the full documented config template |
+| MEDIUM | P0_PARITY | Read and heal pool state under the state lock when building the destroy target list |
+| MEDIUM | P0_PARITY | prune removes only the leaf worktree directory and has no pool-ownership guard on the removal path |
+| MEDIUM | P1_UPGRADE | Port plan §8 P0 gate "Go tests → Rust tests" is roughly a quarter met: 166 Rust tests vs 654 Go tests |
+| MEDIUM | P1_UPGRADE | Rust emits an `expires_at` key that Go has no field for — Go silently discards it, so a TTL lease becomes permanent after any Go write |
+| MEDIUM | UPSTREAM_DRIFT | Config schema silently ignores five upstream keys with no warning |
+| MEDIUM | UPSTREAM_DRIFT | `TREEHOUSE_ROOT` and the `--root` flag are absent — the whole root-precedence chain is gone |
+| MEDIUM | UPSTREAM_DRIFT | No exit code 3 for a dirty worktree that was not returned |
+| MEDIUM | UPSTREAM_DRIFT | Release matrix drops windows/arm64 and there is no Nix packaging path |
+| MEDIUM | UPSTREAM_DRIFT | Introduce a VcsBackend seam so jj (and future backends) can be added without rewriting call sites |
+| MEDIUM | UPSTREAM_DRIFT | Add Jujutsu workspace backend with slot-flavor dispatch |
+| MEDIUM | UPSTREAM_DRIFT | Implement .worktreeinclude seeding of ignored files into a new or recycled worktree |
+| MEDIUM | UPSTREAM_DRIFT | Detect squash-merged worktrees via path-scoped tree comparison when ancestry is absent |
+| MEDIUM | UPSTREAM_DRIFT | Support a configurable base branch for get, validated before any worktree is created or reset |
+| MEDIUM | UPSTREAM_DRIFT | Wait for SIGKILLed processes to be reaped before returning from terminate |
+| MEDIUM | UPSTREAM_DRIFT | Filter the caller's own process tree out of the processes status reports |
+| MEDIUM | UPSTREAM_DRIFT | Resolve the owning repository per worktree in destroy instead of once for the whole pool |
+| MEDIUM | UPSTREAM_DRIFT | status lacks the unverified/damaged classes and mis-reports a failed process scan as a quiet slot |
+| MEDIUM | UPSTREAM_DRIFT | No squash-merge detection: an unmerged slot parked on its recorded base can never be reclaimed by prune or destroy |
+| MEDIUM | UPSTREAM_DRIFT | Configurable base branch for get is absent: AcquireOptions.branch silently changes the default branch instead |
+| MEDIUM | UPSTREAM_DRIFT | Worktree placement is hardcoded to //: no unique-leaf option and no path template |
+| MEDIUM | UPSTREAM_DRIFT | No --no-fetch: every get hard-fails offline instead of falling back to local refs |
+| MEDIUM | UPSTREAM_DRIFT | Corrupt-state recovery marks every slot leased forever; the automatic safe-release pass is missing |
+| MEDIUM | UPSTREAM_DRIFT | No auto-free of proven-safe quarantined slots — `recoverQuarantinedEntries` has no Rust counterpart, so recovered pools stay permanently full |
+| MEDIUM | UPSTREAM_DRIFT | Recovery marker check is git-only (`.git`) — jj worktrees are skipped entirely by Rust's recovery scan |
+| MEDIUM | UPSTREAM_DRIFT | No `version` guard on read — Rust cannot detect a future-version state file and will happily clobber it |
+| LOW | BUG_RUST | Report the actionable --include-* flag and re-measured size on phase-2 skips |
+| LOW | P0_PARITY | Fix `--version` output and the update-notice arrow to match Go byte-for-byte |
+| LOW | P1_UPGRADE | Reject `--format` on init/update/enter instead of accepting and ignoring it |
+| LOW | UPSTREAM_DRIFT | Repo-level `[hooks]` are discarded with no warning, unlike Go |
+| LOW | UPSTREAM_DRIFT | Reconstruct the full vcs.Backend contract; port SkipFetch, IsWorktreeSafeToReset and the seeded-path reset variants |
+| LOW | UPSTREAM_DRIFT | Prune stale worktree registrations before adding a new worktree in get |
+| LOW | UPSTREAM_DRIFT | Report the checked-out branch and detached state per pool slot in status |
+| LOW | UPSTREAM_DRIFT | Add opt-in APFS clonefile sharing for freshly created worktrees |
+| LOW | UPSTREAM_DRIFT | Start the subshell as an interactive login session (`-i -l`) for bash/fish/zsh |
+| LOW | UPSTREAM_DRIFT | prune does not run the vcs.PruneWorktrees stale-registration cleanup before worktree add |
+
+**Hai cái đáng soi trước** (severity cao nhất trong nhóm này):
+
+- **HIGH** — `install.ps1:191-196` không thể thay binary đang chạy. `Move-Item -Force $binPath $tmpInstall` rồi `Move-Item -Force $tmpInstall $finalPath`; không có rename-aside, không rollback. Go đã sửa đúng lớp bug này ở v3.0.0 #121 (`internal/updater/updater.go:722-730`, `:735-753` — backup name unique theo từng lần thử rồi rename target đang khoá sang bên cạnh). Chưa verify trên Windows thật.
+- **MEDIUM** — non-UTF-8 path degrade thành chuỗi rỗng tại `crates/treehouse-core/src/git/shell.rs:297,307,315` (`path.to_str().unwrap_or("")`), git sẽ resolve ngược lại cwd. Đáng có một issue riêng cho Unix.
+
+---
+
+## 9. Test coverage
+
+| | Go v3.1.0 | Rust |
+|---|---|---|
+| Test files | ~63 | 2 integration (`e2e.rs` 230 dòng, `common.rs`) + 26 inline module |
+| Test funcs | ~654 | ~166 `#[test]` |
+
+`e2e.rs` 230 dòng so với `internal/pool/pool_test.go` ~4700 dòng của Go.
+
+Rust **không có** analogue cho:
+- `clone_identity_jj_test.go`
+- `destroy_hook_matrix_test.go`
+- `recovery_symmetry_test.go`
+- `worktree_path_test.go`
+
+**Đáng chú ý:** 4/4 finding CRITICAL ở trên hiện **không có test Rust nào bảo vệ**. Coverage gap đúng chỗ nguy hiểm nhất.
+
+---
+
+## 10. Method & caveat
+
+**Đã làm:** 6 finder theo domain (CLI surface / state wire / pool semantics / VCS layer / safety invariants / config+tests+delivery) đọc song song cả hai repo, mỗi finding bắt buộc trích dẫn `file:line` cả hai vế; finding "thiếu ở Rust" bắt buộc grep cả cây `crates/` kèm từ đồng nghĩa. Sau đó mỗi finding qua 2 lens đối kháng (existence / impact), mặc định `refuted=true` nếu phân vân. Bucket bị sửa trong quá trình verify.
+
+**Chưa làm / rủi ro còn lại:**
+
+- **Không chạy test suite nào.** Không `cargo test`, không `go test`. Kết luận từ đọc source + các probe binary dựng lên, chạy, rồi xoá.
+- **Toàn bộ verify runtime trên darwin.** Các mục Windows (`MOVEFILE_WRITE_THROUGH`, hành vi `install.ps1` khi exe đang chạy) dựa trên đọc code + ngữ nghĩa Win32, **không** thực thi.
+- **jj backend, `internal/fileclone`/APFS, engine `.worktreeinclude` chưa trace** — flag chắc chắn thiếu, nhưng chưa xác minh core có giấu máy gì dưới tên khác không. (Xác nhận: 21 hit "seed" của Rust đều là test double.)
+- **Tag `v2.1.1` vắng trong clone baseline.** Release commit `939cb59` vẫn đọc được, mọi call `P0_PARITY` đều neo vào `git show 939cb59:…` hoặc `git log -S`. Hai domain không đọc được baseline trực tiếp và phải bracket — nếu một phân tách P0-vs-drift nào mang tính quyết định, hãy verify lại trên clone đầy đủ.
+- **Tiền đề chưa kiểm chứng:** ba finding MEDIUM (mất field state, và trọng số reachability của markerless/clone-identity) giả định Go binary và Rust binary **dùng chung một pool**. Không test hay doc nào ở hai repo exercise điều này. Nếu thực tế hai binary không bao giờ trộn, finding wire-format rớt xuống **LOW** và phân tích shared-pool thu hẹp còn case in-project pool.
diff --git a/README.md b/README.md
index d695d0c..759d786 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,26 @@ treehouse is built for coding agents and the orchestrators that spawn them. If y
| `stderr` | 🌳 human banners, warnings, prompts |
| `exit 0` | Success (including declined cleanup prompts) |
| `exit 1` | Error — the message is printed once to `stderr` |
+| `exit 3` | A dirty worktree was left unreturned — **only** when `TREEHOUSE_EXIT_STRICT=1` |
+
+### Exit code 3 (opt-in)
+
+When a worktree has uncommitted changes and you do not pass `--force`, treehouse
+leaves it exactly as it found it. Go v3.0.0 made that case exit `3` instead of
+`0`, as a **breaking change**: a caller reading `exit 0` as "the slot was
+released" was reading a bug.
+
+treehouse keeps `exit 0` as the default so existing scripts do not change
+behaviour, and ships the strict exit code behind an environment variable:
+
+```bash
+TREEHOUSE_EXIT_STRICT=1 treehouse return "$PATH"
+# exit 3 + "worktree not returned ... uncommitted changes were kept"
+```
+
+Turn it on in CI and in any orchestrator that branches on the result. The two
+codes demand different responses: `1` is worth retrying, `3` means the tree stays
+dirty until someone cleans it or passes `--force`.
```bash
# 1) Durable lease — path-only on stdout, nothing else
@@ -165,7 +185,9 @@ exit
| `treehouse get` | Acquire a worktree from the pool |
| `treehouse get --lease` | Durably lease a worktree without a subshell; print its path |
| `treehouse enter ` | Attach to an existing worktree by name (from `status`), even if in use |
-| `treehouse return [path]` | Release a lease, terminate lingering processes, reset, return to pool |
+| `treehouse return [path\|name]` | Release a lease, terminate lingering processes, reset, return to pool |
+| `treehouse return --all` | Return every worktree this repository holds; slots nobody holds are left alone |
+| `treehouse lease ` | Durably lease an existing worktree by name, without touching its contents |
| `treehouse status` | Show pool status (highlights leased and current worktrees) |
| `treehouse prune` | Dry-run removal of stale idle worktrees |
| `treehouse gc` | Reclaim stale, orphaned, and dead-owner worktrees (dry-run default) |
@@ -184,6 +206,8 @@ exit
| `--lease-holder ` | Record who holds the lease (defaults to `$TREEHOUSE_LEASE_HOLDER`) |
| `--ttl ` | Make the lease expire (e.g. `30m`, `1h30m`, `24h`); requires `--lease` |
| `--json` | Print `path`, `lease_id`, `lease_holder`, `leased_at` (and `expires_at`) as JSON; requires `--lease` |
+| `--base ` | Cut from this branch instead of the repository default (does **not** create the branch) |
+| `--no-fetch` | Skip the `git fetch origin` that normally runs before acquiring (offline / air-gapped) |
### `status`
@@ -193,12 +217,31 @@ exit
### `return`
+The argument is read as a path first and then as a slot name, so `treehouse return 3`
+does the obvious thing from inside the repo. It defaults to `$TREEHOUSE_DIR`.
+
| Flag | Description |
|------|-------------|
| `--force` | Clean, reset, and return without prompting |
+| `--all` | Return every worktree held for this repository; slots nobody holds are left alone |
| `--if-lease-id ` | Return only if the current lease has this identity (ABA-safe) |
| `--if-lease-holder ` | Return only if the current lease has this holder |
+### `lease`
+
+Durably lease a worktree **by name** without touching its contents — the counterpart
+to `get --lease` for a slot that already exists.
+
+| Flag | Description |
+|------|-------------|
+| `--lease-holder ` | Record who holds the lease (defaults to `$TREEHOUSE_LEASE_HOLDER`) |
+| `--json` | Print the lease identity as JSON instead of the bare path |
+
+```bash
+treehouse lease 3 --lease-holder agent-42
+treehouse lease 3 --lease-holder agent-42 --json
+```
+
### `prune` / `gc`
| Flag | Description |
@@ -222,7 +265,25 @@ exit
| Flag | Description |
|------|-------------|
-| `--env-path ` | Custom pool root (overrides `treehouse.toml` root and `~/.treehouse`); available on every command |
+| `--root ` | Custom pool root; available on every command. Aliased as `--env-path`. |
+| `TREEHOUSE_ROOT` | Same thing, from the environment |
+
+Precedence, highest first:
+
+```
+--root > TREEHOUSE_ROOT > treehouse.toml `root` > ~/.treehouse
+```
+
+> **`--root` and `--env-path` are the same flag**, and the directory you name is
+> the **parent** of the pool, not the pool itself — Go's `--root` semantics. So
+> `treehouse --root /tmp/pools get` puts the worktree in
+> `/tmp/pools/.treehouse/-/`, not in `/tmp/pools/`. Use an absolute
+> path; a relative one (including `.`) resolves from the repository root, which
+> is how an in-project pool at `/.treehouse` is configured.
+>
+> This is a behaviour change from v0.1.2 and earlier, where `--env-path` was a
+> silent no-op that always fell back to `~/.treehouse`. If you were relying on
+> the old flag, it now works — and the pool it selects is the one above.
### `doctor`
@@ -241,7 +302,7 @@ Sweeps every pool under `~/.treehouse` — designed to run from your OS schedule
| `--interval ` | Sweep interval for the foreground loop (e.g. `30s`, `5m`, `1h`). Default: `60s`. Ignored when `--once` is set |
| `--yes` | Execute instead of dry-run |
| `--prune-orphans` | Include backing-repository-missing orphans |
-| `--env-path ` | Custom pool root (overrides `treehouse.toml` root and `~/.treehouse`) |
+| `--root ` | Custom pool root (see [Global flag](#global-flag)); aliased as `--env-path` |
```bash
# One-shot — ideal for cron / systemd timer
diff --git a/crates/treehouse-core/Cargo.toml b/crates/treehouse-core/Cargo.toml
index 6b66a1f..6cb64c8 100644
--- a/crates/treehouse-core/Cargo.toml
+++ b/crates/treehouse-core/Cargo.toml
@@ -20,6 +20,10 @@ owo-colors = { workspace = true }
rand = { workspace = true }
humantime = { workspace = true }
sha2 = { workspace = true }
+# Release-asset extraction for `treehouse update`; see updater.rs.
+flate2 = { workspace = true }
+tar = { workspace = true }
+zip = { workspace = true }
# The `tru` package (Dicklesworthstone/toon_rust, lib name `toon`) is the TOON
# encoder, verified + pinned by the toon-verify bead. It is optional (the `toon`
# feature) so a checkout without network access to toon_rust still compiles.
diff --git a/crates/treehouse-core/src/config.rs b/crates/treehouse-core/src/config.rs
index 4de6a98..ab0bb86 100644
--- a/crates/treehouse-core/src/config.rs
+++ b/crates/treehouse-core/src/config.rs
@@ -18,24 +18,43 @@ use serde::{Deserialize, Serialize};
use crate::env::TreehouseEnv;
/// Repo-safe + user config settings.
+///
+/// The struct-level `#[serde(default)]` is load-bearing and must not be
+/// narrowed back to per-field attributes. Go decodes a config file ONTO a
+/// `DefaultConfig()` (`internal/config/config.go:157` then `:163`
+/// `toml.DecodeFile(repoPath, &cfg)`), so a key the document omits keeps the
+/// default it already had. Deserializing into a fresh struct instead makes
+/// every field mandatory in practice — which is how a documented hooks-only
+/// `~/.config/treehouse/config.toml` came to hard-fail with
+/// `missing field 'max_trees'` on every repo-scoped command.
+///
+/// Starting from [`TreehouseConfig::default_config`] here reproduces Go's
+/// semantics for every field, including keys added later: a new field is
+/// optional-by-construction instead of needing its own `#[serde(default)]`
+/// remembered.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(default)]
pub struct TreehouseConfig {
pub max_trees: u32,
- #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[serde(skip_serializing_if = "Option::is_none")]
pub root: Option,
- #[serde(default, skip_serializing_if = "Hooks::is_empty")]
+ #[serde(skip_serializing_if = "Hooks::is_empty")]
pub hooks: Hooks,
/// P1 additive: default TTL for `treehouse run` leases. Zero/None = no TTL.
- #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[serde(skip_serializing_if = "Option::is_none")]
pub lease_ttl_secs: Option,
}
/// Lifecycle hooks (user-level only; repo hooks are ignored for safety).
+///
+/// Same decode-onto-default contract as [`TreehouseConfig`]: `[hooks]` with only
+/// one of the two keys must leave the other empty, not fail the whole file.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+#[serde(default)]
pub struct Hooks {
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ #[serde(skip_serializing_if = "Vec::is_empty")]
pub post_create: Vec,
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ #[serde(skip_serializing_if = "Vec::is_empty")]
pub pre_destroy: Vec,
}
@@ -73,13 +92,15 @@ impl TreehouseConfig {
if has_repo_config {
let text = std::fs::read_to_string(&repo_path)
.map_err(|e| ConfigError::Io(repo_path.display().to_string(), e))?;
- let decoded: TreehouseConfig = toml::from_str(&text)
+ let mut decoded: TreehouseConfig = toml::from_str(&text)
.map_err(|e| ConfigError::Toml(repo_path.display().to_string(), e.to_string()))?;
- cfg.max_trees = decoded.max_trees;
- cfg.root = decoded.root;
- cfg.lease_ttl_secs = decoded.lease_ttl_secs;
- // Repo hooks are ignored for safety.
- cfg.hooks = Hooks::default();
+ // Repo hooks are ignored for safety. Assign the decoded config
+ // wholesale rather than copying field by field: a field added
+ // later is then picked up from treehouse.toml automatically instead
+ // of silently reading as the default because nobody remembered to
+ // add it to a copy list.
+ decoded.hooks = Hooks::default();
+ cfg = decoded;
}
let (user_cfg, has_user_config) = load_user()?;
@@ -322,13 +343,12 @@ impl TreehouseConfig {
let text = env
.read_file(&repo_path)
.map_err(|e| ConfigError::Io(repo_path.display().to_string(), e))?;
- let decoded: TreehouseConfig = toml::from_str(&text)
+ let mut decoded: TreehouseConfig = toml::from_str(&text)
.map_err(|e| ConfigError::Toml(repo_path.display().to_string(), e.to_string()))?;
- cfg.max_trees = decoded.max_trees;
- cfg.root = decoded.root;
- cfg.lease_ttl_secs = decoded.lease_ttl_secs;
- // Repo hooks are ignored for safety.
- cfg.hooks = Hooks::default();
+ // Repo hooks are ignored for safety — same wholesale-assignment
+ // rationale as `load`.
+ decoded.hooks = Hooks::default();
+ cfg = decoded;
}
let (user_cfg, has_user_config) = load_user_with_env(env)?;
@@ -608,7 +628,9 @@ mod tests {
let env = crate::env::InMemoryEnv::new(PathBuf::from("/test"));
// Seed repo config
env.seed_file(Path::new("/test/repo/treehouse.toml"), b"max_trees = 4\n");
- // Seed user config with hooks (must include max_trees for TOML parse)
+ // Seed user config with hooks. `max_trees = 99` is deliberately
+ // different from the repo's 4 so this still exercises repo-over-user
+ // precedence rather than just agreeing values.
env.seed_file(
Path::new("/test/config/config.toml"),
b"max_trees = 99\n[hooks]\npost_create = [\"./setup.sh\"]\n",
@@ -620,6 +642,120 @@ mod tests {
assert_eq!(cfg.hooks.post_create, ["./setup.sh"]);
}
+ // ─── absent keys keep their default (M-007) ────────────────────────────
+ //
+ // Go decodes a config file ONTO a DefaultConfig(), so every key is
+ // optional. These tests pin that contract: a treehouse.toml or config.toml
+ // that omits a key must load with the default, never fail to parse.
+
+ #[test]
+ fn repo_config_omitting_max_trees_keeps_default() {
+ let _guard = env_lock().lock().unwrap();
+ let dir = tempfile::tempdir().unwrap();
+ // A treehouse.toml that only sets `root` — previously a hard
+ // `missing field 'max_trees'` error that broke every repo-scoped
+ // command (status/get/prune/destroy) in the repo.
+ std::fs::write(dir.path().join("treehouse.toml"), "root = \"pools\"\n").unwrap();
+
+ let cfg = TreehouseConfig::load(dir.path()).unwrap();
+ assert_eq!(
+ cfg.max_trees, DEFAULT_MAX_TREES,
+ "absent key must keep default"
+ );
+ assert_eq!(cfg.root.as_deref(), Some("pools"));
+ }
+
+ #[test]
+ fn hooks_only_user_config_keeps_default_max_trees() {
+ let _guard = env_lock().lock().unwrap();
+ // The exact shape the Go README tells users to paste into
+ // ~/.config/treehouse/config.toml: a [hooks] table and nothing else.
+ let fake_home = tempfile::tempdir().unwrap();
+ let user_dir = fake_home.path().join(".config").join("treehouse");
+ std::fs::create_dir_all(&user_dir).unwrap();
+ std::fs::write(
+ user_dir.join("config.toml"),
+ "[hooks]\npre_destroy = [\"./scripts/cleanup.sh\"]\n",
+ )
+ .unwrap();
+ unsafe {
+ std::env::set_var("HOME", fake_home.path());
+ }
+
+ let dir = tempfile::tempdir().unwrap();
+ let cfg = TreehouseConfig::load(dir.path()).unwrap();
+ assert_eq!(cfg.max_trees, DEFAULT_MAX_TREES);
+ assert_eq!(cfg.hooks.pre_destroy, ["./scripts/cleanup.sh"]);
+
+ unsafe {
+ std::env::remove_var("HOME");
+ }
+ }
+
+ #[test]
+ fn hooks_only_user_config_keeps_default_max_trees_via_env() {
+ let env = crate::env::InMemoryEnv::new(PathBuf::from("/test"));
+ env.seed_file(
+ Path::new("/test/config/config.toml"),
+ b"[hooks]\npost_create = [\"./setup.sh\"]\n",
+ );
+ let cfg = TreehouseConfig::load_with_env(Path::new("/test/repo"), &env).unwrap();
+ assert_eq!(cfg.max_trees, DEFAULT_MAX_TREES);
+ assert_eq!(cfg.hooks.post_create, ["./setup.sh"]);
+ }
+
+ #[test]
+ fn load_with_env_repo_config_omitting_max_trees_keeps_default() {
+ let env = crate::env::InMemoryEnv::new(PathBuf::from("/test"));
+ env.seed_file(
+ Path::new("/test/repo/treehouse.toml"),
+ b"root = \"worktrees\"\n",
+ );
+ let cfg = TreehouseConfig::load_with_env(Path::new("/test/repo"), &env).unwrap();
+ assert_eq!(cfg.max_trees, DEFAULT_MAX_TREES);
+ assert_eq!(cfg.root.as_deref(), Some("worktrees"));
+ }
+
+ #[test]
+ fn partial_hooks_table_keeps_the_other_key_empty() {
+ // Same decode-onto-default contract one level down: naming only one key
+ // under [hooks] must not fail the file.
+ let env = crate::env::InMemoryEnv::new(PathBuf::from("/test"));
+ env.seed_file(
+ Path::new("/test/config/config.toml"),
+ b"[hooks]\npost_create = [\"./setup.sh\"]\n",
+ );
+ let cfg = TreehouseConfig::load_with_env(Path::new("/test/repo"), &env).unwrap();
+ assert_eq!(cfg.hooks.post_create, ["./setup.sh"]);
+ assert!(cfg.hooks.pre_destroy.is_empty());
+ }
+
+ #[test]
+ fn explicit_max_trees_still_overrides_the_default() {
+ // Guard against "fixing" M-007 by making max_trees permanently
+ // default: an explicitly spelled key must still win.
+ let env = crate::env::InMemoryEnv::new(PathBuf::from("/test"));
+ env.seed_file(
+ Path::new("/test/repo/treehouse.toml"),
+ b"max_trees = 3\nroot = \"x\"\n",
+ );
+ let cfg = TreehouseConfig::load_with_env(Path::new("/test/repo"), &env).unwrap();
+ assert_eq!(cfg.max_trees, 3);
+ }
+
+ #[test]
+ fn wrong_type_for_a_present_key_is_still_an_error() {
+ // Decode-onto-default must not turn into decode-anything: a key that IS
+ // present but malformed has to fail closed.
+ let env = crate::env::InMemoryEnv::new(PathBuf::from("/test"));
+ env.seed_file(
+ Path::new("/test/repo/treehouse.toml"),
+ b"max_trees = \"lots\"\n",
+ );
+ let err = TreehouseConfig::load_with_env(Path::new("/test/repo"), &env).unwrap_err();
+ assert!(matches!(err, ConfigError::Toml(..)), "got {err}");
+ }
+
#[test]
fn load_with_env_defaults_without_config() {
let env = crate::env::InMemoryEnv::new(PathBuf::from("/test"));
diff --git a/crates/treehouse-core/src/destroy.rs b/crates/treehouse-core/src/destroy.rs
index 6929c25..6e93b04 100644
--- a/crates/treehouse-core/src/destroy.rs
+++ b/crates/treehouse-core/src/destroy.rs
@@ -11,7 +11,7 @@
//! fresh lock, then delete. Any skip restores the original owner
//! reservation; a worktree re-acquired mid-hook is never deleted.
-use std::path::Path;
+use std::path::{Path, PathBuf};
use crate::pool::{Pool, PoolError, with_pool_lock};
use crate::process::ProcessInfo;
@@ -101,20 +101,22 @@ impl Pool {
let allow_leased = matches!(spec, DestroyTargetSpec::Single(_));
let all = matches!(spec, DestroyTargetSpec::All);
- // Resolve the merge target so destroy and prune agree on "unmerged".
- let repo_root = self
- .git
- .main_repo_root(&self.root)
- .ok()
- .unwrap_or_else(|| self.root.clone());
- let default_ref = self
- .git
- .default_branch_merge_ref(&crate::git::GitRepo {
- common_dir: repo_root.clone(),
- worktree: None,
- })
- .ok()
- .unwrap_or_default();
+ // Each slot resolves against its OWN repository (M-020). A pool is keyed
+ // by ORIGIN URL, so two clones share one pool directory while every slot
+ // still belongs to exactly one physical clone. Resolving a single root
+ // for the whole pool runs `git worktree remove` from the wrong
+ // repository, and under an `--all` sweep it is worse than wrong: the pool
+ // is opened by directory, so `self.root` is the pool directory's PARENT
+ // and not a repository at all — nothing could ever be reclaimed.
+ //
+ // This is the SAME resolver prune and gc use (Go
+ // `worktreePruneContextResolver`, prune.go:401-418, reached from destroy
+ // via `planAndDestroy`), so the three commands agree on "unmerged" and
+ // on which repository owns a slot. Go's rule is stated at
+ // `resolvePoolRepoRoot` (destroy.go:611-619): callers "must resolve
+ // every slot independently and must not apply one slot's root to
+ // another".
+ let mut resolver = crate::prune::PruneContextResolver::new(self);
// Build the target list: all managed worktrees, or just the named one.
let state = State::read_state(&self.dir).map_err(PoolError::State)?;
@@ -142,19 +144,41 @@ impl Pool {
..Default::default()
};
- // Classify + gate.
+ // Classify + gate. Each target carries the context resolved FROM ITS
+ // OWN PATH, and `execute_destroy` reuses that per-slot root for
+ // `worktree_remove` rather than re-deriving one pool-wide value.
let mut removable = Vec::new();
for wt in &targets {
- let target = self.classify_for_destroy(wt, &default_ref);
+ // A markerless slot (Go `resolveDestroyContext`, destroy.go:252-258)
+ // has no context at all: dispatching git on it would resolve the
+ // repository ENCLOSING the pool. An empty default ref makes it
+ // classify as Unverified, which is a skip, never a deletion.
+ let context = if crate::pool::slot_backend_name(Path::new(&wt.path)).is_empty() {
+ crate::prune::PruneContext {
+ repo_root: PathBuf::new(),
+ default_ref: None,
+ context_error: None,
+ }
+ } else {
+ // A resolution failure leaves the default ref empty, which
+ // `classify_for_destroy` reports as Unverified rather than
+ // disposable (Go destroy.go:255-256).
+ resolver.context(wt).unwrap_or(crate::prune::PruneContext {
+ repo_root: PathBuf::new(),
+ default_ref: None,
+ context_error: Some("could not resolve the worktree's repository".into()),
+ })
+ };
+ let target = self.classify_for_destroy(wt, context.default_ref.as_deref().unwrap_or(""));
let mut t = target;
t.bytes = self.measure_size(&t.path);
match self.allows(&t, allow_leased, opts) {
- Ok(()) => removable.push(t),
+ Ok(()) => removable.push((t, context)),
Err(skip) => result.skipped.push(skip),
}
}
- result.planned = removable.clone();
- result.planned_bytes = removable.iter().map(|t| t.bytes).sum();
+ result.planned = removable.iter().map(|(t, _)| t.clone()).collect();
+ result.planned_bytes = removable.iter().map(|(t, _)| t.bytes).sum();
if opts.dry_run {
return Ok(result);
@@ -162,7 +186,7 @@ impl Pool {
// Execute: two-phase.
let (destroyed, exec_skips) =
- self.execute_destroy(&removable, &repo_root, &default_ref, allow_leased, opts)?;
+ self.execute_destroy(&removable, allow_leased, opts)?;
result.destroyed = destroyed.clone();
result.freed_bytes = destroyed.iter().map(|t| t.bytes).sum();
result.skipped.extend(exec_skips);
@@ -171,11 +195,13 @@ impl Pool {
/// Classifies a worktree for destroy using live state (Go classifyForDestroy).
fn classify_for_destroy(&self, wt: &WorktreeEntry, default_ref: &str) -> DestroyTarget {
- let procs = self
- .process
- .find_in_worktree(Path::new(&wt.path))
- .unwrap_or_default();
- let proc_scan_error = self.process.find_in_worktree(Path::new(&wt.path)).is_err();
+ // Scan ONCE and derive both outputs from it. Scanning twice would let
+ // `procs` and `proc_scan_error` disagree when a process starts between
+ // the two sweeps (each `find_in_worktree` re-enumerates), and it would
+ // pay for two full refreshes per classification.
+ let scan = self.process.find_in_worktree(Path::new(&wt.path));
+ let proc_scan_error = scan.is_err();
+ let procs = scan.unwrap_or_default();
let backing_missing = self.backing_repository_missing(&wt.path);
let dirty = self.git.is_dirty(Path::new(&wt.path)).ok();
let merged = if default_ref.is_empty() {
@@ -275,69 +301,78 @@ impl Pool {
}
/// Executes the two-phase destroy (Go `executeDestroy`).
+ ///
+ /// Each planned target arrives with the context resolved from its OWN path,
+ /// and that per-slot `repo_root` is what `worktree_remove` runs against.
fn execute_destroy(
&self,
- removable: &[DestroyTarget],
- repo_root: &Path,
- default_ref: &str,
+ removable: &[(DestroyTarget, crate::prune::PruneContext)],
allow_leased: bool,
opts: &DestroyOptions,
) -> Result<(Vec, Vec), PoolError> {
if removable.is_empty() {
return Ok((Vec::new(), Vec::new()));
}
- let planned_by_path: std::collections::HashMap = removable
- .iter()
- .map(|t| (t.path.clone(), t.clone()))
- .collect();
+ let planned_by_path: std::collections::HashMap =
+ removable
+ .iter()
+ .map(|(t, ctx)| (t.path.clone(), (t.clone(), ctx.clone())))
+ .collect();
// Phase 1: reserve Destroying + fresh owner under the lock.
- let reserved: Vec<(Reservation, DestroyTarget)> =
- with_pool_lock(&self.dir, self.lock_timeout, || {
- let mut state = State::read_state(&self.dir).map_err(PoolError::State)?;
- heal_state(&mut state, |pid| self.process.started_at(pid));
- let mut reserved = Vec::new();
- let mut skips = Vec::new();
- for i in 0..state.worktrees.len() {
- if !planned_by_path.contains_key(&state.worktrees[i].path) {
- continue;
- }
- let current = self.classify_for_destroy(&state.worktrees[i], default_ref);
- if state.worktrees[i].destroying
- && crate::reservation::owner_alive(&state.worktrees[i], &self.process)
- {
- skips.push(DestroySkip {
- target: current,
- needed_flags: vec![],
- leased_bulk: false,
- detail: "reserved by another destroy".into(),
- });
- continue;
- }
- if self.allows(¤t, allow_leased, opts).is_err() {
- continue;
- }
- let path = state.worktrees[i].path.clone();
- let reservation =
- Reservation::reserve_destroy(&path, &mut state.worktrees[i], &self.process)
- .map_err(|e| {
- PoolError::Git(crate::git::GitError::new(
- "reserve destroy",
- e.to_string(),
- crate::git::GitErrorKind::Other,
- ))
- })?;
- reserved.push((reservation, current));
+ //
+ // Returns the skips alongside the reservations: a target that flips to
+ // non-removable between the plan phase and here (concurrent destroy,
+ // or it became dirty/in-use/leased) is neither destroyed nor reported
+ // unless we carry its skip out. Go appends both phases' skips into
+ // `result.Skipped` (destroy.go:378, :383).
+ let (reserved, mut skips) = with_pool_lock(&self.dir, self.lock_timeout, || {
+ let mut state = State::read_state(&self.dir).map_err(PoolError::State)?;
+ heal_state(&mut state, |pid| self.process.started_at(pid));
+ let mut reserved = Vec::new();
+ let mut skips = Vec::new();
+ for i in 0..state.worktrees.len() {
+ let Some((_, context)) = planned_by_path.get(&state.worktrees[i].path) else {
+ continue;
+ };
+ let context = context.clone();
+ let default_ref = context.default_ref.as_deref().unwrap_or("");
+ let current = self.classify_for_destroy(&state.worktrees[i], default_ref);
+ if state.worktrees[i].destroying
+ && crate::reservation::owner_alive(&state.worktrees[i], &self.process)
+ {
+ skips.push(DestroySkip {
+ target: current,
+ needed_flags: vec![],
+ leased_bulk: false,
+ detail: "reserved by another destroy".into(),
+ });
+ continue;
+ }
+ if let Err(skip) = self.allows(¤t, allow_leased, opts) {
+ skips.push(skip);
+ continue;
}
- state_file::write_state(&self.dir, &state)
- .map_err(|e| PoolError::Io("writing state".into(), e))?;
- Ok::<_, PoolError>(reserved)
- })?;
- let _ = planned_by_path;
+ let path = state.worktrees[i].path.clone();
+ let reservation =
+ Reservation::reserve_destroy(&path, &mut state.worktrees[i], &self.process)
+ .map_err(|e| {
+ PoolError::Git(crate::git::GitError::new(
+ "reserve destroy",
+ e.to_string(),
+ crate::git::GitErrorKind::Other,
+ ))
+ })?;
+ reserved.push((reservation, current, context));
+ }
+ state_file::write_state(&self.dir, &state)
+ .map_err(|e| PoolError::Io("writing state".into(), e))?;
+ Ok::<_, PoolError>((reserved, skips))
+ })?;
// Hooks OUTSIDE all locks (non-fatal).
if !opts.pre_destroy.is_empty() {
- for (reservation, _) in &reserved {
+ for (reservation, _, _) in &reserved {
let mut out = std::io::stdout();
let mut err = std::io::stderr();
crate::hooks::run(
@@ -350,13 +385,13 @@ impl Pool {
}
// Phase 2: re-verify + delete under a fresh lock.
- let (destroyed, skips) = with_pool_lock(&self.dir, self.lock_timeout, || {
+ let (destroyed, exec_skips) = with_pool_lock(&self.dir, self.lock_timeout, || {
let mut state = State::read_state(&self.dir).map_err(PoolError::State)?;
let mut destroyed = Vec::new();
let mut skips = Vec::new();
let mut removed_paths = std::collections::HashSet::new();
- for (reservation, planned) in &reserved {
+ for (reservation, planned, context) in &reserved {
let idx = state
.worktrees
.iter()
@@ -378,7 +413,10 @@ impl Pool {
// became dirty/in-use is skipped).
let mut current_entry = state.worktrees[idx].clone();
reservation.restore_original(&mut current_entry);
- let mut current = self.classify_for_destroy(¤t_entry, default_ref);
+ let mut current = self.classify_for_destroy(
+ ¤t_entry,
+ context.default_ref.as_deref().unwrap_or(""),
+ );
current.bytes = planned.bytes;
if self.allows(¤t, allow_leased, opts).is_err() {
reservation.restore_original(&mut state.worktrees[idx]);
@@ -409,12 +447,31 @@ impl Pool {
continue;
}
}
- if !self
- .process
- .find_in_worktree(Path::new(&path))
- .unwrap_or_default()
- .is_empty()
- {
+ // Re-scan with a LIVE table (`find_in_worktree`
+ // re-enumerates). Reading a pre-kill snapshot here would
+ // still list the pids we just terminated and make
+ // `--include-in-use` skip forever, so the flag could never
+ // remove a worktree that actually had processes.
+ //
+ // A scan ERROR fails CLOSED: it restores the reservation
+ // and skips rather than continuing to
+ // `worktree_remove` + `remove_dir_all` on a worktree whose
+ // liveness we could not establish (Go
+ // destroy.go:504-510).
+ let survivors = match self.process.find_in_worktree(Path::new(&path)) {
+ Ok(survivors) => survivors,
+ Err(e) => {
+ reservation.restore_original(&mut state.worktrees[idx]);
+ skips.push(DestroySkip {
+ target: current,
+ needed_flags: vec![],
+ leased_bulk: false,
+ detail: format!("could not verify worktree processes stopped: {e}"),
+ });
+ continue;
+ }
+ };
+ if !survivors.is_empty() {
reservation.restore_original(&mut state.worktrees[idx]);
skips.push(DestroySkip {
target: current,
@@ -426,10 +483,33 @@ impl Pool {
}
}
- // Remove the worktree.
+ // Remove the worktree from the repository that OWNS it (M-020).
+ //
+ // `context.repo_root` was resolved from THIS slot's path in the
+ // plan phase. Using a pool-wide root here is what made a
+ // shared pool's removals run from the wrong repository, and made
+ // `destroy --all` (where `self.root` is not a repository at all)
+ // reclaim nothing while recording a CleanupError per slot
+ // forever. Go reaches the same per-slot root through
+ // `resolvePoolRepoRoot(wt)` at destroy.go:588.
+ let remove_root = context.repo_root.clone();
+ if remove_root.as_os_str().is_empty() {
+ // No live repository owns this slot, so there is nothing to
+ // deregister it from: git has no registration to drop.
+ // Go takes the filesystem route alone for the same slots
+ // (destroy.go:576-590).
+ reservation.restore_original(&mut state.worktrees[idx]);
+ skips.push(DestroySkip {
+ target: current,
+ needed_flags: vec![],
+ leased_bulk: false,
+ detail: "cannot resolve the repository owning this worktree".into(),
+ });
+ continue;
+ }
match self.git.worktree_remove(
&crate::git::GitRepo {
- common_dir: repo_root.to_path_buf(),
+ common_dir: remove_root,
worktree: None,
},
Path::new(&path),
@@ -458,6 +538,11 @@ impl Pool {
Ok::<_, PoolError>((destroyed, skips))
})?;
+ // Both phases' skips reach the caller: a phase-1 skip means the target
+ // was never even attempted, so reporting only phase 2 would make it
+ // vanish from the output — and `destroy --yes` would exit 0
+ // having destroyed nothing.
+ skips.extend(exec_skips);
Ok((destroyed, skips))
}
}
@@ -553,4 +638,378 @@ mod tests {
std::fs::remove_file(wt.join(".git")).unwrap();
assert!(!pool.backing_repository_missing(&wt.to_string_lossy()));
}
+
+ /// Builds a real git repo with one commit on `main`, opens a pool against
+ /// it under a temp HOME, and acquires one worktree.
+ ///
+ /// Returns the pool and the acquired path. The two temp dirs are returned
+ /// so they outlive the pool: the pool dir lives under `home`, and the
+ /// worktree's git metadata points into `repo`. Real git is required (the
+ /// destroy path runs `git worktree remove`), matching the existing pool
+ /// integration test.
+ fn real_pool_with_one_worktree() -> (
+ tempfile::TempDir,
+ tempfile::TempDir,
+ Pool,
+ std::path::PathBuf,
+ ) {
+ let dir = tempfile::tempdir().unwrap();
+ let repo = dir.path().join("repo");
+ let init = std::process::Command::new("git")
+ .args(["init", "--initial-branch=main", repo.to_str().unwrap()])
+ .current_dir(dir.path())
+ .output()
+ .expect("git must be installed");
+ assert!(
+ init.status.success(),
+ "git init failed: {}",
+ String::from_utf8_lossy(&init.stderr)
+ );
+ let git_in_repo = |args: &[&str]| {
+ let out = std::process::Command::new("git")
+ .args(args)
+ .current_dir(&repo)
+ .output()
+ .expect("git must be installed");
+ assert!(
+ out.status.success(),
+ "git {args:?} failed: {}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+ };
+ git_in_repo(&["config", "user.email", "t@t.com"]);
+ git_in_repo(&["config", "user.name", "T"]);
+ std::fs::write(repo.join("README.md"), b"hi\n").unwrap();
+ git_in_repo(&["add", "."]);
+ git_in_repo(&["commit", "-m", "init"]);
+
+ let home = tempfile::tempdir().unwrap();
+ let opts = crate::pool::OpenOptions {
+ config: crate::config::TreehouseConfig {
+ root: Some(home.path().to_str().unwrap().to_string()),
+ ..crate::config::TreehouseConfig::default_config()
+ },
+ ..Default::default()
+ };
+ let pool = Pool::open(&repo, None, &opts).unwrap();
+ let acquired = pool
+ .get(&crate::pool::AcquireOptions {
+ branch: Some("main".to_string()),
+ ..Default::default()
+ })
+ .unwrap();
+ let path = acquired.path.clone();
+ pool.release(&path.to_string_lossy()).unwrap();
+ // Hand both temp dirs back so the caller keeps them alive (and so they
+ // are actually cleaned up when the caller is done).
+ (dir, home, pool, path)
+ }
+
+ /// M-004: `destroy --include-in-use` must actually remove a worktree that
+ /// had a live process.
+ ///
+ /// Before the fix the post-kill survivor re-scan read the same frozen
+ /// snapshot that still listed the just-SIGKILLed pid, so `is_empty()` was
+ /// never true and destroy ALWAYS skipped with "worktree processes still
+ /// running after termination" — the opt-in flag was a silent no-op.
+ #[test]
+ fn destroy_include_in_use_removes_a_worktree_that_had_a_process() {
+ let (_repo, _home, pool, path) = real_pool_with_one_worktree();
+ assert!(path.exists(), "worktree exists before destroy");
+
+ // Start a process INSIDE the worktree, after the pool (and its process
+ // table) were built — the ordering that made the old scan blind.
+ let mut cmd = std::process::Command::new(if cfg!(windows) { "ping" } else { "sleep" });
+ if cfg!(windows) {
+ cmd.args(["-n", "60", "127.0.0.1"]);
+ } else {
+ cmd.arg("60");
+ }
+ let mut child = cmd
+ .current_dir(&path)
+ .spawn()
+ .expect("spawn idle child in the worktree");
+
+ let result = pool
+ .destroy(
+ &DestroyTargetSpec::All,
+ &DestroyOptions {
+ dry_run: false,
+ include_in_use: true,
+ include_unlanded: true,
+ include_leased: false,
+ pre_destroy: vec![],
+ },
+ )
+ .expect("destroy must not error");
+
+ // The child is terminated by destroy, so reap it to avoid a zombie.
+ let _ = child.wait();
+
+ assert_eq!(
+ result.destroyed.len(),
+ 1,
+ "--include-in-use must destroy the worktree; skips were: {:?}",
+ result
+ .skipped
+ .iter()
+ .map(|s| s.detail.as_str())
+ .collect::>()
+ );
+ assert!(
+ !result
+ .skipped
+ .iter()
+ .any(|s| s.detail.contains("still running after termination")),
+ "the survivor check must not trip on a process that was actually killed"
+ );
+ assert!(!path.exists(), "the worktree directory must be gone");
+ }
+
+ /// M-011: a target skipped in phase 1 must still be reported.
+ ///
+ /// The phase-1 lock closure used to build `skips` and return only
+ /// `reserved`, so the vector never escaped; phase 2 iterates `reserved`
+ /// alone and the skip was dropped on the floor. A destroy that lost that
+ /// race then printed "Destroyed 0 worktree(s)" and exited 0.
+ #[test]
+ fn phase_one_skip_is_reported_in_the_result() {
+ let (_repo, _home, pool, path) = real_pool_with_one_worktree();
+
+ // Simulate a concurrent destroy winning the race: mark the worktree
+ // Destroying with a LIVE owner (this process), which is exactly the
+ // condition phase 1 checks before reserving.
+ let mut state = State::read_state(&pool.dir).unwrap();
+ let me = std::process::id() as i32;
+ let my_started = pool
+ .process
+ .started_at(me)
+ .expect("own start time resolves");
+ let wt = state
+ .worktrees
+ .iter_mut()
+ .find(|w| w.path == path.to_string_lossy())
+ .expect("worktree in state");
+ wt.destroying = true;
+ wt.owner_pid = me;
+ wt.owner_started_at = my_started;
+ crate::state_file::write_state(&pool.dir, &state).unwrap();
+
+ let result = pool
+ .destroy(
+ &DestroyTargetSpec::All,
+ &DestroyOptions {
+ dry_run: false,
+ include_in_use: true,
+ include_unlanded: true,
+ include_leased: true,
+ pre_destroy: vec![],
+ },
+ )
+ .expect("destroy must not error");
+
+ assert!(
+ result.destroyed.is_empty(),
+ "a worktree reserved by another destroy must not be removed"
+ );
+ assert!(
+ result
+ .skipped
+ .iter()
+ .any(|s| s.detail == "reserved by another destroy"),
+ "the phase-1 skip must reach the caller; got {:?}",
+ result
+ .skipped
+ .iter()
+ .map(|s| s.detail.as_str())
+ .collect::>()
+ );
+ assert!(path.exists(), "the reserved worktree must be left on disk");
+ }
+
+ /// Builds a real repo with a commit, then a pool opened BY DIRECTORY over
+ /// the same slot the repo-relative open produced.
+ ///
+ /// This is the `destroy --all` / `prune --all` shape (Go feature #146): the
+ /// pool is keyed by origin, so `--all` opens it by directory and `self.root`
+ /// becomes the pool directory's PARENT — a directory that is NOT a
+ /// repository. Returns the pool plus the temp dirs that keep everything
+ /// alive.
+ fn pool_opened_by_directory() -> (
+ tempfile::TempDir,
+ tempfile::TempDir,
+ std::path::PathBuf,
+ Pool,
+ std::path::PathBuf,
+ ) {
+ let dir = tempfile::tempdir().unwrap();
+ let repo = dir.path().join("repo");
+ let out = std::process::Command::new("git")
+ .args(["init", "--initial-branch=main", repo.to_str().unwrap()])
+ .current_dir(dir.path())
+ .output()
+ .expect("git must be installed");
+ assert!(
+ out.status.success(),
+ "git init failed: {}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+ let git_in_repo = |args: &[&str]| {
+ let out = std::process::Command::new("git")
+ .args(args)
+ .current_dir(&repo)
+ .output()
+ .expect("git must be installed");
+ assert!(
+ out.status.success(),
+ "git {args:?} failed: {}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+ };
+ git_in_repo(&["config", "user.email", "t@t.com"]);
+ git_in_repo(&["config", "user.name", "T"]);
+ std::fs::write(repo.join("README.md"), b"hi\n").unwrap();
+ git_in_repo(&["add", "."]);
+ git_in_repo(&["commit", "-m", "init"]);
+
+ let home = tempfile::tempdir().unwrap();
+ let repo_opened = Pool::open(
+ &repo,
+ None,
+ &crate::pool::OpenOptions {
+ config: crate::config::TreehouseConfig {
+ root: Some(home.path().to_str().unwrap().to_string()),
+ ..crate::config::TreehouseConfig::default_config()
+ },
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ let acquired = repo_opened
+ .get(&crate::pool::AcquireOptions {
+ branch: Some("main".to_string()),
+ ..Default::default()
+ })
+ .unwrap();
+ let path = acquired.path.clone();
+ repo_opened.release(&path.to_string_lossy()).unwrap();
+ let pool_dir = repo_opened.pool_dir().to_path_buf();
+
+ // Re-open BY DIRECTORY — exactly what the `--all` sweeps do.
+ let by_dir = Pool::open_at(&pool_dir, &Default::default()).unwrap();
+ assert!(
+ !by_dir.root.join(".git").exists(),
+ "precondition: an --all pool's root is not a repository, which is \
+ why a single pool-wide repo_root can never reclaim anything"
+ );
+ (dir, home, repo, by_dir, path)
+ }
+
+ /// M-020 (destroy half): `destroy --all` must resolve each slot's owning
+ /// repository from the SLOT'S OWN PATH, not once from `self.root`.
+ ///
+ /// Before the fix, `destroy` resolved one `repo_root` from `self.root` and
+ /// reused it for every slot's `worktree_remove`. Under `Pool::open_at` —
+ /// the shape every `--all` sweep uses — `self.root` is the pool directory's
+ /// PARENT and not a repository, so `git worktree remove` failed for every
+ /// slot and destroy reclaimed nothing, forever. This test fails if the
+ /// per-slot resolution is removed and the single pool-wide root comes back.
+ #[test]
+ fn destroy_all_removes_a_slot_resolved_from_its_own_path() {
+ let (_dir, _home, repo, pool, path) = pool_opened_by_directory();
+ assert!(path.exists(), "worktree exists before destroy");
+
+ let result = pool
+ .destroy(
+ &DestroyTargetSpec::All,
+ &DestroyOptions {
+ dry_run: false,
+ include_in_use: true,
+ include_unlanded: true,
+ include_leased: false,
+ pre_destroy: vec![],
+ },
+ )
+ .expect("destroy must not error");
+
+ assert_eq!(
+ result.destroyed.len(),
+ 1,
+ "destroy --all must reclaim the slot; skips were: {:?}",
+ result
+ .skipped
+ .iter()
+ .map(|s| s.detail.as_str())
+ .collect::>()
+ );
+ assert!(
+ !path.exists(),
+ "the worktree directory must be gone from disk"
+ );
+
+ // And the repository that owned it no longer lists it: the removal ran
+ // against the slot's OWN repo, which is the only one that could
+ // deregister it.
+ let listing = std::process::Command::new("git")
+ .args(["worktree", "list"])
+ .current_dir(&repo)
+ .output()
+ .expect("git must be installed");
+ assert!(listing.status.success());
+ let out = String::from_utf8_lossy(&listing.stdout);
+ assert!(
+ !out.contains(&path.to_string_lossy().to_string()),
+ "the owning repo must no longer list the worktree; got:\n{out}"
+ );
+ }
+
+ /// M-020 (destroy half): a slot whose repository cannot be resolved is
+ /// SKIPPED, never deleted.
+ ///
+ /// `git worktree remove` against a nonexistent repository is the loud
+ /// failure the fix removes for healthy slots — but for a genuinely
+ /// unresolvable one, skipping is the only safe answer, and the caller must
+ /// be told why rather than watching a bare git error scroll past.
+ #[test]
+ fn destroy_skips_a_slot_whose_repository_cannot_be_resolved() {
+ let (_dir, _home, _repo, pool, _path) = pool_opened_by_directory();
+
+ // A slot whose `.git` marker names a gitdir that does not exist: the
+ // repository that owned it is gone, so no root can be resolved.
+ let mut state = State::read_state(&pool.dir).unwrap();
+ assert_eq!(state.worktrees.len(), 1);
+ state.worktrees[0].destroying = false;
+ crate::state_file::write_state(&pool.dir, &state).unwrap();
+
+ // Point the recorded path at a directory with no repository above it.
+ let orphan_parent = tempfile::tempdir().unwrap();
+ let orphan = orphan_parent.path().join("gone");
+ std::fs::create_dir_all(&orphan).unwrap();
+ std::fs::write(orphan.join(".git"), "gitdir: /nonexistent/treehouse.git\n").unwrap();
+ let mut state = State::read_state(&pool.dir).unwrap();
+ state.worktrees[0].path = orphan.to_string_lossy().into_owned();
+ crate::state_file::write_state(&pool.dir, &state).unwrap();
+
+ let result = pool
+ .destroy(
+ &DestroyTargetSpec::All,
+ &DestroyOptions {
+ dry_run: false,
+ include_in_use: true,
+ include_unlanded: true,
+ include_leased: true,
+ pre_destroy: vec![],
+ },
+ )
+ .expect("destroy must not error");
+
+ assert!(
+ result.destroyed.is_empty(),
+ "a slot with no resolvable repository must never be destroyed"
+ );
+ assert!(
+ orphan.exists(),
+ "the unresolvable slot's directory must be left on disk"
+ );
+ }
}
diff --git a/crates/treehouse-core/src/gc.rs b/crates/treehouse-core/src/gc.rs
index 07bd8cb..b9af118 100644
--- a/crates/treehouse-core/src/gc.rs
+++ b/crates/treehouse-core/src/gc.rs
@@ -93,29 +93,58 @@ pub const GC_SKIP_VALID_LEASE: &str = "valid lease";
pub const GC_SKIP_IN_USE: &str = "in use";
pub const GC_SKIP_DISPOSABLE_BAR: &str = "not disposable";
+/// The `tag` gc assigns to an orphaned candidate. An orphan has no live
+/// repository to deregister the worktree from, so its cleanup is filesystem
+/// only.
+const GC_TAG_ORPHANED: &str = "stale/orphaned";
+
impl Pool {
/// Reclaims stale worktrees (Go-style two-phase, strictly safe).
pub fn gc(&self, opts: &GcOptions) -> Result {
// Snapshot under one lock: read + heal + write.
let entries = crate::prune::with_pool_snapshot(self)?;
- let repo_root = self
- .git
- .main_repo_root(&self.root)
- .unwrap_or_else(|_| self.root.clone());
- let default_ref = self.resolve_prune_default_ref(&repo_root);
+ // Each slot is resolved against its OWN repository (M-020); the fetch
+ // + default-ref lookup is memoized per resolved root. Under an `--all`
+ // sweep `self.root` is the pool directory's PARENT and not a
+ // repository at all, so a single pool-wide root would leave every slot
+ // permanently unverifiable and gc would reclaim nothing, forever.
+ let mut resolver = crate::prune::PruneContextResolver::new(self);
let mut result = GcResult {
dry_run: opts.dry_run,
..Default::default()
};
- let mut planned: Vec = Vec::new();
+ let mut planned: Vec<(GcWorktree, crate::prune::PruneContext)> = Vec::new();
let now = chrono::Utc::now();
for wt in &entries {
+ // An orphan has no live repository to resolve a ref in, and
+ // `analyze_gc_candidate` answers from the orphan branch before it
+ // ever reads the default ref.
+ let context = if self.backing_repository_missing(&wt.path) {
+ crate::prune::PruneContext {
+ repo_root: std::path::PathBuf::new(),
+ default_ref: None,
+ context_error: None,
+ }
+ } else {
+ match resolver.context(wt) {
+ Ok(c) => c,
+ Err(reason) => {
+ result.skipped.push(GcSkipped {
+ name: wt.name.clone(),
+ path: wt.path.clone(),
+ category: crate::prune::PRUNE_SKIP_CANNOT_VERIFY.to_string(),
+ reason: format!("cannot verify worktree: {reason}"),
+ });
+ continue;
+ }
+ }
+ };
let (candidate, skipped) = self.analyze_gc_candidate(
wt,
- default_ref.as_ref().ok().map(|s| s.as_str()),
+ context.default_ref.as_deref(),
opts,
now,
);
@@ -126,7 +155,7 @@ impl Pool {
if let Some(c) = candidate {
result.reclaimable_bytes += c.bytes;
result.candidates.push(c.clone());
- planned.push(c);
+ planned.push((c, context));
}
}
@@ -134,7 +163,7 @@ impl Pool {
return Ok(result);
}
- let (reclaimed, errors) = self.execute_gc(&planned)?;
+ let (reclaimed, errors) = self.execute_gc(&planned, opts)?;
result.reclaimed = reclaimed.clone();
result.errors = errors;
result.freed_bytes = reclaimed.iter().map(|w| w.bytes).sum();
@@ -208,7 +237,7 @@ impl Pool {
name,
path,
bytes,
- tag: "stale/orphaned".into(),
+ tag: GC_TAG_ORPHANED.into(),
warning: "content could not be verified".into(),
}),
None,
@@ -293,15 +322,16 @@ impl Pool {
/// for retry on the next gc run.
fn execute_gc(
&self,
- planned: &[GcWorktree],
+ planned: &[(GcWorktree, crate::prune::PruneContext)],
+ opts: &GcOptions,
) -> Result<(Vec, Vec), PoolError> {
// Phase 1: reserve Destroying + fresh owner.
- let reserved: Vec<(Reservation, GcWorktree)> =
+ let reserved: Vec<(Reservation, GcWorktree, crate::prune::PruneContext)> =
crate::pool::with_pool_lock(&self.dir, self.lock_timeout, || {
let mut state = State::read_state(&self.dir).map_err(PoolError::State)?;
heal_state(&mut state, |pid| self.process.started_at(pid));
let mut reserved = Vec::new();
- for w in planned {
+ for (w, context) in planned {
let Some(idx) = state.worktrees.iter().position(|e| e.path == w.path) else {
continue;
};
@@ -318,25 +348,32 @@ impl Pool {
crate::git::GitErrorKind::Other,
))
})?;
- reserved.push((reservation, w.clone()));
+ reserved.push((reservation, w.clone(), context.clone()));
}
state_file::write_state(&self.dir, &state)
.map_err(|e| PoolError::Io("writing state".into(), e))?;
Ok::<_, PoolError>(reserved)
})?;
- // Phase 2: re-verify + physical cleanup + state commit.
+ // Phase 2: RE-CLASSIFY + physical cleanup + state commit.
//
- // Invariant: a worktree is removed from state **only** when both
- // `git worktree remove` and `remove_dir_all` succeed. On failure the
- // entry is retained so the next gc run can retry.
+ // gc's two-phase engine has the same shape as prune's, so it gets the
+ // same treatment: the plan's verdict is not trusted, because the
+ // `pre_destroy` hooks run with no lock held between the two phases and
+ // a slot can go dirty, gain a process, or gain a valid lease in that
+ // window. The full classification is re-run inside the deleting lock.
+ //
+ // Invariant: a worktree is removed from state **only** when cleanup
+ // fully succeeds. On failure the entry is retained with its
+ // reservation restored, so it is immediately retryable rather than
+ // stuck behind a `destroying` flag naming a still-live process.
crate::pool::with_pool_lock(&self.dir, self.lock_timeout, || {
let mut state = State::read_state(&self.dir).map_err(PoolError::State)?;
let mut reclaimed = Vec::new();
let mut removed = std::collections::HashSet::new();
let mut errors = Vec::new();
- for (reservation, w) in &reserved {
+ for (reservation, w, context) in &reserved {
let Some(idx) = state
.worktrees
.iter()
@@ -348,14 +385,25 @@ impl Pool {
continue; // re-acquired mid-hook; never remove
}
let path = state.worktrees[idx].path.clone();
+
+ if self.final_gc_safety_check(&state.worktrees[idx], context, opts) {
+ reservation.restore_original(&mut state.worktrees[idx]);
+ continue;
+ }
+
let repo = crate::git::GitRepo {
- common_dir: self.root.clone(),
+ // The repository that OWNS this worktree, not whatever
+ // repository the pool happens to have been opened against.
+ common_dir: context.repo_root.clone(),
worktree: None,
};
- // A: git worktree remove.
+ // A: git worktree remove. An orphan has no live repository to
+ // deregister from, so it takes the filesystem route alone.
let mut cleanup_ok = true;
- if let Err(e) = self.git.worktree_remove(&repo, Path::new(&path)) {
+ if w.tag != GC_TAG_ORPHANED
+ && let Err(e) = self.git.worktree_remove(&repo, Path::new(&path))
+ {
errors.push(CleanupError {
name: w.name.clone(),
path: path.clone(),
@@ -387,8 +435,9 @@ impl Pool {
if cleanup_ok {
removed.insert(path.clone());
reclaimed.push(w.clone());
+ } else {
+ reservation.restore_original(&mut state.worktrees[idx]);
}
- // !cleanup_ok → entry stays in state, eligible for retry
}
state.worktrees.retain(|e| !removed.contains(&e.path));
@@ -397,6 +446,48 @@ impl Pool {
Ok::<_, PoolError>((reclaimed, errors))
})
}
+
+ /// The last word on disposability, run inside the deleting lock. `true`
+ /// means the slot must be left alone.
+ ///
+ /// Every condition the plan checked is checked again against the state
+ /// just read: a valid lease taken during the unlocked hook window, a
+ /// process that walked in, dirtiness, and merge state. Fails closed on
+ /// ambiguity — an `is_dirty` that errors counts as dirty.
+ fn final_gc_safety_check(
+ &self,
+ wt: &WorktreeEntry,
+ context: &crate::prune::PruneContext,
+ opts: &GcOptions,
+ ) -> bool {
+ let now = chrono::Utc::now();
+ // A valid lease taken between plan and delete makes the slot off limits
+ // — this is the whole point of the two-phase contract.
+ if wt.leased && !wt.is_stale_lease(now) {
+ return true;
+ }
+ if self
+ .process
+ .is_worktree_in_use(Path::new(&wt.path))
+ .unwrap_or(true)
+ {
+ return true;
+ }
+ if self.backing_repository_missing(&wt.path) {
+ return !opts.prune_orphans;
+ }
+ if self.git.is_dirty(Path::new(&wt.path)).unwrap_or(true) {
+ return true;
+ }
+ let Some(default_ref) = context.default_ref.as_deref() else {
+ return true;
+ };
+ !matches!(
+ self.git
+ .is_head_merged_into_ref(Path::new(&wt.path), default_ref),
+ Ok(true)
+ )
+ }
}
/// Recursively measures a directory's size.
@@ -513,6 +604,7 @@ mod tests {
holder: "test-agent".into(),
ttl: Some(chrono::Duration::hours(1)),
}),
+ ..Default::default()
})
.unwrap();
let wt_path = acquired.path.clone();
@@ -692,4 +784,59 @@ mod tests {
SetFileAttributesW(wide.as_ptr(), 0x80);
}
}
+
+ // ─── M-020: gc resolves each slot's repository from its own path ──────
+
+ /// M-020: `gc --all` opens the pool BY DIRECTORY, so `self.root` is the
+ /// pool directory's parent — not a repository. One pool-wide root therefore
+ /// failed to resolve for every slot, `default_ref` came back unresolved,
+ /// and every candidate was skipped as unverifiable: `gc --all` reclaimed
+ /// nothing, forever. Each slot must be resolved from its OWN path.
+ #[test]
+ fn gc_under_an_all_sweep_resolves_each_slots_own_repository() {
+ let (pool, wt_path, _home, _repo) = setup_gc_test();
+ let swept = Pool::open_at(pool.pool_dir(), &OpenOptions::default()).unwrap();
+ assert_eq!(
+ swept.root,
+ pool.pool_dir().parent().unwrap(),
+ "an --all sweep has no repository as its root"
+ );
+
+ let result = swept
+ .gc(&GcOptions {
+ dry_run: false,
+ prune_orphans: false,
+ })
+ .unwrap();
+
+ assert!(
+ result.skipped.is_empty(),
+ "no slot should be unverifiable under a per-slot root, got: {:?}",
+ result.skipped
+ );
+ assert_eq!(
+ result.reclaimed.len(),
+ 1,
+ "the expired-lease worktree must actually be reclaimed"
+ );
+ assert!(!wt_path.exists(), "the worktree directory must be gone");
+ }
+
+ /// The repository passed to `git worktree remove` must be the one that
+ /// OWNS the worktree. A pool-wide root deregisters the wrong clone's
+ /// bookkeeping; this pins the per-slot context that reaches the executor.
+ #[test]
+ fn gc_context_points_at_the_owning_repository() {
+ let (pool, wt_path, _home, _repo) = setup_gc_test();
+ let mut resolver = crate::prune::PruneContextResolver::new(&pool);
+ let state = crate::state::State::read_state(pool.pool_dir()).unwrap();
+ let wt = state.worktrees.iter().find(|w| w.path == wt_path).unwrap();
+ let context = resolver.context(wt).unwrap();
+ assert!(
+ context.repo_root.ends_with("repo"),
+ "must resolve the worktree's own repository, got: {:?}",
+ context.repo_root
+ );
+ assert!(!context.repo_root.starts_with(&pool.root));
+ }
}
diff --git a/crates/treehouse-core/src/git/mod.rs b/crates/treehouse-core/src/git/mod.rs
index 86e82c7..03f8fa9 100644
--- a/crates/treehouse-core/src/git/mod.rs
+++ b/crates/treehouse-core/src/git/mod.rs
@@ -94,6 +94,21 @@ pub trait GitBackend: Send + Sync {
/// dirty worktree is rejected by git).
fn remove_clean_worktree(&self, repo: &GitRepo, path: &Path) -> Result<(), GitError>;
+ /// Creates `branch` at `worktree`'s current HEAD and checks it out (Go
+ /// `vcs.CreateBranch`, the `--branch` / `-b` acquisition flag).
+ ///
+ /// DESTRUCTIVE and fail-closed under the same marker precondition as
+ /// [`Self::reset_worktree`]: `git branch` + `git checkout` against a
+ /// markerless path would create and switch a branch in the repository
+ /// ENCLOSING the pool. Git refuses to create a branch that already exists,
+ /// so this never adopts a caller's ref.
+ fn create_branch(&self, worktree: &Path, branch: &str) -> Result<(), GitError>;
+
+ /// Whether a LOCAL branch named `branch` exists (Go `LocalBranchExists`,
+ /// `git show-ref --verify --quiet refs/heads/`). Used to refuse
+ /// `--branch` against an existing ref rather than silently adopting it.
+ fn local_branch_exists(&self, repo: &GitRepo, branch: &str) -> bool;
+
/// Whether the worktree has tracked or untracked changes (Go `IsDirty`):
/// `git status --porcelain --untracked-files=all` — ANY output is dirty.
/// The `--untracked-files=all` flag is load-bearing: it forces untracked
@@ -103,9 +118,23 @@ pub trait GitBackend: Send + Sync {
/// Resets a worktree to `branch` (Go `ResetWorktree`): a single semantic
/// unit of `checkout --detach --force` then `reset --hard` then
/// `clean -fd`.
+ ///
+ /// DESTRUCTIVE and fail-closed. Implementations MUST verify that
+ /// `worktree` still carries the marker git wrote when it was created
+ /// (a `.git` entry — file for a linked worktree, directory for a primary
+ /// checkout) and return `Err` before touching anything if it is absent or
+ /// unresolvable. Without the marker, git resolves `worktree` by walking
+ /// UP to whatever repository encloses it; in an in-project pool that is
+ /// the user's own working tree, and `reset --hard` + `clean -fd` there is
+ /// irreversible data loss. Go enforces the same precondition in
+ /// `destructiveBackendForWorktree` (vcs.go:513-525).
fn reset_worktree(&self, worktree: &Path, branch: &str) -> Result<(), GitError>;
/// Detaches the worktree HEAD (Go `DetachWorktree`).
+ ///
+ /// DESTRUCTIVE and fail-closed under the same marker precondition as
+ /// [`Self::reset_worktree`]: `checkout --detach` against a markerless path
+ /// detaches the ENCLOSING repository's HEAD, not the slot's.
fn detach_worktree(&self, worktree: &Path) -> Result<(), GitError>;
/// Whether HEAD of `worktree` is an ancestor of `reference` (Go
diff --git a/crates/treehouse-core/src/git/shell.rs b/crates/treehouse-core/src/git/shell.rs
index 3da74ce..e132f21 100644
--- a/crates/treehouse-core/src/git/shell.rs
+++ b/crates/treehouse-core/src/git/shell.rs
@@ -7,12 +7,90 @@
//! Binary discovery: `GIT_BIN` env override → `PATH` → Windows
//! `Program Files\Git\bin\git.exe`.
-use std::ffi::OsStr;
+use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use super::{GitBackend, GitError, GitErrorKind, GitRepo};
+/// One git argument that may carry a caller-supplied path.
+///
+/// `Command` accepts an `OsStr` on every platform, so a path can reach git
+/// byte-for-byte. Routing it through `Path::to_str()` first does not: that
+/// returns `None` for any path that is not valid UTF-8, and the tempting
+/// `unwrap_or("")` then hands git an EMPTY argument, which git resolves
+/// against its own cwd — a different directory than the caller named, on the
+/// exact subcommands (`worktree remove --force`) that delete one.
+enum Arg<'a> {
+ Lit(&'a str),
+ Path(&'a Path),
+}
+
+impl Arg<'_> {
+ fn to_os_string(&self) -> OsString {
+ match self {
+ Self::Lit(s) => OsString::from(s),
+ Self::Path(p) => p.as_os_str().to_os_string(),
+ }
+ }
+}
+
+/// Fail-closed precondition for every operation that REWRITES a worktree's
+/// checkout (reset, detach).
+///
+/// Go wraps all five destructive entry points in `destructiveBackendForWorktree`
+/// (`internal/vcs/vcs.go:513-525`, added in c88b53e / v2.3.0) for exactly this
+/// reason: it refuses a markerless path rather than falling back to the
+/// configured backend, because in an in-project pool — a supported layout,
+/// since a relative pool `root` nests `.treehouse` under the repo — that
+/// fallback resolves to the repository ENCLOSING the pool. `reset --hard`
+/// followed by `clean -fd` then discards the user's real uncommitted work in
+/// their real working tree.
+///
+/// The marker is checked with `symlink_metadata` (lstat) rather than
+/// `metadata` (stat), mirroring Go's `markerPresent`: a dangling symlink IS an
+/// entry that is present on disk, and its unresolvable target is a read
+/// failure for the caller to surface — not an absent marker. It is then
+/// followed once with `metadata` so a dangling link fails HERE, loudly, instead
+/// of surfacing three destructive git commands later. Both file and directory
+/// `.git` entries are accepted: a linked worktree has a `gitdir:` file, a
+/// primary checkout has a directory, and both are legitimately the thing the
+/// caller asked us to reset.
+///
+/// jj is not a backend in this port, so only the `.git` marker is consulted
+/// (Go's `slotMarkerBackend` accepts a `.jj` directory as the alternative).
+fn require_worktree_marker(worktree: &Path) -> Result<(), GitError> {
+ let marker = worktree.join(".git");
+ let present = match std::fs::symlink_metadata(&marker) {
+ Ok(_) => true,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
+ Err(e) => {
+ return Err(GitError::new(
+ "lstat .git marker",
+ format!("reading .git marker in {}: {e}", worktree.display()),
+ GitErrorKind::Other,
+ ));
+ }
+ };
+ if !present {
+ return Err(GitError::new(
+ "lstat .git marker",
+ format!(
+ "refusing to modify {}: it holds no .git marker",
+ worktree.display()
+ ),
+ GitErrorKind::Other,
+ ));
+ }
+ std::fs::metadata(&marker).map(|_| ()).map_err(|e| {
+ GitError::new(
+ "stat .git marker",
+ format!("resolving .git marker in {}: {e}", worktree.display()),
+ GitErrorKind::Other,
+ )
+ })
+}
+
/// Runs git by spawning the binary directly (no shell).
#[derive(Debug, Clone)]
pub struct ShellGitBackend {
@@ -44,6 +122,11 @@ impl ShellGitBackend {
/// Runs `git ` in `cwd`, returning the `Output` (no exit-code check).
fn run(&self, cwd: Option<&Path>, args: &[&str]) -> Output {
+ self.run_os(cwd, args.iter().map(|s| OsString::from(*s)).collect())
+ }
+
+ /// The spawn itself, with arguments already converted to the OS form.
+ fn run_os(&self, cwd: Option<&Path>, args: Vec) -> Output {
let mut cmd = Command::new(&self.git_bin);
cmd.args(args);
if let Some(dir) = cwd {
@@ -70,12 +153,27 @@ impl ShellGitBackend {
kind: GitErrorKind,
) -> Result<(), GitError> {
let output = self.run(cwd, args);
- if output.status.success() {
- return Ok(());
- }
- let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
- let command = format!("git {}", args.join(" "));
- Err(GitError::new(command, message, kind))
+ checked(output, format!("git {}", args.join(" ")), kind)
+ }
+
+ /// [`Self::run_checked`] for argument lists that carry a caller-supplied
+ /// path. Only the `worktree` subcommands take one, and they are exactly
+ /// the ones where a degraded path argument reaches a destructive git
+ /// command — hence the separate entry point rather than a generic
+ /// conversion that would touch every call site.
+ fn run_checked_args(
+ &self,
+ cwd: Option<&Path>,
+ args: &[Arg<'_>],
+ kind: GitErrorKind,
+ ) -> Result<(), GitError> {
+ let command = args
+ .iter()
+ .map(|a| a.to_os_string().to_string_lossy().into_owned())
+ .collect::>()
+ .join(" ");
+ let output = self.run_os(cwd, args.iter().map(Arg::to_os_string).collect());
+ checked(output, format!("git {command}"), kind)
}
/// Runs git and returns trimmed, UTF-8-lossy stdout, failing on nonzero
@@ -112,6 +210,17 @@ impl ShellGitBackend {
}
}
+/// Shared exit-code check: success is `Ok`, anything else is the stderr text
+/// wrapped with the command line that produced it (the `command` half of a
+/// [`GitError`], so operators can see what actually ran).
+fn checked(output: Output, command: String, kind: GitErrorKind) -> Result<(), GitError> {
+ if output.status.success() {
+ return Ok(());
+ }
+ let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
+ Err(GitError::new(command, message, kind))
+}
+
/// Finds the git binary: `GIT_BIN` env → `PATH` → Windows Program Files.
fn find_git_binary() -> Option {
if let Some(v) = std::env::var_os("GIT_BIN") {
@@ -288,35 +397,119 @@ impl GitBackend for ShellGitBackend {
fn worktree_add(&self, repo: &GitRepo, path: &Path, branch: &str) -> Result<(), GitError> {
let ref_ = self.branch_ref(repo, branch);
- self.run_checked(
+ self.run_checked_args(
Some(&repo.common_dir),
&[
- "worktree",
- "add",
- "--detach",
- path.to_str().unwrap_or(""),
- &ref_,
+ Arg::Lit("worktree"),
+ Arg::Lit("add"),
+ Arg::Lit("--detach"),
+ Arg::Path(path),
+ Arg::Lit(&ref_),
],
GitErrorKind::Other,
)
}
fn worktree_remove(&self, repo: &GitRepo, path: &Path) -> Result<(), GitError> {
- self.run_checked(
+ self.run_checked_args(
Some(&repo.common_dir),
- &["worktree", "remove", "--force", path.to_str().unwrap_or("")],
+ &[
+ Arg::Lit("worktree"),
+ Arg::Lit("remove"),
+ Arg::Lit("--force"),
+ Arg::Path(path),
+ ],
GitErrorKind::Other,
)
}
fn remove_clean_worktree(&self, repo: &GitRepo, path: &Path) -> Result<(), GitError> {
- self.run_checked(
+ self.run_checked_args(
Some(&repo.common_dir),
- &["worktree", "remove", path.to_str().unwrap_or("")],
+ &[Arg::Lit("worktree"), Arg::Lit("remove"), Arg::Path(path)],
GitErrorKind::Other,
)
}
+ fn create_branch(&self, worktree: &Path, branch: &str) -> Result<(), GitError> {
+ // Marker FIRST, before any dispatch on this path. `git branch` +
+ // `git checkout` are only ever meant for the checkout named by
+ // `worktree`; against a markerless path git walks UP to the enclosing
+ // repository and would create and switch a branch in the user's own
+ // working tree.
+ require_worktree_marker(worktree)?;
+
+ // Create at the worktree's CURRENT commit, verified first. Going
+ // through `rev-parse` means the branch is pinned to a known commit
+ // rather than to whatever HEAD reports between two commands.
+ let expected_head = self.run_stdout(
+ Some(worktree),
+ &["rev-parse", "--verify", "HEAD^{commit}"],
+ GitErrorKind::Other,
+ )?;
+
+ // `--` ends option parsing: a name beginning with `-` is then a branch,
+ // not a flag. Git refuses an existing ref, including one created
+ // concurrently, so this never adopts a caller's branch.
+ self.run_checked_args(
+ Some(worktree),
+ &[
+ Arg::Lit("branch"),
+ Arg::Lit("--"),
+ Arg::Lit(branch),
+ Arg::Lit(&expected_head),
+ ],
+ GitErrorKind::Other,
+ )?;
+
+ let checkout = self.run(Some(worktree), &["checkout", branch]);
+ if !checkout.status.success() {
+ return Err(GitError::new(
+ format!("git checkout {branch}"),
+ String::from_utf8_lossy(&checkout.stderr).trim().to_string(),
+ GitErrorKind::Other,
+ ));
+ }
+
+ // Exit status alone is not authoritative: a post-checkout hook can fail
+ // after checkout, or succeed after switching HEAD somewhere else. Both
+ // the branch NAME and the COMMIT must still match the acquisition.
+ let checked_out = self.run_stdout(
+ Some(worktree),
+ &["symbolic-ref", "-q", "--short", "HEAD"],
+ GitErrorKind::Other,
+ )?;
+ let head = self.run_stdout(
+ Some(worktree),
+ &["rev-parse", "--verify", "HEAD^{commit}"],
+ GitErrorKind::Other,
+ )?;
+ if checked_out != branch || head != expected_head {
+ return Err(GitError::new(
+ format!("git checkout {branch}"),
+ format!(
+ "checkout did not leave HEAD on {branch:?} at commit {expected_head} \
+ (on {checked_out:?} at {head})"
+ ),
+ GitErrorKind::Other,
+ ));
+ }
+ Ok(())
+ }
+
+ fn local_branch_exists(&self, repo: &GitRepo, branch: &str) -> bool {
+ let out = self.run(
+ Some(&repo.common_dir),
+ &[
+ "show-ref",
+ "--verify",
+ "--quiet",
+ &format!("refs/heads/{branch}"),
+ ],
+ );
+ out.status.success()
+ }
+
fn is_dirty(&self, worktree: &Path) -> Result {
let out = self.run_stdout(
Some(worktree),
@@ -327,6 +520,12 @@ impl GitBackend for ShellGitBackend {
}
fn reset_worktree(&self, worktree: &Path, branch: &str) -> Result<(), GitError> {
+ // Precondition FIRST, before anything can resolve the path upward.
+ // `checkout --force` / `reset --hard` / `clean -fd` are only ever meant
+ // for the checkout named by `worktree`; with the marker gone, git
+ // walks up to the enclosing repository and these three commands destroy
+ // the user's real uncommitted work there.
+ require_worktree_marker(worktree)?;
// Resolve the repo root; fall back to the worktree path.
let repo_root = self
.repo_root(worktree)
@@ -351,6 +550,9 @@ impl GitBackend for ShellGitBackend {
}
fn detach_worktree(&self, worktree: &Path) -> Result<(), GitError> {
+ // Same precondition as `reset_worktree`: `checkout --detach` on a
+ // markerless path detaches the ENCLOSING repository's HEAD.
+ require_worktree_marker(worktree)?;
self.run_checked(
Some(worktree),
&["checkout", "--detach"],
@@ -648,4 +850,147 @@ mod tests {
assert_eq!(merge_ref, "refs/heads/main");
assert!(backend.is_head_merged_into_ref(&wt, &merge_ref).unwrap());
}
+
+ // ---- M-002: fail closed on a pool slot that lost its .git marker ----
+ //
+ // The scenario these reproduce is an IN-PROJECT pool: a supported layout
+ // (config.rs nests `.treehouse` under the repo when `root` is relative),
+ // in which a slot whose `.git` marker was deleted sits INSIDE the user's
+ // real repository. Git then walks up from the slot, resolves against the
+ // enclosing repo, and `reset --hard` + `clean -fd` destroy the user's
+ // uncommitted work there. Go refuses this at vcs.go:513-525.
+
+ /// Creates a pool-slot-shaped directory INSIDE `repo`: a plain directory
+ /// with no `.git` marker at all, exactly what remains after the marker of
+ /// a torn-down worktree is deleted.
+ fn markerless_slot(repo: &Path) -> PathBuf {
+ let slot = repo.join(".treehouse").join("wt-1");
+ std::fs::create_dir_all(&slot).unwrap();
+ slot
+ }
+
+ #[test]
+ fn integration_reset_worktree_refuses_markerless_slot() {
+ let (_dir, repo) = temp_repo();
+ let slot = markerless_slot(&repo);
+ let backend = ShellGitBackend::discover().unwrap();
+
+ // The enclosing repo carries work that MUST survive the refusal.
+ std::fs::write(repo.join("README.md"), b"uncommitted edit\n").unwrap();
+ std::fs::write(repo.join("scratch.txt"), b"untracked\n").unwrap();
+
+ let err = backend
+ .reset_worktree(&slot, "main")
+ .expect_err("reset must refuse a slot with no .git marker");
+ assert!(
+ err.message.contains("refusing to modify"),
+ "error must state the refusal, got: {}",
+ err.message
+ );
+ assert!(
+ err.message.contains(&slot.display().to_string()),
+ "error must name the offending path, got: {}",
+ err.message
+ );
+
+ // The real proof: nothing in the ENCLOSING repo was touched.
+ assert_eq!(
+ std::fs::read_to_string(repo.join("README.md")).unwrap(),
+ "uncommitted edit\n",
+ "reset --hard must not have run against the enclosing repo"
+ );
+ assert!(
+ repo.join("scratch.txt").exists(),
+ "clean -fd must not have run against the enclosing repo"
+ );
+ }
+
+ #[test]
+ fn integration_detach_worktree_refuses_markerless_slot() {
+ let (_dir, repo) = temp_repo();
+ let slot = markerless_slot(&repo);
+ let backend = ShellGitBackend::discover().unwrap();
+
+ let branch_before = backend
+ .run_stdout(
+ Some(&repo),
+ &["symbolic-ref", "--short", "HEAD"],
+ GitErrorKind::Other,
+ )
+ .unwrap();
+
+ let err = backend
+ .detach_worktree(&slot)
+ .expect_err("detach must refuse a slot with no .git marker");
+ assert!(
+ err.message.contains("refusing to modify"),
+ "error must state the refusal, got: {}",
+ err.message
+ );
+
+ let branch_after = backend
+ .run_stdout(
+ Some(&repo),
+ &["symbolic-ref", "--short", "HEAD"],
+ GitErrorKind::Other,
+ )
+ .unwrap();
+ assert_eq!(
+ branch_after, branch_before,
+ "checkout --detach must not have run against the enclosing repo"
+ );
+ }
+
+ /// Go `markerPresent` counts a dangling symlink as PRESENT and surfaces
+ /// the unresolvable target as a read failure — never as an absent marker,
+ /// which would let the caller fall through to the enclosing repository.
+ #[test]
+ #[cfg(unix)]
+ fn integration_markerless_check_rejects_dangling_git_symlink() {
+ let (_dir, repo) = temp_repo();
+ let slot = markerless_slot(&repo);
+ std::os::unix::fs::symlink(repo.join(".git").join("does-not-exist"), slot.join(".git"))
+ .unwrap();
+
+ let err = require_worktree_marker(&slot).expect_err("dangling marker must be refused");
+ assert!(
+ err.message.starts_with("resolving .git marker in"),
+ "a dangling symlink is a read failure, not an absent marker; got: {}",
+ err.message
+ );
+ }
+
+ /// The guard must not be so strict that it breaks the real thing: a linked
+ /// worktree's `.git` is a FILE, and that is the normal pool-slot shape.
+ /// (`integration_reset_worktree_cleans_dirty_changes` covers the happy path
+ /// too; this pins the file-vs-directory contract explicitly.)
+ #[test]
+ fn integration_marker_check_accepts_dot_git_file_and_directory() {
+ let (_dir, repo) = temp_repo();
+ let wt = repo.join("wt");
+ must_git(
+ Some(&repo),
+ &["worktree", "add", "--detach", wt.to_str().unwrap(), "main"],
+ );
+
+ let marker = wt.join(".git");
+ assert!(
+ marker.is_file(),
+ "a linked worktree's .git is a file, and the guard must accept it"
+ );
+ require_worktree_marker(&wt).expect("linked worktree must pass the marker check");
+ require_worktree_marker(&repo).expect("primary checkout's .git dir must pass too");
+ }
+
+ #[test]
+ fn integration_marker_check_refuses_missing_directory() {
+ let dir = tempfile::tempdir().unwrap();
+ let err = require_worktree_marker(&dir.path().join("nope"))
+ .expect_err("a path that does not exist has no marker");
+ assert!(
+ err.message.contains("refusing to modify"),
+ "got: {}",
+ err.message
+ );
+ }
}
diff --git a/crates/treehouse-core/src/lease.rs b/crates/treehouse-core/src/lease.rs
index 8566d55..6c52201 100644
--- a/crates/treehouse-core/src/lease.rs
+++ b/crates/treehouse-core/src/lease.rs
@@ -187,6 +187,7 @@ mod tests {
leased_at: now(),
..WorktreeEntry::default()
}],
+ ..Default::default()
};
// Even with zero matching processes, heal must not clear the lease.
heal_state(&mut state, |_| None);
diff --git a/crates/treehouse-core/src/pool.rs b/crates/treehouse-core/src/pool.rs
index d695737..c01c876 100644
--- a/crates/treehouse-core/src/pool.rs
+++ b/crates/treehouse-core/src/pool.rs
@@ -17,7 +17,7 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
-use crate::config::{TreehouseConfig, resolve_pool_dir};
+use crate::config::{TreehouseConfig, resolve_pool_dir_with_env};
use crate::env::{DefaultEnv, TreehouseEnv};
use crate::git::{GitBackend, GitRepo};
use crate::hooks;
@@ -33,6 +33,15 @@ use crate::state_file;
pub struct OpenOptions {
pub config: TreehouseConfig,
pub lock_timeout: std::time::Duration,
+ /// The `--root` flag: a pool root the caller named explicitly.
+ ///
+ /// A separate tier from `config.root` because Go's precedence is
+ /// `flag > TREEHOUSE_ROOT > treehouse.toml root > ~/.treehouse`
+ /// (`config.ResolveRoot`, config.go:110-117) and the env var is read
+ /// inside `open_with_env`, below where a flag belongs. Folding the flag
+ /// into `config.root` would let `TREEHOUSE_ROOT` silently outrank
+ /// something the user typed on the command line.
+ pub root_override: Option,
}
impl Default for OpenOptions {
@@ -40,6 +49,7 @@ impl Default for OpenOptions {
Self {
config: TreehouseConfig::default_config(),
lock_timeout: DEFAULT_LOCK_TIMEOUT,
+ root_override: None,
}
}
}
@@ -47,8 +57,32 @@ impl Default for OpenOptions {
/// Options for acquiring a worktree (`get`).
#[derive(Debug, Clone, Default)]
pub struct AcquireOptions {
- /// Override the branch to reset to.
+ /// The branch a worktree is cut from and reset to — Go's `BaseBranch`,
+ /// NOT Go's `Branch` (which *creates* a branch). The two are separate
+ /// options upstream because "cut from X" and "check out new Y" are
+ /// separate decisions; conflating them is how a caller ends up resetting
+ /// every acquired slot to a branch it meant to create.
pub branch: Option,
+ /// Skip the `origin` fetch (Go `SkipFetch`, `--no-fetch`). For an
+ /// air-gapped or offline acquisition, where the fetch would fail the
+ /// whole command over a ref the worktree could have been cut from
+ /// locally.
+ pub skip_fetch: bool,
+ /// CREATE and check out a new branch at the acquired commit (Go `Branch`,
+ /// `-b`). Distinct from [`Self::branch`], which is where the worktree is
+ /// cut FROM: this is the branch that comes into existence. The two are
+ /// separate options upstream because "cut from X" and "check out new Y"
+ /// are separate decisions.
+ pub new_branch: Option,
+ /// Name a newly created worktree's directory `-` instead of
+ /// `` (Go `UniqueLeaf`, `--unique-leaf`). Creation-time naming only —
+ /// it never moves a worktree that already exists.
+ pub unique_leaf: bool,
+ /// Template for a newly created worktree's directory (Go `WorktreePath`,
+ /// `--worktree-path`), e.g. `{pool}/{slot}/{repo}`. `None` keeps the
+ /// built-in layout. A template supersedes `unique_leaf` because it names
+ /// every segment of the path including the leaf.
+ pub worktree_path: Option,
/// Lease the worktree (non-interactive) instead of an owner reservation.
pub lease: Option,
}
@@ -70,12 +104,283 @@ pub struct Acquired {
pub lease: Option,
}
+/// The environment variable that opts into unique worktree leaf directories
+/// (Go `config.UniqueLeafEnvVar`, config.go:68).
+pub const TREEHOUSE_UNIQUE_LEAF_VAR: &str = "TREEHOUSE_UNIQUE_LEAF";
+
+/// The environment variable that overrides the configured worktree path
+/// template (Go `config.WorktreePathEnvVar`, config.go:73).
+pub const TREEHOUSE_WORKTREE_PATH_VAR: &str = "TREEHOUSE_WORKTREE_PATH";
+
+/// Placeholders a worktree path template may use (Go
+/// `internal/pool/worktree_path.go:16-19`). `{pool}` and `{repo_parent}`
+/// expand to absolute directories; `{slot}` and `{repo}` are a single segment.
+pub const PLACEHOLDER_POOL: &str = "{pool}";
+pub const PLACEHOLDER_SLOT: &str = "{slot}";
+pub const PLACEHOLDER_REPO: &str = "{repo}";
+pub const PLACEHOLDER_REPO_PARENT: &str = "{repo_parent}";
+
+const WORKTREE_PATH_PLACEHOLDERS: [&str; 4] = [
+ PLACEHOLDER_POOL,
+ PLACEHOLDER_SLOT,
+ PLACEHOLDER_REPO,
+ PLACEHOLDER_REPO_PARENT,
+];
+
+/// Placeholders that can tell one repository's slots from another's (Go
+/// `repositoryScopedPlaceholders`, worktree_path.go:52-56).
+///
+/// One is REQUIRED: slot names are allocated per pool, so a template carrying
+/// none sends the first slot of every repository to the same directory.
+/// `{repo_parent}` is deliberately absent — two sibling repositories expand it
+/// identically, so it distinguishes nothing on its own.
+const REPOSITORY_SCOPED_PLACEHOLDERS: [&str; 2] = [PLACEHOLDER_POOL, PLACEHOLDER_REPO];
+
+/// Resolves the directory a newly created slot is placed in (Go
+/// `resolveWorktreePath`, worktree_path.go:170-198).
+///
+/// An empty template keeps the built-in `//` layout byte for
+/// byte; `unique_leaf` then appends `-` to the leaf, which is what makes
+/// two clones' slots stop colliding on one directory name. A template
+/// SUPERSEDES `unique_leaf` because it names every segment including the leaf.
+/// Recycled slots never reach here — they are handed back at the path recorded
+/// in state, so a template never moves a worktree that exists.
+pub(crate) fn resolve_worktree_path(
+ repo_root: &Path,
+ pool_dir: &Path,
+ slot: &str,
+ template: &str,
+ unique_leaf: bool,
+) -> Result {
+ let repo_name = repo_root
+ .file_name()
+ .map(|n| n.to_string_lossy().into_owned())
+ .unwrap_or_else(|| "repo".to_string());
+
+ if template.is_empty() {
+ let leaf = if unique_leaf {
+ format!("{repo_name}-{slot}")
+ } else {
+ repo_name.clone()
+ };
+ return Ok(pool_dir.join(slot).join(leaf));
+ }
+
+ let expanded = validate_worktree_path_template(template)?;
+ let replaced = expanded
+ .replace(PLACEHOLDER_POOL, &pool_dir.to_string_lossy())
+ .replace(PLACEHOLDER_SLOT, slot)
+ .replace(PLACEHOLDER_REPO, &repo_name)
+ .replace(PLACEHOLDER_REPO_PARENT, &repo_root.parent().unwrap_or(repo_root).to_string_lossy());
+
+ let resolved = PathBuf::from(clean_slash_path(&replaced));
+ if !resolved.is_absolute() {
+ return Err(PoolError::Io(
+ format!(
+ "worktree path {template:?} resolves to the relative path {:?}",
+ resolved.display().to_string()
+ ),
+ std::io::Error::new(std::io::ErrorKind::InvalidInput, "not absolute"),
+ ));
+ }
+ Ok(resolved)
+}
+
+/// Checks a template without touching the filesystem and returns what it
+/// expands to (Go `validateWorktreePathTemplate`, worktree_path.go:87-129).
+///
+/// Acquire runs this before anything else, so a bad template fails on EVERY
+/// invocation — including the ones that recycle an existing slot and so never
+/// expand it. Otherwise a typo would silently hand back a slot at the built-in
+/// layout and never report itself.
+pub(crate) fn validate_worktree_path_template(template: &str) -> Result {
+ if template.is_empty() {
+ return Ok(String::new());
+ }
+ let bad = |msg: String| {
+ PoolError::Io(
+ msg,
+ std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid worktree path"),
+ )
+ };
+ // Every `{...}` group must be a known placeholder, so a misspelling is
+ // reported instead of becoming a literal directory name.
+ for group in braced_groups(template) {
+ if !WORKTREE_PATH_PLACEHOLDERS.contains(&group.as_str()) {
+ return Err(bad(format!(
+ "worktree path {template:?} uses unknown placeholder {group} (supported: {})",
+ WORKTREE_PATH_PLACEHOLDERS.join(" ")
+ )));
+ }
+ }
+ if !template.contains(PLACEHOLDER_SLOT) {
+ return Err(bad(format!(
+ "worktree path {template:?} must contain {PLACEHOLDER_SLOT}: without it every pool slot resolves to the same directory"
+ )));
+ }
+ // Carrying `{slot}` is not enough: a following `..` cancels the segment it
+ // produced, so two slots clean down to one path (Go `slotsResolveApart`,
+ // worktree_path.go:135-142). Only `{slot}` is substituted; the others stay
+ // literal text, which path cleaning treats as ordinary segments.
+ let resolve_for = |slot: &str| clean_slash_path(&template.replace(PLACEHOLDER_SLOT, slot));
+ if resolve_for("slot-a") == resolve_for("slot-b") {
+ return Err(bad(format!(
+ "worktree path {template:?} resolves to the same directory for every slot, so two slots would share one worktree; give {PLACEHOLDER_SLOT} a path segment that survives path cleaning"
+ )));
+ }
+ if !REPOSITORY_SCOPED_PLACEHOLDERS
+ .iter()
+ .any(|p| template.contains(p))
+ {
+ return Err(bad(format!(
+ "worktree path {template:?} must contain one of {}: slot names are allocated per repository, so without one the first slot of every repository resolves to the same directory",
+ REPOSITORY_SCOPED_PLACEHOLDERS.join(" ")
+ )));
+ }
+ Ok(template.to_string())
+}
+
+/// The `GitRepo` a repository-scoped branch check runs against.
+fn repo_for_branch(root: &Path) -> GitRepo {
+ GitRepo {
+ common_dir: root.to_path_buf(),
+ worktree: None,
+ }
+}
+
+/// Rejects a branch name git itself would refuse (Go
+/// `vcs.ValidateBranchName` -> `git check-ref-format --branch`).
+///
+/// `--branch` names a ref that is created in the user's repository, so a name
+/// like `../evil` or `-x` must be refused before anything runs — git would
+/// treat those as options or as a path rather than as a branch.
+pub(crate) fn validate_branch_name(branch: &str) -> Result<(), PoolError> {
+ if branch.is_empty() {
+ return Err(PoolError::Io(
+ "--branch requires a non-empty branch name".to_string(),
+ std::io::Error::new(std::io::ErrorKind::InvalidInput, "empty branch name"),
+ ));
+ }
+ // Git's own rule set, inlined so a bad name is rejected without spawning a
+ // process and without depending on the caller's repository: the ref must
+ // not begin with `-` or `.`, must not end in `.lock`, must not contain a
+ // control character, ` `, `~`, `^`, `:`, `?`, `*`, `[`, or `\`, must not
+ // contain `..` or `@{`, and must contain no empty path component.
+ let bad = |why: &str| {
+ PoolError::Io(
+ format!("invalid branch name {branch:?}: {why}"),
+ std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid branch name"),
+ )
+ };
+ if branch.starts_with('-') {
+ return Err(bad("it would be read as an option"));
+ }
+ if branch.starts_with('.') {
+ return Err(bad("a ref component may not begin with '.'"));
+ }
+ if branch.contains("..") {
+ return Err(bad("it contains '..'"));
+ }
+ if branch.contains("@{") {
+ return Err(bad("it contains '@{'"));
+ }
+ if branch.ends_with('.') {
+ return Err(bad("a ref must not end with '.'"));
+ }
+ if branch.ends_with(".lock") {
+ return Err(bad("a ref may not end with '.lock'"));
+ }
+ if branch.ends_with('/') {
+ return Err(bad("a ref must not end with '/'"));
+ }
+ if branch.starts_with('/') {
+ return Err(bad("a ref must not begin with '/'"));
+ }
+ if branch.contains("//") {
+ return Err(bad("it contains an empty path component"));
+ }
+ for ch in branch.chars() {
+ if (ch as u32) < 0x20 || ch == ' ' || " ~^:?*[\\".contains(ch) {
+ return Err(bad("it contains a character git forbids in a ref name"));
+ }
+ }
+ Ok(())
+}
+
+/// Every `{...}` group in `template`, in order (Go `placeholderPattern`,
+/// worktree_path.go:60).
+fn braced_groups(template: &str) -> Vec {
+ let bytes: Vec = template.chars().collect();
+ let mut groups = Vec::new();
+ let mut i = 0;
+ while i < bytes.len() {
+ // A group is `{...}` with no nested `{` inside; anything else (a
+ // `{}` placeholder, or an unclosed brace) is left as ordinary text and
+ // rejected by the caller's unknown-placeholder check.
+ if bytes[i] == '{'
+ && let Some(close) = (i + 1..bytes.len()).find(|&j| bytes[j] == '}')
+ && !bytes[i + 1..close].contains(&'{')
+ {
+ groups.push(bytes[i..=close].iter().collect());
+ i = close + 1;
+ continue;
+ }
+ i += 1;
+ }
+ groups
+}
+
+/// Lexical path cleaning, matching Go's `filepath.Clean(filepath.FromSlash(...))`
+/// closely enough for the template checks above: collapse `//`, drop `.`, and
+/// resolve `..` without touching the filesystem.
+fn clean_slash_path(path: &str) -> String {
+ let absolute = path.starts_with('/');
+ let mut out: Vec<&str> = Vec::new();
+ for segment in path.split('/') {
+ match segment {
+ "" | "." => {}
+ ".." => {
+ match out.last() {
+ Some(&last) if last != ".." => {
+ out.pop();
+ }
+ _ => {
+ if !absolute {
+ out.push("..");
+ }
+ }
+ };
+ }
+ other => out.push(other),
+ }
+ }
+ let joined = out.join("/");
+ if absolute {
+ format!("/{joined}")
+ } else if joined.is_empty() {
+ ".".to_string()
+ } else {
+ joined
+ }
+}
+
/// The worktree status string values (Go parity).
pub const STATUS_AVAILABLE: &str = "available";
pub const STATUS_IN_USE: &str = "in-use";
pub const STATUS_DIRTY: &str = "dirty";
pub const STATUS_LEASED: &str = "leased";
pub const STATUS_HERE: &str = "you're here";
+/// A slot whose contents cannot be judged at all (Go `StatusDamaged`,
+/// pool.go:25): its VCS marker is missing, so every backend dispatch on that
+/// path would silently resolve the repository ENCLOSING the pool. Distinct
+/// from `dirty` — which is a claim about content — and from `in use`, which is
+/// a claim about a reservation.
+pub const STATUS_DAMAGED: &str = "damaged";
+
+/// The environment variable that overrides the configured pool root
+/// (Go `config.RootEnvVar`, config.go:63). Outranks `treehouse.toml`'s
+/// `root`, which in turn outranks the built-in default.
+pub(crate) const TREEHOUSE_ROOT_VAR: &str = "TREEHOUSE_ROOT";
/// One worktree's status as reported by `status`.
#[derive(Debug, Clone)]
@@ -112,13 +417,39 @@ impl Pool {
}
/// Opens a pool using the injected environment.
+ ///
+ /// The pool root is resolved through `env`, never through the process
+ /// environment directly: `--env-path` arrives here as a `TreehouseEnv`
+ /// whose `pool_root()` names a custom directory, and calling the
+ /// non-`_with_env` resolver silently discarded it, so the flag was a no-op
+ /// and every invocation fell back to `~/.treehouse`.
+ ///
+ /// Precedence mirrors Go `config.ResolveRoot` (config.go:110-117):
+ /// `TREEHOUSE_ROOT` outranks the `treehouse.toml` `root`, and the default
+ /// is whichever tier each one resolves to. `env.pool_root()` is the
+ /// `--env-path` channel and, as in Go, still wins when neither an env var
+ /// nor a config root is set — it is the final fallback, not a competitor to
+ /// the `root` argument. `opts.root_override` is the `--root` flag and sits
+ /// above all of them, because Go ranks an explicit flag first.
pub fn open_with_env(
repo_root: &Path,
remote_url: Option<&str>,
opts: &OpenOptions,
env: Arc,
) -> Result {
- let dir = resolve_pool_dir(repo_root, opts.config.root.as_deref(), remote_url)
+ // `TREEHOUSE_ROOT` is read through the injected env rather than
+ // `std::env` so an in-memory environment can exercise the override
+ // tier without mutating the process environment of the test binary.
+ let env_root = env
+ .env_var(TREEHOUSE_ROOT_VAR)
+ .filter(|v| !v.is_empty())
+ .map(|v| v.to_string());
+ let root = opts
+ .root_override
+ .clone()
+ .or(env_root)
+ .or_else(|| opts.config.root.clone());
+ let dir = resolve_pool_dir_with_env(repo_root, root.as_deref(), remote_url, env.as_ref())
.map_err(PoolError::Config)?;
env.ensure_dir(&dir)
.map_err(|e| PoolError::Io(format!("creating pool dir {}", dir.display()), e))?;
@@ -185,11 +516,129 @@ impl Pool {
Ok(self.git.is_dirty(path)?)
}
+ /// Whether `dir` is a treehouse pool directory (Go `pool.IsPoolDir`).
+ ///
+ /// The state file is the marker, exactly as upstream: a directory that only
+ /// *looks* like a pool (right parent, right name) is not one, and treating
+ /// it as one would let `destroy --all` aim at an unrelated tree.
+ pub fn is_pool_dir(dir: &Path) -> bool {
+ State::state_file_path(dir).is_file()
+ }
+
+ /// Whether `path`'s VCS marker can be read, i.e. whether it names a real
+ /// worktree of the configured backend (Go `vcs.WorktreeBackendName`).
+ ///
+ /// `false` means the slot is damaged, and a caller must NOT dispatch git on
+ /// it: there is deliberately no fallback backend, because in the supported
+ /// in-project pool layout the fallback resolves the repository ENCLOSING
+ /// the pool and every "fact" it reports — dirty, in-use, branch — is about
+ /// the user's own working tree rather than the slot.
+ pub fn is_readable_worktree(path: &Path) -> bool {
+ !slot_backend_name(path).is_empty()
+ }
+
+ /// Every worktree entry, in state order, read under the pool lock.
+ fn read_entries(&self) -> Result, PoolError> {
+ with_pool_lock(&self.dir, self.lock_timeout, || {
+ let state = State::read_state(&self.dir).map_err(PoolError::State)?;
+ Ok(state.worktrees)
+ })
+ }
+
+ /// Looks a worktree up by its exact path (Go `pool.FindByPath`).
+ pub fn find_by_path(&self, path: &str) -> Result, PoolError> {
+ Ok(self.read_entries()?.into_iter().find(|w| w.path == path))
+ }
+
+ /// Looks a worktree up by its slot name — the first column
+ /// `status` prints (Go `pool.FindByName`).
+ pub fn find_by_name(&self, name: &str) -> Result , PoolError> {
+ Ok(self.read_entries()?.into_iter().find(|w| w.name == name))
+ }
+
+ /// Every slot name in the pool, for the diagnostic Go's
+ /// `unknownWorktreeNameError` prints alongside a refused name.
+ pub fn worktree_names(&self) -> Result, PoolError> {
+ Ok(self.read_entries()?.into_iter().map(|w| w.name).collect())
+ }
+
+ /// Durably leases an EXISTING worktree by name, in place (Go
+ /// `pool.LeaseExisting`, the `treehouse lease` verb, #128).
+ ///
+ /// State-only by contract: nothing is fetched, reset, cleaned, or checked
+ /// out, so it is safe on a slot already holding live work. That is the
+ /// whole point of the verb — it hands a slot the same "never handed out,
+ /// never pruned, never destroyed" protection a leased *acquisition* has,
+ /// without touching a single byte of the worktree.
+ pub fn lease_existing(&self, name: &str, holder: &str) -> Result {
+ with_pool_lock(&self.dir, self.lock_timeout, || {
+ let mut state = State::read_state(&self.dir).map_err(PoolError::State)?;
+ // Read BEFORE heal, so a name whose directory has since vanished is
+ // still "registered" and gets the actionable stale-entry message
+ // rather than the misleading "no worktree named X".
+ let registered = state.worktrees.iter().any(|w| w.name == name);
+ heal_state(&mut state, |pid| self.process.started_at(pid));
+
+ for wt in &mut state.worktrees {
+ if wt.name != name {
+ continue;
+ }
+ if wt.destroying {
+ return Err(PoolError::BeingDestroyed(name.to_string()));
+ }
+ // Refuse rather than re-mint: a second lease would orphan the
+ // first holder's identity, and `return --if-lease-id` for the
+ // original would then silently stop matching.
+ if wt.leased {
+ return Err(PoolError::AlreadyLeased {
+ name: name.to_string(),
+ holder: wt.lease_holder.clone(),
+ });
+ }
+ mark_acquired_lease(wt, holder, chrono::Utc::now());
+ let info = crate::lease::lease_info_from_entry(wt);
+ state_file::write_state(&self.dir, &state)
+ .map_err(|e| PoolError::Io("writing state".to_string(), e))?;
+ return Ok(info);
+ }
+
+ if registered {
+ return Err(PoolError::StaleEntry(name.to_string()));
+ }
+ Err(PoolError::NoSuchWorktree(name.to_string()))
+ })
+ }
+
/// Acquires a worktree from the pool (`get`).
///
/// Short-lock protocol: branch+fetch outside the lock; reserve under lock;
/// reset outside; re-validate under a second lock; hooks outside.
pub fn get(&self, opts: &AcquireOptions) -> Result {
+ // Step 0 (OUTSIDE lock): validate the placement and branch options
+ // BEFORE the fetch and before any slot is inspected.
+ //
+ // Both checks are pure text rules, so a wrong value costs nothing to
+ // reject. Running them here — rather than only on the creation branch —
+ // is what makes them bite on every invocation, INCLUDING the ones that
+ // recycle an existing slot and so never expand the template. Without
+ // this, a typo silently hands back a slot at the built-in layout and
+ // never reports itself (Go `acquire`, pool.go:377-386).
+ validate_worktree_path_template(opts.worktree_path.as_deref().unwrap_or(""))?;
+ if let Some(new_branch) = opts.new_branch.as_deref() {
+ validate_branch_name(new_branch)?;
+ // Fails if the branch ALREADY exists: `-b` creates, it never
+ // adopts (Go pool.go:412-418).
+ if self.git.local_branch_exists(&repo_for_branch(&self.root), new_branch) {
+ return Err(PoolError::Io(
+ format!("branch {new_branch:?} already exists"),
+ std::io::Error::new(
+ std::io::ErrorKind::AlreadyExists,
+ "branch already exists",
+ ),
+ ));
+ }
+ }
+
// Step 1 (OUTSIDE lock): resolve the default branch and fetch origin.
let repo = GitRepo {
common_dir: self.root.clone(),
@@ -199,10 +648,17 @@ impl Pool {
Some(b) => b.clone(),
None => self.git.default_branch(&repo).map_err(PoolError::Git)?,
};
- if self.git.has_remote(&repo, "origin") {
+ if !opts.skip_fetch && self.git.has_remote(&repo, "origin") {
self.git.fetch(&repo).map_err(PoolError::Git)?;
}
+ // Step 1b (OUTSIDE lock): resolve this clone's identity once, so the
+ // reuse loop can scope candidates to it without a `rev-parse` per slot
+ // (Go `commonDir, identityErr := acquisitionCommonGitDir(repoRoot)`,
+ // pool.go:412). After the fetch, matching Go: a base that exists only
+ // on origin must be rejected against post-fetch refs.
+ let requester = RequesterIdentity::resolve(self.git.as_ref(), &self.root);
+
// Step 2 (LOCK #1): read + heal + scan + mark acquired + write.
let (name, path, lease) = acquire_locked(
&self.dir,
@@ -214,6 +670,7 @@ impl Pool {
opts,
&branch,
&repo,
+ &requester,
)?;
// Step 3 (OUTSIDE lock): reset the worktree to the default branch.
@@ -222,6 +679,18 @@ impl Pool {
.reset_worktree(Path::new(&path), &branch)
.map_err(PoolError::Git)?;
+ // Step 3b (OUTSIDE lock): create and check out the requested branch.
+ //
+ // This is Go's `-b` (AcquireOptions.Branch, pool.go:567-601), and it
+ // runs AFTER the reset so the branch is created at the commit the slot
+ // was actually acquired at — creating it earlier would pin it to
+ // whatever HEAD the slot happened to be recycled with.
+ if let Some(new_branch) = opts.new_branch.as_deref() {
+ self.git
+ .create_branch(Path::new(&path), new_branch)
+ .map_err(PoolError::Git)?;
+ }
+
// Step 4 (LOCK #2): re-validate the reservation is intact; rewrite
// only if heal changed something.
with_pool_lock(&self.dir, self.lock_timeout, || {
@@ -268,13 +737,36 @@ impl Pool {
holder: &str,
ttl: Option,
) -> Result {
- let acquired = self.get(&AcquireOptions {
- lease: Some(LeaseAcquireOptions {
- holder: holder.to_string(),
- ttl,
- }),
- ..Default::default()
- })?;
+ self.acquire_lease_with_options(
+ &AcquireOptions {
+ lease: Some(LeaseAcquireOptions {
+ holder: holder.to_string(),
+ ttl,
+ }),
+ ..Default::default()
+ },
+ holder,
+ )
+ }
+
+ /// Lease acquire that also honours the acquisition flags (Go
+ /// `getLeaseRunE(repoRoot, poolDir, cfg, acquireOpts)`, cmd/get.go:169-173).
+ ///
+ /// This exists so `--lease` does not silently discard `--base`, `-b`,
+ /// `--unique-leaf` and `--worktree-path`: the holder/TTL pair alone cannot
+ /// carry them, and folding them in here keeps one acquire path for both
+ /// modes instead of two that disagree.
+ pub fn acquire_lease_with_options(
+ &self,
+ opts: &AcquireOptions,
+ holder: &str,
+ ) -> Result {
+ let mut opts = opts.clone();
+ opts.lease = Some(LeaseAcquireOptions {
+ holder: holder.to_string(),
+ ttl: opts.lease.as_ref().and_then(|l| l.ttl),
+ });
+ let acquired = self.get(&opts)?;
let lease = acquired.lease.as_ref();
Ok(LeaseInfo {
path: acquired.path.to_string_lossy().into_owned(),
@@ -345,7 +837,7 @@ impl Pool {
// is still reserved.
with_pool_lock(&self.dir, self.lock_timeout, || {
let mut state = State::read_state(&self.dir).map_err(PoolError::State)?;
- let _ = releasable_worktree(&mut state, worktree_path, preconditions)?;
+ let _ = releasable_worktree(&mut state, worktree_path, preconditions, &self.process)?;
if let Some(cb) = before_reset {
cb()?;
}
@@ -364,7 +856,7 @@ impl Pool {
// and fail its precondition, so only one release ever succeeds.
with_pool_lock(&self.dir, self.lock_timeout, || {
let mut state = State::read_state(&self.dir).map_err(PoolError::State)?;
- let wt = releasable_worktree(&mut state, worktree_path, preconditions)?;
+ let wt = releasable_worktree(&mut state, worktree_path, preconditions, &self.process)?;
wt.owner_pid = 0;
wt.owner_started_at = 0;
crate::state::clear_lease(wt);
@@ -384,7 +876,7 @@ impl Pool {
) -> Result<(), PoolError> {
with_pool_lock(&self.dir, self.lock_timeout, || {
let mut state = State::read_state(&self.dir).map_err(PoolError::State)?;
- let _ = releasable_worktree(&mut state, worktree_path, preconditions)?;
+ let _ = releasable_worktree(&mut state, worktree_path, preconditions, &self.process)?;
Ok(())
})
}
@@ -430,9 +922,24 @@ impl Pool {
if cwd_in_worktree(&cwd, Path::new(&wt.path)) {
ws.status = STATUS_HERE.to_string();
}
+ } else if slot_backend_name(Path::new(&wt.path)).is_empty() {
+ // Damaged. Checked before `is_dirty` because that call
+ // would resolve the repository ENCLOSING the pool and
+ // report on it, and reported separately from "dirty" so a
+ // slot whose contents cannot be read is never mistaken for
+ // one that was read and found changed.
+ ws.status = STATUS_DAMAGED.to_string();
} else if self.git.is_dirty(Path::new(&wt.path)).unwrap_or(false) {
ws.status = STATUS_DIRTY.to_string();
}
+ // A slot the recovery scan adopted but could not read is
+ // reported damaged rather than leased, so the read failure is
+ // never mistaken for an ordinary lease. It stays leased
+ // underneath, so acquire and prune keep skipping it like every
+ // other recovered entry (Go pool.go:1258-1261).
+ if !wt.recovery_error.is_empty() {
+ ws.status = STATUS_DAMAGED.to_string();
+ }
result.push(ws);
}
Ok(result)
@@ -451,13 +958,29 @@ pub(crate) fn with_pool_lock(
}
impl Pool {
+ /// Flattens the lock's double-`Result` into a single [`PoolError`].
+ ///
+ /// A failure from inside the critical section is returned AS IS. Stringifying
+ /// it would bury every typed error a caller can match on — `PoolFull` and
+ /// the reason it is full, `BeingDestroyed`, `LeasePrecondition` — behind an
+ /// opaque `Lock` variant that only its `Display` can recover. Only a real
+ /// lock failure (timeout, I/O on the lock file) becomes `PoolError::Lock`.
fn lock_err_ty(e: LockError) -> PoolError {
- PoolError::Lock(e.to_string())
+ match e {
+ LockError::Callback(inner) => inner,
+ other => PoolError::Lock(other.to_string()),
+ }
}
}
/// The lock-acquire critical section (LOCK #1 of `get`): read + heal + scan +
/// mark acquired + write, all under the pool state lock.
+///
+/// `requester` is the requester's own clone identity, resolved by the caller
+/// before the lock was taken (Go `acquisitionCommonGitDir(repoRoot)` at
+/// pool.go:412). Resolving it once outside the loop matters: a pool shared by
+/// two clones must never hand one clone a slot the other owns, and proving
+/// that is a `git rev-parse` per candidate otherwise.
#[allow(clippy::too_many_arguments)]
fn acquire_locked(
dir: &Path,
@@ -469,34 +992,78 @@ fn acquire_locked(
opts: &AcquireOptions,
branch: &str,
repo: &GitRepo,
+ requester: &RequesterIdentity,
) -> Result<(String, String, Option), PoolError> {
with_state_lock(dir, lock_timeout, || {
let mut state = State::read_state(dir).map_err(PoolError::State)?;
heal_state(&mut state, |pid| process.started_at(pid));
- // Find an available worktree (clean, not in-use, not leased).
+ // Why a candidate slot was passed over, counted so a full pool can say
+ // WHY it is full. Go feeds the same two counters into its PoolFull
+ // message (pool.go:641-646): "in use" is the ordinary case and needs no
+ // mention, but "another clone owns it" and "we could not prove who owns
+ // it" are the two an operator can act on, and silently reporting a
+ // generic full pool sends them looking in the wrong place.
+ let mut skips = SkipCounts::default();
+
+ // Find an available worktree (clean, not in-use, not leased, this
+ // clone's, and holding no unlanded work).
for i in 0..state.worktrees.len() {
- let (name, path, available) = {
+ let candidate = {
let wt = &state.worktrees[i];
if wt.destroying || wt.leased || reservation::owner_alive(wt, process) {
- (String::new(), String::new(), false)
- } else {
- let in_use = process
- .is_worktree_in_use(Path::new(&wt.path))
- .unwrap_or(true);
- if in_use {
- (String::new(), String::new(), false)
- } else {
- let dirty = git.is_dirty(Path::new(&wt.path)).map_err(PoolError::Git)?;
- if dirty {
- (String::new(), String::new(), false)
- } else {
- (wt.name.clone(), wt.path.clone(), true)
- }
+ continue;
+ }
+ let path = Path::new(&wt.path);
+
+ // Marker first, before ANY dispatch on this path (Go
+ // pool.go:468-480). A slot with no marker would have its
+ // in-use, dirty and merge checks resolved against the
+ // repository ENCLOSING the pool, and the reset would rewrite
+ // that repository instead. Skipping is the only safe answer.
+ if slot_backend_name(path).is_empty() {
+ continue;
+ }
+
+ // Clone identity, then availability. Pools are keyed by origin
+ // URL, so two clones of the same origin share one pool dir, but
+ // a linked worktree still belongs to ONE physical clone. Never
+ // reset another clone's slot — even when both clones hold
+ // identical refs — and never one whose owner, or our own,
+ // cannot be proven.
+ match requester.matches_slot(git.as_ref(), path) {
+ IdentityMatch::Yes => {}
+ IdentityMatch::OtherClone => {
+ skips.other_clone += 1;
+ continue;
}
+ IdentityMatch::Unverified => {
+ skips.unverified_clone += 1;
+ continue;
+ }
+ }
+
+ if process.is_worktree_in_use(path).unwrap_or(true) {
+ continue;
}
+
+ // A crashed or rebooted owner leaves the reservation empty
+ // while its worktree still holds committed work: a clean tree
+ // passes `is_dirty`, so availability alone must not authorize a
+ // reset. Fail closed — if the merge state cannot be proven,
+ // leave the slot untouched rather than let the reset discard
+ // the work (Go pool.go:500-522, fix #104).
+ match git.is_dirty(path) {
+ Ok(true) | Err(_) => continue,
+ Ok(false) => {}
+ }
+ if !requester.safe_to_reset(git.as_ref(), path, branch) {
+ continue;
+ }
+
+ Some((wt.name.clone(), wt.path.clone()))
};
- if available {
+ if let Some((name, path)) = candidate {
// Stamp the reservation (persisted now).
let lease_info = mark_acquired_entry(&mut state.worktrees[i], opts, process);
state_file::write_state(dir, &state)
@@ -507,17 +1074,16 @@ fn acquire_locked(
// No available worktree — create a new one if the pool allows.
if state.worktrees.len() as u32 >= max_trees {
- return Err(PoolError::PoolFull {
- count: state.worktrees.len() as u32,
- max: max_trees,
- });
+ return Err(skips.into_pool_full(state.worktrees.len() as u32, max_trees));
}
- let name = next_name(&state);
- let repo_name = root
- .file_name()
- .map(|n| n.to_string_lossy().into_owned())
- .unwrap_or_else(|| "repo".into());
- let wt_path = dir.join(&name).join(&repo_name);
+ let name = next_free_name(dir, &state);
+ let wt_path = resolve_worktree_path(
+ root,
+ dir,
+ &name,
+ opts.worktree_path.as_deref().unwrap_or(""),
+ opts.unique_leaf,
+ )?;
std::fs::create_dir_all(wt_path.parent().unwrap())
.map_err(|e| PoolError::Io("creating worktree parent".to_string(), e))?;
@@ -540,6 +1106,109 @@ fn acquire_locked(
.map_err(Pool::lock_err_ty)
}
+/// The outcome of comparing a candidate slot against the requester's clone.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum IdentityMatch {
+ /// Same physical clone — the only case in which a slot may be reused.
+ Yes,
+ /// A different clone owns this worktree.
+ OtherClone,
+ /// Ownership could not be proven, in either direction.
+ Unverified,
+}
+
+/// Why candidate slots were passed over, for the pool-full message.
+#[derive(Debug, Default, Clone, Copy)]
+struct SkipCounts {
+ /// Slots proven to belong to another clone (Go `otherClone`).
+ other_clone: u32,
+ /// Slots whose ownership could not be proven (Go `unverifiedClone`).
+ unverified_clone: u32,
+}
+
+impl SkipCounts {
+ /// Builds the pool-full error, naming the actionable skip reasons.
+ ///
+ /// The plain message is kept byte-for-byte for the ordinary case: scripts
+ /// match on it, and a full pool of genuinely in-use worktrees has nothing
+ /// extra to say.
+ fn into_pool_full(self, count: u32, max: u32) -> PoolError {
+ if self.other_clone > 0 || self.unverified_clone > 0 {
+ PoolError::PoolFullNotOurs {
+ count,
+ other_clone: self.other_clone,
+ unverified_clone: self.unverified_clone,
+ max,
+ }
+ } else {
+ PoolError::PoolFull { count, max }
+ }
+ }
+}
+
+/// The requesting clone's identity, resolved once before the state lock.
+struct RequesterIdentity {
+ /// The main repo root backing this clone, or `None` when it could not be
+ /// resolved. An unresolvable requester disables REUSE, never allocation:
+ /// a fresh worktree is created in this clone's own repository, which is
+ /// always safe, whereas reusing a slot of unknown provenance is not.
+ root: Option,
+}
+
+impl RequesterIdentity {
+ /// Resolves the requester's clone identity from its repository root
+ /// (Go `acquisitionCommonGitDir`, pool.go:365-372).
+ fn resolve(git: &dyn GitBackend, repo_root: &Path) -> Self {
+ Self {
+ root: git.main_repo_root(repo_root).ok(),
+ }
+ }
+
+ /// Whether `slot` is this clone's own worktree.
+ fn matches_slot(&self, git: &dyn GitBackend, slot: &Path) -> IdentityMatch {
+ let Some(mine) = &self.root else {
+ return IdentityMatch::Unverified;
+ };
+ match git.main_repo_root(slot) {
+ Ok(theirs) if same_file(mine, &theirs) => IdentityMatch::Yes,
+ Ok(_) => IdentityMatch::OtherClone,
+ Err(_) => IdentityMatch::Unverified,
+ }
+ }
+
+ /// Whether `slot` may be reset to `branch` without discarding committed
+ /// work (Go `vcs.IsWorktreeSafeToReset`).
+ ///
+ /// The check is `merge-base --is-ancestor HEAD [`: a slot whose HEAD is
+ /// reachable from the ref we are about to reset to holds nothing that reset
+ /// would lose. An error is NOT "safe" — the ref could not be resolved, the
+ /// worktree could not be read, or git failed — and every one of those means
+ /// the answer is unknown, so the slot is left alone.
+ ///
+ /// Go pins the verified commit SHA and HEAD so the guard and the reset
+ /// target the same object, and consults a second reading (the slot's
+ /// recorded base branch) when the first says unsafe. Neither is ported: the
+ /// `GitBackend` trait exposes no ref-to-SHA or HEAD accessor to pin against,
+ /// and this port's `WorktreeEntry` carries Go's `base_branch` only as an
+ /// uninterpreted `extra` field, so the second reading's required "HEAD did
+ /// not move between the two readings" guard has nothing to compare. What
+ /// ships is the first reading alone, which is strictly safer than the code
+ /// it replaces (no check at all) and strictly less permissive than Go.
+ fn safe_to_reset(&self, git: &dyn GitBackend, slot: &Path, branch: &str) -> bool {
+ let Some(root) = &self.root else {
+ return false;
+ };
+ let ref_ = git.branch_ref(
+ &GitRepo {
+ common_dir: root.clone(),
+ worktree: None,
+ },
+ branch,
+ );
+ matches!(git.is_head_merged_into_ref(slot, &ref_), Ok(true))
+ }
+}
+
/// Marks an entry as acquired (owner reservation or lease), returning lease
/// info if leasing. Extracted so both the reused and new-worktree paths share
/// it.
@@ -587,10 +1256,34 @@ fn cwd_in_worktree(cwd: &Path, worktree_path: &Path) -> bool {
}
/// Preconditions for a conditional release.
+///
+/// The three booleans are predicates, not flavours of the identity fields, and
+/// each one exists because a caller hands release authority to something
+/// other than the session that acquired the slot. `treehouse lease` and
+/// `treehouse return --all` both do exactly that: either one can act on a slot
+/// somebody else is holding, so without a guard the release would reset the
+/// worktree and clear the *new* owner's reservation while the new owner is
+/// still working in it.
+///
+/// They are checked under the same lock as the release (Go's
+/// `ValidateReleasePreconditions`, pool.go:967-981): checking and then acting
+/// in separate critical sections leaves a takeover window between them, and
+/// the reset is destructive.
#[derive(Debug, Clone, Default)]
pub struct ReleasePreconditions {
pub expected_lease_id: Option],
pub expected_lease_holder: Option,
+ /// Refuse unless the slot still carries THIS process's own owner
+ /// reservation. This is what an acquiring `treehouse get` holds until it
+ /// returns the slot.
+ pub require_owned_by_caller: bool,
+ /// Refuse once anybody has leased the slot. Replays an observation of an
+ /// unleased slot — "reclaim what nobody held" must not reclaim what
+ /// somebody now holds.
+ pub require_unleased: bool,
+ /// Refuse a recovered entry. Nothing about a slot the recovery scan had to
+ /// reconstruct proves it idle, so it is only released by naming it.
+ pub refuse_recovered: bool,
}
/// Finds a managed, releasable worktree by path, validating preconditions
@@ -599,6 +1292,7 @@ fn releasable_worktree<'a>(
state: &'a mut State,
worktree_path: &str,
preconditions: &ReleasePreconditions,
+ process: &ProcessTable,
) -> Result<&'a mut WorktreeEntry, PoolError> {
for wt in &mut state.worktrees {
if wt.path != worktree_path {
@@ -607,7 +1301,7 @@ fn releasable_worktree<'a>(
if wt.destroying {
return Err(PoolError::BeingDestroyed(worktree_path.to_string()));
}
- validate_release_preconditions_inner(wt, preconditions)?;
+ validate_release_preconditions_inner(wt, preconditions, process)?;
return Ok(wt);
}
Err(PoolError::NotFound(worktree_path.to_string()))
@@ -616,7 +1310,32 @@ fn releasable_worktree<'a>(
fn validate_release_preconditions_inner(
wt: &WorktreeEntry,
preconditions: &ReleasePreconditions,
+ process: &ProcessTable,
) -> Result<(), PoolError> {
+ if preconditions.require_owned_by_caller {
+ check_owned_by_caller(wt, process)?;
+ }
+ if preconditions.refuse_recovered
+ && wt.leased
+ && wt.lease_holder == crate::state::RECOVERED_LEASE_HOLDER
+ {
+ return Err(PoolError::RecoveredEntry(wt.path.clone()));
+ }
+ // `require_unleased` asserts the ABSENCE of a lease, which is the opposite
+ // of what the two identity fields assert, so it can never fall through to
+ // them: it returns here either way.
+ if preconditions.require_unleased {
+ if wt.leased {
+ return Err(PoolError::LeasePrecondition {
+ path: wt.path.clone(),
+ reason: format!(
+ "worktree {} was observed unleased and is leased now",
+ wt.path
+ ),
+ });
+ }
+ return Ok(());
+ }
if preconditions.expected_lease_id.is_none() && preconditions.expected_lease_holder.is_none() {
return Ok(());
}
@@ -645,6 +1364,49 @@ fn validate_release_preconditions_inner(
Ok(())
}
+/// Whether the slot still carries this process's own owner reservation (Go
+/// `checkOwnedByCaller`, pool.go:1418-1438).
+///
+/// `leased` is tested FIRST and deliberately: `mark_acquired`'s lease path
+/// zeroes the owner pair, so a durably-leased slot reads as "owner_pid == 0"
+/// and would otherwise be reported as "already released" — the one answer that
+/// lets the caller think nobody took it.
+fn check_owned_by_caller(wt: &WorktreeEntry, process: &ProcessTable) -> Result<(), PoolError> {
+ if wt.leased {
+ return Err(PoolError::OwnerPrecondition {
+ path: wt.path.clone(),
+ reason: if wt.lease_holder.is_empty() {
+ "it is now durably leased".to_string()
+ } else {
+ format!("it is now durably leased (holder: {:?})", wt.lease_holder)
+ },
+ });
+ }
+ if wt.owner_pid == 0 {
+ return Err(PoolError::OwnerPrecondition {
+ path: wt.path.clone(),
+ reason: "it was already released".to_string(),
+ });
+ }
+ let pid = std::process::id() as i32;
+ // Both fields are compared: a bare PID can have been recycled onto an
+ // unrelated process, whose reservation must not be mistaken for ours.
+ let Some(started_at) = process.started_at(pid) else {
+ return Err(PoolError::OwnerPrecondition {
+ path: wt.path.clone(),
+ reason: "this process's own identity could not be read to confirm the reservation"
+ .to_string(),
+ });
+ };
+ if wt.owner_pid != pid || wt.owner_started_at != started_at {
+ return Err(PoolError::OwnerPrecondition {
+ path: wt.path.clone(),
+ reason: "it is now reserved by another session".to_string(),
+ });
+ }
+ Ok(())
+}
+
/// The next numeric worktree name (Go `nextName`).
pub fn next_name(state: &State) -> String {
let mut max = 0;
@@ -658,6 +1420,92 @@ pub fn next_name(state: &State) -> String {
(max + 1).to_string()
}
+/// The next numeric worktree name that is free BOTH in state and on disk.
+///
+/// `next_name` alone derives the name from state, which is not enough: a
+/// worktree whose state entry was lost (a crash between `git worktree add`
+/// and the state write, a truncated state file, a hand-deleted entry) still
+/// occupies `//`, and reissuing that number would run `git worktree
+/// add` into a directory that already holds a real worktree. The state
+/// recovery scan in `state.rs` already adopts such directories as quarantined
+/// leases, but acquisition must not depend on that scan having run in this
+/// process — so the filesystem is the authority here, exactly as Go's
+/// `resolveWorktreePath` skips occupied candidates.
+///
+/// Fail-closed: if the pool directory cannot be read, the computed name is
+/// still returned and the subsequent `worktree_add` is what surfaces the
+/// problem. Silently returning a different number would hide the fault.
+pub(crate) fn next_free_name(pool_dir: &Path, state: &State) -> String {
+ let mut max = next_name(state).parse::().unwrap_or(1) - 1;
+ if let Ok(entries) = std::fs::read_dir(pool_dir) {
+ for entry in entries.flatten() {
+ if let Ok(n) = entry.file_name().to_string_lossy().parse::()
+ && n > max
+ {
+ max = n;
+ }
+ }
+ }
+ (max + 1).to_string()
+}
+
+/// The VCS marker a pooled worktree must carry, as a backend name (Go
+/// `vcs.WorktreeBackendName`, pool.go:499-518).
+///
+/// Returns `""` for a damaged or missing slot. There is deliberately NO
+/// fallback to a configured backend: without the marker, every git dispatch
+/// on that path walks UP to whatever repository encloses it, so in the
+/// supported in-project pool layout (`root = "."`, which nests `.treehouse`
+/// under the repo) the safety checks would vouch for the user's own working
+/// tree and the reset would rewrite it. `""` makes the caller skip the slot
+/// instead — destroy classifies it unverified, prune skips it as
+/// unverifiable, and neither path ever resets it.
+///
+/// lstat-then-stat, matching `git::shell::require_worktree_marker` and
+/// `state::marker_backend`: a dangling `.git` symlink IS an entry that is
+/// present on disk, and its unresolvable target is a read failure for the
+/// caller to report — not an absent marker. Only a genuinely absent entry is
+/// a damaged slot. jj is not a backend in this port, so only `.git` counts.
+pub(crate) fn slot_backend_name(worktree: &Path) -> String {
+ let marker = worktree.join(".git");
+ match std::fs::symlink_metadata(&marker) {
+ Ok(meta) if meta.file_type().is_symlink() => match std::fs::metadata(&marker) {
+ // A dangling or self-referential link cannot be judged: report it
+ // as damaged rather than guessing a backend.
+ Ok(_) => "git".to_string(),
+ Err(_) => String::new(),
+ },
+ Ok(_) => "git".to_string(),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
+ Err(_) => String::new(),
+ }
+}
+
+/// Whether two paths name the same filesystem object (Go `os.SameFile`).
+///
+/// Compares device + inode on unix, so neither a symlinked path nor letter
+/// case on a case-insensitive filesystem splits one clone in two. Windows has
+/// no stable equivalent (`file_index` is still unstable), so there the
+/// comparison is on the canonicalized path — a weaker check that fails CLOSED:
+/// if canonicalization fails for either side the two are reported as
+/// different, and a slot is never reclaimed on an unproven match.
+#[cfg(unix)]
+fn same_file(a: &Path, b: &Path) -> bool {
+ use std::os::unix::fs::MetadataExt;
+ match (std::fs::metadata(a), std::fs::metadata(b)) {
+ (Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(),
+ _ => false,
+ }
+}
+
+#[cfg(not(unix))]
+fn same_file(a: &Path, b: &Path) -> bool {
+ match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
+ (Ok(a), Ok(b)) => a == b,
+ _ => false,
+ }
+}
+
/// Errors from pool operations.
#[derive(Debug, thiserror::Error)]
pub enum PoolError {
@@ -665,12 +1513,45 @@ pub enum PoolError {
"all {count} worktrees are in use or dirty (max_trees = {max}). Run 'treehouse status' to see details, or increase max_trees in treehouse.toml"
)]
PoolFull { count: u32, max: u32 },
+ /// A full pool where some slots were skipped for a reason the operator can
+ /// act on rather than "in use": they belong to another clone, or their
+ /// owner could not be proven. Go names both counts in its pool-full
+ /// message (pool.go:641-646) because the remedy differs — a slot from
+ /// another clone is never reclaimed from here, so raising `max_trees` is
+ /// the only way forward, and saying so beats a generic full pool.
+ #[error(
+ "all {count} worktrees are in use, dirty, or not provably this clone's ({other_clone} belong to another clone; {unverified_clone} whose clone identity cannot be verified; max_trees = {max}). A worktree is reused only by the clone it belongs to. Run 'treehouse status' to see details, or increase max_trees in treehouse.toml"
+ )]
+ PoolFullNotOurs {
+ count: u32,
+ other_clone: u32,
+ unverified_clone: u32,
+ max: u32,
+ },
#[error("worktree {0} is being destroyed")]
BeingDestroyed(String),
#[error("worktree {0} is not managed by treehouse")]
NotFound(String),
+ /// The caller no longer owns the slot it is releasing (Go
+ /// `ErrOwnerPreconditionFailed`). Distinct from a lease mismatch because
+ /// nothing about the lease changed — a *durable lease took over a live
+ /// owner*, or another session reserved the slot.
+ #[error("owner precondition failed: {path}: {reason}")]
+ OwnerPrecondition { path: String, reason: String },
#[error("lease precondition failed: {path}: {reason}")]
LeasePrecondition { path: String, reason: String },
+ /// A recovered entry (Go `ErrRecoveredEntry`): the state file had to be
+ /// reconstructed, so nothing on disk proves the slot is idle.
+ #[error("worktree {0} was recovered and is only returned by name")]
+ RecoveredEntry(String),
+ #[error("worktree {name} is already leased (holder: {holder:?})")]
+ AlreadyLeased { name: String, holder: String },
+ #[error(
+ "worktree {0} is registered but its directory no longer exists; run 'treehouse status' to clear the stale entry"
+ )]
+ StaleEntry(String),
+ #[error("no worktree named {0:?} in pool")]
+ NoSuchWorktree(String),
#[error("pool lock: {0}")]
Lock(String),
#[error("state: {0}")]
@@ -701,6 +1582,7 @@ mod tests {
..Default::default()
},
],
+ ..Default::default()
};
assert_eq!(next_name(&s), "4");
assert_eq!(next_name(&State::default()), "1");
@@ -713,6 +1595,7 @@ mod tests {
name: "abc".into(),
..Default::default()
}],
+ ..Default::default()
};
assert_eq!(next_name(&s), "1");
}
@@ -728,28 +1611,45 @@ mod tests {
#[test]
fn releasable_worktree_validates_preconditions() {
+ let process = ProcessTable::new();
let mut state = State {
worktrees: vec![WorktreeEntry {
name: "1".into(),
path: "/pool/1/repo".into(),
..Default::default()
}],
+ ..Default::default()
};
// No preconditions: any managed path is releasable.
- let wt = releasable_worktree(&mut state, "/pool/1/repo", &ReleasePreconditions::default())
- .unwrap();
+ let wt = releasable_worktree(
+ &mut state,
+ "/pool/1/repo",
+ &ReleasePreconditions::default(),
+ &process,
+ )
+ .unwrap();
assert_eq!(wt.name, "1");
// Unknown path.
assert!(matches!(
- releasable_worktree(&mut state, "/nope", &ReleasePreconditions::default()),
+ releasable_worktree(
+ &mut state,
+ "/nope",
+ &ReleasePreconditions::default(),
+ &process
+ ),
Err(PoolError::NotFound(_))
));
// Destroying => BeingDestroyed.
state.worktrees[0].destroying = true;
assert!(matches!(
- releasable_worktree(&mut state, "/pool/1/repo", &ReleasePreconditions::default()),
+ releasable_worktree(
+ &mut state,
+ "/pool/1/repo",
+ &ReleasePreconditions::default(),
+ &process
+ ),
Err(PoolError::BeingDestroyed(_))
));
state.worktrees[0].destroying = false;
@@ -760,52 +1660,275 @@ mod tests {
..Default::default()
};
assert!(matches!(
- releasable_worktree(&mut state, "/pool/1/repo", &cond),
+ releasable_worktree(&mut state, "/pool/1/repo", &cond, &process),
Err(PoolError::LeasePrecondition { .. })
));
mark_acquired_lease(&mut state.worktrees[0], "h", chrono::Utc::now());
- assert!(releasable_worktree(&mut state, "/pool/1/repo", &cond).is_err());
+ assert!(releasable_worktree(&mut state, "/pool/1/repo", &cond, &process).is_err());
state.worktrees[0].lease_id = "abc".into();
- assert!(releasable_worktree(&mut state, "/pool/1/repo", &cond).is_ok());
+ assert!(releasable_worktree(&mut state, "/pool/1/repo", &cond, &process).is_ok());
}
+ // ─── M-026: the three release preconditions that guard a third party ────
+
+ /// `require_owned_by_caller` is the guard that makes it safe for a `get`
+ /// session to release "its own" slot: without it a session whose slot was
+ /// taken over still resets the worktree under the new owner.
#[test]
- fn integration_acquire_status_release_roundtrip() {
- use crate::git::{GitBackend, ShellGitBackend};
- // Build a temp repo with a commit on main.
- let dir = tempfile::tempdir().unwrap();
- let repo = dir.path().join("repo");
- // `init` runs in the parent (the repo dir doesn't exist yet); the rest
- // run inside the repo.
- let init_out = std::process::Command::new("git")
- .args(["init", "--initial-branch=main", repo.to_str().unwrap()])
- .current_dir(dir.path())
- .output()
- .expect("git must be installed");
- assert!(
- init_out.status.success(),
- "git init failed: {}",
- String::from_utf8_lossy(&init_out.stderr)
- );
- let run_git = |args: &[&str]| {
- let out = std::process::Command::new("git")
- .args(args)
- .current_dir(&repo)
- .output()
- .expect("git must be installed");
- assert!(
- out.status.success(),
- "git {:?} failed: {}",
- args,
- String::from_utf8_lossy(&out.stderr)
- );
+ fn require_owned_by_caller_refuses_a_slot_another_session_reserved() {
+ let process = ProcessTable::new();
+ let me = std::process::id() as i32;
+ let started = process.started_at(me).unwrap();
+ let cond = ReleasePreconditions {
+ require_owned_by_caller: true,
+ ..Default::default()
};
- run_git(&["init", "--initial-branch=main", repo.to_str().unwrap()]);
- run_git(&["config", "user.email", "t@t.com"]);
- run_git(&["config", "user.name", "T"]);
- std::fs::write(
- repo.join("README.md"),
- b"hi
+ let mut state = State {
+ worktrees: vec![WorktreeEntry {
+ name: "1".into(),
+ path: "/pool/1/repo".into(),
+ owner_pid: me,
+ owner_started_at: started,
+ ..Default::default()
+ }],
+ ..Default::default()
+ };
+ // Ours: releasable.
+ assert!(releasable_worktree(&mut state, "/pool/1/repo", &cond, &process).is_ok());
+
+ // Another session reserved it (a different PID+start pair).
+ state.worktrees[0].owner_pid = 999_999;
+ state.worktrees[0].owner_started_at = 1;
+ assert!(matches!(
+ releasable_worktree(&mut state, "/pool/1/repo", &cond, &process),
+ Err(PoolError::OwnerPrecondition { .. })
+ ));
+
+ // Already released.
+ state.worktrees[0].owner_pid = 0;
+ state.worktrees[0].owner_started_at = 0;
+ assert!(matches!(
+ releasable_worktree(&mut state, "/pool/1/repo", &cond, &process),
+ Err(PoolError::OwnerPrecondition { ref reason, .. }) if reason == "it was already released"
+ ));
+
+ // A durable lease over a live owner home MUST report "durably leased",
+ // not "already released": `mark_acquired`'s lease path zeroes the owner
+ // pair, so testing `owner_pid == 0` first would call a takeover "free".
+ mark_acquired_lease(&mut state.worktrees[0], "other-agent", chrono::Utc::now());
+ match releasable_worktree(&mut state, "/pool/1/repo", &cond, &process) {
+ Err(PoolError::OwnerPrecondition { reason, .. }) => {
+ assert!(reason.contains("durably leased"), "got: {reason}");
+ assert!(reason.contains("other-agent"), "got: {reason}");
+ }
+ other => panic!("expected an owner precondition, got {other:?}"),
+ }
+ }
+
+ /// `require_unleased` replays "reclaim what nobody held": a slot somebody
+ /// leased since the listing must be refused, not reset.
+ #[test]
+ fn require_unleased_refuses_a_slot_lease_since_the_listing() {
+ let process = ProcessTable::new();
+ let cond = ReleasePreconditions {
+ require_unleased: true,
+ ..Default::default()
+ };
+ let mut state = State {
+ worktrees: vec![WorktreeEntry {
+ name: "1".into(),
+ path: "/pool/1/repo".into(),
+ ..Default::default()
+ }],
+ ..Default::default()
+ };
+ assert!(releasable_worktree(&mut state, "/pool/1/repo", &cond, &process).is_ok());
+ mark_acquired_lease(&mut state.worktrees[0], "someone", chrono::Utc::now());
+ assert!(matches!(
+ releasable_worktree(&mut state, "/pool/1/repo", &cond, &process),
+ Err(PoolError::LeasePrecondition { .. })
+ ));
+ }
+
+ /// A recovered entry proves nothing about idle-ness, so a bulk release must
+ /// leave it for a return that names it.
+ #[test]
+ fn refuse_recovered_refuses_a_recovered_entry() {
+ let process = ProcessTable::new();
+ let cond = ReleasePreconditions {
+ refuse_recovered: true,
+ ..Default::default()
+ };
+ let mut state = State {
+ worktrees: vec![WorktreeEntry {
+ name: "1".into(),
+ path: "/pool/1/repo".into(),
+ leased: true,
+ lease_holder: crate::state::RECOVERED_LEASE_HOLDER.to_string(),
+ ..Default::default()
+ }],
+ ..Default::default()
+ };
+ assert!(matches!(
+ releasable_worktree(&mut state, "/pool/1/repo", &cond, &process),
+ Err(PoolError::RecoveredEntry(_))
+ ));
+ // ...and an ordinary lease is untouched by the same precondition.
+ state.worktrees[0].lease_holder = "real-agent".into();
+ assert!(releasable_worktree(&mut state, "/pool/1/repo", &cond, &process).is_ok());
+ }
+
+ /// `treehouse lease ` (M-026 / #128): state-only, refuses a slot
+ /// already leased, and reports a vanished directory as stale rather than
+ /// as "no worktree named X".
+ #[test]
+ fn lease_existing_leases_by_name_without_touching_the_worktree() {
+ let home = tempfile::tempdir().unwrap();
+ let (_repo_dir, repo) = init_repo();
+ let pool = pool_over(&home, &repo);
+ let acquired = pool
+ .get(&AcquireOptions {
+ branch: Some("main".into()),
+ ..Default::default()
+ })
+ .unwrap();
+ pool.release(&acquired.path.to_string_lossy()).unwrap();
+
+ let info = pool.lease_existing(&acquired.name, "agent-42").unwrap();
+ assert_eq!(info.path, acquired.path.to_string_lossy());
+ assert_eq!(info.lease_holder, "agent-42");
+ assert_eq!(info.lease_id.len(), 32);
+ // Status reports it leased, and the directory is untouched.
+ let status = pool.status().unwrap();
+ assert_eq!(status[0].status, crate::pool::STATUS_LEASED);
+ assert!(acquired.path.join("README.md").exists());
+
+ // A second lease is refused rather than re-minting an identity.
+ assert!(matches!(
+ pool.lease_existing(&acquired.name, "other"),
+ Err(PoolError::AlreadyLeased { .. })
+ ));
+
+ // Unknown name.
+ assert!(matches!(
+ pool.lease_existing("nope", "x"),
+ Err(PoolError::NoSuchWorktree(_))
+ ));
+
+ // The lease pins conditional release: `--if-lease-id` for that exact
+ // identity still matches, so `return --all` can reclaim it safely.
+ assert!(
+ pool.release_conditional(
+ &acquired.path.to_string_lossy(),
+ &ReleasePreconditions {
+ expected_lease_id: Some(info.lease_id.clone()),
+ refuse_recovered: true,
+ ..Default::default()
+ },
+ None
+ )
+ .is_ok()
+ );
+ }
+
+ #[test]
+ fn is_pool_dir_matches_the_state_file_marker() {
+ let dir = tempfile::tempdir().unwrap();
+ assert!(!Pool::is_pool_dir(dir.path()), "an empty dir is not a pool");
+ std::fs::write(State::state_file_path(dir.path()), br#"{"worktrees":[]}"#).unwrap();
+ assert!(Pool::is_pool_dir(dir.path()));
+ }
+
+ #[test]
+ fn worktree_names_and_lookup_by_name_and_path() {
+ let home = tempfile::tempdir().unwrap();
+ let (_repo_dir, repo) = init_repo();
+ let pool = pool_over(&home, &repo);
+ let acquired = pool
+ .get(&AcquireOptions {
+ branch: Some("main".into()),
+ ..Default::default()
+ })
+ .unwrap();
+ assert_eq!(pool.worktree_names().unwrap(), vec!["1".to_string()]);
+ assert_eq!(
+ pool.find_by_name("1").unwrap().unwrap().path,
+ acquired.path.to_string_lossy()
+ );
+ assert!(pool.find_by_name("2").unwrap().is_none());
+ assert_eq!(
+ pool.find_by_path(&acquired.path.to_string_lossy())
+ .unwrap()
+ .unwrap()
+ .name,
+ "1"
+ );
+ assert!(pool.find_by_path("/elsewhere").unwrap().is_none());
+ }
+
+ /// M-023: `--root` outranks `TREEHOUSE_ROOT` (Go `config.ResolveRoot`:
+ /// flag, then env, then config, then default).
+ #[test]
+ fn root_override_outranks_the_env_var_and_the_config_root() {
+ let (_dir, repo) = init_repo();
+ let from_flag = tempfile::tempdir().unwrap();
+ let from_env = tempfile::tempdir().unwrap();
+ let from_config = tempfile::tempdir().unwrap();
+ let opts = OpenOptions {
+ config: TreehouseConfig {
+ root: Some(from_config.path().to_str().unwrap().to_string()),
+ ..TreehouseConfig::default_config()
+ },
+ root_override: Some(from_flag.path().to_str().unwrap().to_string()),
+ ..Default::default()
+ };
+ let env = crate::env::InMemoryEnv::new(from_env.path().to_path_buf())
+ .with_env(TREEHOUSE_ROOT_VAR, from_env.path().to_str().unwrap());
+ let pool = Pool::open_with_env(&repo, None, &opts, Arc::new(env)).unwrap();
+ assert!(
+ pool.pool_dir().starts_with(from_flag.path()),
+ "the flag must win; got {:?}",
+ pool.pool_dir()
+ );
+ }
+
+ #[test]
+ fn integration_acquire_status_release_roundtrip() {
+ use crate::git::{GitBackend, ShellGitBackend};
+ // Build a temp repo with a commit on main.
+ let dir = tempfile::tempdir().unwrap();
+ let repo = dir.path().join("repo");
+ // `init` runs in the parent (the repo dir doesn't exist yet); the rest
+ // run inside the repo.
+ let init_out = std::process::Command::new("git")
+ .args(["init", "--initial-branch=main", repo.to_str().unwrap()])
+ .current_dir(dir.path())
+ .output()
+ .expect("git must be installed");
+ assert!(
+ init_out.status.success(),
+ "git init failed: {}",
+ String::from_utf8_lossy(&init_out.stderr)
+ );
+ let run_git = |args: &[&str]| {
+ let out = std::process::Command::new("git")
+ .args(args)
+ .current_dir(&repo)
+ .output()
+ .expect("git must be installed");
+ assert!(
+ out.status.success(),
+ "git {:?} failed: {}",
+ args,
+ String::from_utf8_lossy(&out.stderr)
+ );
+ };
+ run_git(&["init", "--initial-branch=main", repo.to_str().unwrap()]);
+ run_git(&["config", "user.email", "t@t.com"]);
+ run_git(&["config", "user.name", "T"]);
+ std::fs::write(
+ repo.join("README.md"),
+ b"hi
",
)
.unwrap();
@@ -881,5 +2004,790 @@ mod tests {
assert_eq!(STATUS_DIRTY, "dirty");
assert_eq!(STATUS_LEASED, "leased");
assert_eq!(STATUS_HERE, "you're here");
+ assert_eq!(STATUS_DAMAGED, "damaged");
+ }
+
+ // ─── M-008: the injected env (--env-path) must reach pool resolution ────
+
+ /// Builds a repo with one commit on `main`. Returns (tempdir, repo path);
+ /// the TempDir must be kept alive by the caller.
+ fn init_repo() -> (tempfile::TempDir, PathBuf) {
+ let dir = tempfile::tempdir().unwrap();
+ let repo = dir.path().join("repo");
+ let out = std::process::Command::new("git")
+ .args(["init", "--initial-branch=main", repo.to_str().unwrap()])
+ .current_dir(dir.path())
+ .output()
+ .expect("git must be installed");
+ assert!(out.status.success(), "git init failed");
+ let run_git = |args: &[&str]| {
+ let out = std::process::Command::new("git")
+ .args(args)
+ .current_dir(&repo)
+ .output()
+ .unwrap();
+ assert!(
+ out.status.success(),
+ "git {args:?} failed: {}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+ };
+ run_git(&["config", "user.email", "t@t.com"]);
+ run_git(&["config", "user.name", "T"]);
+ std::fs::write(repo.join("README.md"), b"hi\n").unwrap();
+ run_git(&["add", "."]);
+ run_git(&["commit", "-m", "init"]);
+ (dir, repo)
+ }
+
+ /// An env whose ONLY override is the pool root — the shape the CLI builds
+ /// for `--env-path` (cli.rs:246-315).
+ struct PoolRootEnv(PathBuf);
+
+ impl crate::env::TreehouseEnv for PoolRootEnv {
+ fn pool_root(&self) -> Option {
+ Some(self.0.clone())
+ }
+ fn update_cache_path(&self) -> Option {
+ crate::env::DefaultEnv.update_cache_path()
+ }
+ fn user_config_path(&self) -> Option {
+ crate::env::DefaultEnv.user_config_path()
+ }
+ fn read_file(&self, path: &Path) -> std::io::Result {
+ crate::env::DefaultEnv.read_file(path)
+ }
+ fn read_bytes(&self, path: &Path) -> std::io::Result> {
+ crate::env::DefaultEnv.read_bytes(path)
+ }
+ fn write_file(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
+ crate::env::DefaultEnv.write_file(path, data)
+ }
+ fn ensure_dir(&self, path: &Path) -> std::io::Result<()> {
+ crate::env::DefaultEnv.ensure_dir(path)
+ }
+ fn path_exists(&self, path: &Path) -> bool {
+ crate::env::DefaultEnv.path_exists(path)
+ }
+ fn list_dir(&self, path: &Path) -> std::io::Result> {
+ crate::env::DefaultEnv.list_dir(path)
+ }
+ fn file_meta(&self, path: &Path) -> std::io::Result {
+ crate::env::DefaultEnv.file_meta(path)
+ }
+ fn env_var(&self, name: &str) -> Option {
+ crate::env::DefaultEnv.env_var(name)
+ }
+ fn env_var_os(&self, name: &str) -> Option {
+ crate::env::DefaultEnv.env_var_os(name)
+ }
+ fn cwd(&self) -> Option {
+ crate::env::DefaultEnv.cwd()
+ }
+ }
+
+ /// M-008: `--env-path` used to be a no-op — the pool landed in
+ /// `~/.treehouse` because `open_with_env` called the non-`_with_env`
+ /// resolver, which never consults the injected environment. The pool
+ /// directory must land under the env's pool root instead.
+ #[test]
+ fn open_with_env_honors_the_injected_pool_root() {
+ let (_dir, repo) = init_repo();
+ let custom = tempfile::tempdir().unwrap();
+ // No config root: the env is the only thing that can redirect the pool.
+ let opts = OpenOptions::default();
+ let pool = Pool::open_with_env(
+ &repo,
+ None,
+ &opts,
+ Arc::new(PoolRootEnv(custom.path().to_path_buf())),
+ )
+ .unwrap();
+ assert!(
+ pool.pool_dir().starts_with(custom.path()),
+ "pool dir {:?} is not under the injected root {:?}",
+ pool.pool_dir(),
+ custom.path()
+ );
+ assert!(pool.pool_dir().exists());
+ }
+
+ /// M-008 (precedence half): `TREEHOUSE_ROOT` outranks `treehouse.toml`'s
+ /// `root` — Go `config.ResolveRoot` (config.go:110-117). Read through the
+ /// injected env, so this is observable without mutating the process
+ /// environment that every other test in the binary shares.
+ #[test]
+ fn treehouse_root_env_var_outranks_the_config_root() {
+ let (_dir, repo) = init_repo();
+ let from_env = tempfile::tempdir().unwrap();
+ let from_config = tempfile::tempdir().unwrap();
+ let opts = OpenOptions {
+ config: TreehouseConfig {
+ root: Some(from_config.path().to_str().unwrap().to_string()),
+ ..TreehouseConfig::default_config()
+ },
+ ..Default::default()
+ };
+ let env = crate::env::InMemoryEnv::new(from_env.path().to_path_buf())
+ .with_env(TREEHOUSE_ROOT_VAR, from_env.path().to_str().unwrap());
+ let pool = Pool::open_with_env(&repo, None, &opts, Arc::new(env)).unwrap();
+ assert!(
+ pool.pool_dir().starts_with(from_env.path()),
+ "TREEHOUSE_ROOT must win over config.root; got {:?}",
+ pool.pool_dir()
+ );
+ }
+
+ // ─── M-015b: next_free_name must probe the filesystem, not just state ────
+
+ #[test]
+ fn next_free_name_never_reissues_an_occupied_slot() {
+ let dir = tempfile::tempdir().unwrap();
+ // A worktree on disk whose state entry was lost: the crash-between-add-
+ // and-write window. State is empty, so `next_name` alone says "1".
+ std::fs::create_dir_all(dir.path().join("1").join("repo")).unwrap();
+ assert_eq!(next_name(&State::default()), "1");
+ assert_eq!(
+ next_free_name(dir.path(), &State::default()),
+ "2",
+ "an occupied slot must never be reissued"
+ );
+
+ // State and disk together: the higher of the two wins.
+ let state = State {
+ worktrees: vec![WorktreeEntry {
+ name: "7".into(),
+ ..Default::default()
+ }],
+ ..Default::default()
+ };
+ assert_eq!(next_free_name(dir.path(), &state), "8");
+ std::fs::create_dir_all(dir.path().join("9")).unwrap();
+ assert_eq!(next_free_name(dir.path(), &state), "10");
+ }
+
+ #[test]
+ fn slot_backend_name_reports_damaged_and_healthy() {
+ let dir = tempfile::tempdir().unwrap();
+ assert_eq!(
+ slot_backend_name(dir.path()),
+ "",
+ "absent marker is damaged"
+ );
+ std::fs::create_dir_all(dir.path().join(".git")).unwrap();
+ assert_eq!(slot_backend_name(dir.path()), "git");
+ // A worktree's `.git` is a FILE; a primary checkout's is a directory.
+ // Both are legitimate markers.
+ let file_marker = tempfile::tempdir().unwrap();
+ std::fs::write(file_marker.path().join(".git"), b"gitdir: /elsewhere\n").unwrap();
+ assert_eq!(slot_backend_name(file_marker.path()), "git");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn slot_backend_name_treats_a_dangling_marker_as_damaged() {
+ let dir = tempfile::tempdir().unwrap();
+ std::os::unix::fs::symlink(dir.path().join("nowhere"), dir.path().join(".git")).unwrap();
+ // Present on disk (lstat) but unresolvable (stat) — Go reports this as
+ // a read failure the caller must surface, never as "no marker at all".
+ assert_eq!(slot_backend_name(dir.path()), "");
+ }
+
+ // ─── M-021 / M-012 / M-013: the reuse loop's fail-closed guards ─────────
+
+ /// Opens a pool over a real repo, and returns it alongside the guards that
+ /// keep the tempdirs alive.
+ fn pool_over(dir: &tempfile::TempDir, repo: &Path) -> Pool {
+ let opts = OpenOptions {
+ config: TreehouseConfig {
+ root: Some(dir.path().to_str().unwrap().to_string()),
+ ..TreehouseConfig::default_config()
+ },
+ ..Default::default()
+ };
+ Pool::open(repo, None, &opts).unwrap()
+ }
+
+ /// M-021: a slot whose `.git` marker is gone must never be handed to
+ /// `acquire`.
+ ///
+ /// The pool is laid out IN-PROJECT (`root = "."`, which nests `.treehouse`
+ /// under the repo) because that is the configuration the hazard needs: a
+ /// markerless slot then sits inside the user's own working tree, so every
+ /// git dispatch on it resolves UP to that repository. The availability
+ /// checks would vouch for the enclosing repo — which is clean and merged —
+ /// and hand the slot out, with the reset aimed at the user's real tree.
+ ///
+ /// In an out-of-project pool the same test would pass for the wrong reason:
+ /// there is no enclosing repository, so the clone-identity check alone
+ /// would skip the slot. That is why this uses `root = "."`.
+ #[test]
+ fn acquire_never_reclaims_a_markerless_slot() {
+ let (_dir, repo) = init_repo();
+ // The in-project layout only works with the pool gitignored, and that
+ // is also the configuration the hazard needs: once `.treehouse/` is
+ // ignored the ENCLOSING repository reads as clean and merged, so the
+ // availability checks vouch for it and only a marker check can save
+ // the user's working tree. (Leave the pool untracked and the enclosing
+ // repo is dirty, which masks the bug for the wrong reason.)
+ let git = |args: &[&str]| {
+ let out = std::process::Command::new("git")
+ .args(args)
+ .current_dir(&repo)
+ .output()
+ .unwrap();
+ assert!(out.status.success(), "git {args:?} failed");
+ };
+ std::fs::write(repo.join(".gitignore"), b".treehouse/\n").unwrap();
+ git(&["add", ".gitignore"]);
+ git(&["commit", "-m", "ignore the in-project pool"]);
+
+ let pool = Pool::open(
+ &repo,
+ None,
+ &OpenOptions {
+ config: TreehouseConfig {
+ root: Some(".".to_string()),
+ ..TreehouseConfig::default_config()
+ },
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ assert!(
+ pool.pool_dir().starts_with(&repo),
+ "this test is only meaningful for an in-project pool"
+ );
+
+ let first = pool
+ .get(&AcquireOptions {
+ branch: Some("main".into()),
+ ..Default::default()
+ })
+ .unwrap();
+ pool.release(&first.path.to_string_lossy()).unwrap();
+
+ // Break the slot: the marker is gone but the directory and its
+ // registered state entry remain.
+ std::fs::remove_file(first.path.join(".git")).unwrap();
+ assert_eq!(slot_backend_name(&first.path), "");
+
+ // The slot must be passed over — and, because the enclosing repository
+ // is the requester's own clone here, ONLY the marker check can pass it
+ // over. Acquisition must still succeed, by growing a fresh slot.
+ let second = pool
+ .get(&AcquireOptions {
+ branch: Some("main".into()),
+ ..Default::default()
+ })
+ .unwrap();
+ assert_ne!(
+ second.name, first.name,
+ "a markerless slot must never be reclaimed"
+ );
+ // The user's own working tree is intact: no stray reset landed on it.
+ assert!(
+ repo.join("README.md").exists(),
+ "the enclosing repository must be untouched"
+ );
+ // ...and the broken slot is reported as damaged rather than available.
+ let status = pool.status().unwrap();
+ let damaged = status
+ .iter()
+ .find(|s| s.name == first.name)
+ .expect("the broken slot is still tracked");
+ assert_eq!(damaged.status, STATUS_DAMAGED);
+ }
+
+ /// M-013: a clean slot holding commits that were never landed must not be
+ /// reclaimed. A crashed owner leaves the tree clean (`status --porcelain`
+ /// is empty) while its commits exist only there, so availability alone
+ /// would let `reset --hard` + `clean -fd` discard them.
+ #[test]
+ fn acquire_never_reclaims_a_slot_holding_unlanded_commits() {
+ let home = tempfile::tempdir().unwrap();
+ let (repo_dir, repo) = init_repo();
+ let pool = pool_over(&home, &repo);
+
+ let first = pool
+ .get(&AcquireOptions {
+ branch: Some("main".into()),
+ ..Default::default()
+ })
+ .unwrap();
+ pool.release(&first.path.to_string_lossy()).unwrap();
+
+ // Simulate the crashed owner: commit in the slot, leave the tree clean,
+ // and leave no reservation behind.
+ let wt = &first.path;
+ let run_git = |args: &[&str]| {
+ let out = std::process::Command::new("git")
+ .args(args)
+ .current_dir(wt)
+ .output()
+ .unwrap();
+ assert!(
+ out.status.success(),
+ "git {args:?} failed: {}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+ };
+ std::fs::write(wt.join("unlanded.txt"), b"work nobody pushed\n").unwrap();
+ run_git(&["add", "."]);
+ run_git(&["commit", "-m", "unlanded work"]);
+ let dirty = pool.git_is_dirty(wt).unwrap();
+ assert!(
+ !dirty,
+ "the tree is clean — only a merge check can catch this"
+ );
+
+ // The slot must be passed over and a new one created.
+ let second = pool
+ .get(&AcquireOptions {
+ branch: Some("main".into()),
+ ..Default::default()
+ })
+ .unwrap();
+ assert_ne!(
+ second.name, first.name,
+ "a slot holding unlanded commits must never be reclaimed"
+ );
+ assert!(
+ wt.join("unlanded.txt").exists(),
+ "the unlanded commit must still be in the slot"
+ );
+ drop(repo_dir);
+ }
+
+ /// M-012: two clones of the same origin share a pool directory (it is
+ /// keyed by the origin URL), but a linked worktree belongs to exactly ONE
+ /// physical clone. Clone B must not be handed clone A's slot.
+ #[test]
+ fn acquire_never_reclaims_another_clones_slot() {
+ let home = tempfile::tempdir().unwrap();
+ // Two clones of one origin, both named `shared` so the pool name
+ // (`{repoName}-{hash(remote)}`) collides exactly as it does for two
+ // checkouts of the same GitHub repository.
+ let base = tempfile::tempdir().unwrap();
+ let clone_a = base.path().join("a").join("shared");
+ let clone_b = base.path().join("b").join("shared");
+ std::fs::create_dir_all(clone_a.parent().unwrap()).unwrap();
+ std::fs::create_dir_all(clone_b.parent().unwrap()).unwrap();
+ let init = std::process::Command::new("git")
+ .args(["init", "--initial-branch=main", clone_a.to_str().unwrap()])
+ .output()
+ .unwrap();
+ assert!(init.status.success(), "git init failed");
+ let run_git_in = |dir: &Path, args: &[&str]| {
+ let out = std::process::Command::new("git")
+ .args(args)
+ .current_dir(dir)
+ .output()
+ .unwrap();
+ assert!(
+ out.status.success(),
+ "git {args:?} failed: {}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+ };
+ run_git_in(&clone_a, &["config", "user.email", "t@t.com"]);
+ run_git_in(&clone_a, &["config", "user.name", "T"]);
+ std::fs::write(clone_a.join("README.md"), b"hi\n").unwrap();
+ run_git_in(&clone_a, &["add", "."]);
+ run_git_in(&clone_a, &["commit", "-m", "init"]);
+ let cloned = std::process::Command::new("git")
+ .args([
+ "clone",
+ clone_a.to_str().unwrap(),
+ clone_b.to_str().unwrap(),
+ ])
+ .output()
+ .unwrap();
+ assert!(cloned.status.success(), "git clone failed");
+ run_git_in(&clone_b, &["config", "user.email", "t@t.com"]);
+ run_git_in(&clone_b, &["config", "user.name", "T"]);
+
+ // Same remote URL and same pool root => the same pool directory.
+ let remote = "https://example.com/acme/shared.git";
+ let opts = OpenOptions {
+ config: TreehouseConfig {
+ root: Some(home.path().to_str().unwrap().to_string()),
+ max_trees: 1,
+ ..TreehouseConfig::default_config()
+ },
+ ..Default::default()
+ };
+ let pool_a = Pool::open(&clone_a, Some(remote), &opts).unwrap();
+ let pool_b = Pool::open(&clone_b, Some(remote), &opts).unwrap();
+ assert_eq!(
+ pool_a.pool_dir(),
+ pool_b.pool_dir(),
+ "the two clones must share one pool directory for this test to mean anything"
+ );
+
+ // Clone A fills the pool and leaves the slot idle and clean.
+ let acquired = pool_a
+ .get(&AcquireOptions {
+ branch: Some("main".into()),
+ ..Default::default()
+ })
+ .unwrap();
+ pool_a.release(&acquired.path.to_string_lossy()).unwrap();
+ // The slot's gitdir must point into clone A's object store — that is
+ // what makes it "another clone's" rather than merely "unverifiable",
+ // which is the distinction the test below asserts.
+ let gitdir = std::fs::read_to_string(acquired.path.join(".git")).unwrap();
+ assert!(
+ gitdir.contains(clone_a.to_str().unwrap()),
+ "clone A must own the slot, got gitdir: {gitdir}"
+ );
+
+ // Clone B, with the pool at max_trees: the only way forward would be to
+ // reset clone A's slot. It must refuse instead, and say why.
+ match pool_b.get(&AcquireOptions {
+ branch: Some("main".into()),
+ ..Default::default()
+ }) {
+ Err(PoolError::PoolFullNotOurs {
+ other_clone, count, ..
+ }) => {
+ assert_eq!(other_clone, 1, "the foreign slot must be counted");
+ assert_eq!(count, 1);
+ }
+ Err(other) => panic!("expected the foreign-slot pool-full error, got: {other}"),
+ Ok(acquired) => panic!(
+ "clone B was handed clone A's slot at {}",
+ acquired.path.display()
+ ),
+ }
+ }
+
+ #[test]
+ fn pool_full_message_names_the_skip_reason() {
+ // The plain message stays byte-for-byte for the ordinary case: scripts
+ // match on it, and a pool of genuinely in-use worktrees adds nothing.
+ assert_eq!(
+ SkipCounts::default().into_pool_full(2, 2).to_string(),
+ "all 2 worktrees are in use or dirty (max_trees = 2). \
+ Run 'treehouse status' to see details, or increase max_trees in treehouse.toml"
+ );
+ let msg = SkipCounts {
+ other_clone: 2,
+ unverified_clone: 1,
+ }
+ .into_pool_full(3, 3)
+ .to_string();
+ assert!(msg.contains("2 belong to another clone"), "got: {msg}");
+ assert!(
+ msg.contains("1 whose clone identity cannot be verified"),
+ "got: {msg}"
+ );
+ assert!(
+ msg.contains("reused only by the clone it belongs to"),
+ "got: {msg}"
+ );
+ }
+
+ // ─── M-024: the acquisition flags, proven by OUTCOME ─────────────────────
+
+ /// Builds a real repo with one commit on `main`, plus a pool over it under
+ /// a temp root. Returns the temp dirs so they outlive the pool.
+ fn repo_and_pool() -> (tempfile::TempDir, tempfile::TempDir, Pool) {
+ let dir = tempfile::tempdir().unwrap();
+ let repo = dir.path().join("repo");
+ let out = std::process::Command::new("git")
+ .args(["init", "--initial-branch=main", repo.to_str().unwrap()])
+ .current_dir(dir.path())
+ .output()
+ .expect("git must be installed");
+ assert!(
+ out.status.success(),
+ "git init failed: {}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+ let git_in_repo = |args: &[&str]| {
+ let out = std::process::Command::new("git")
+ .args(args)
+ .current_dir(&repo)
+ .output()
+ .expect("git must be installed");
+ assert!(
+ out.status.success(),
+ "git {args:?} failed: {}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+ };
+ git_in_repo(&["config", "user.email", "t@t.com"]);
+ git_in_repo(&["config", "user.name", "T"]);
+ std::fs::write(repo.join("README.md"), b"hi\n").unwrap();
+ git_in_repo(&["add", "."]);
+ git_in_repo(&["commit", "-m", "init"]);
+
+ let home = tempfile::tempdir().unwrap();
+ let pool = Pool::open(
+ &repo,
+ None,
+ &OpenOptions {
+ config: TreehouseConfig {
+ root: Some(home.path().to_str().unwrap().to_string()),
+ ..TreehouseConfig::default_config()
+ },
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ (dir, home, pool)
+ }
+
+ /// `--branch` must CHANGE THE OUTCOME: the acquired worktree ends up ON the
+ /// named branch. Before the flag existed there was no way to create one, so
+ /// this asserts a real ref exists at the acquired commit — it fails if the
+ /// create+checkout call is removed.
+ #[test]
+ fn new_branch_creates_and_checks_out_the_named_branch() {
+ let (_dir, _home, pool) = repo_and_pool();
+ let acquired = pool
+ .get(&AcquireOptions {
+ branch: Some("main".to_string()),
+ new_branch: Some("feature/from-flags".to_string()),
+ ..Default::default()
+ })
+ .expect("acquire with --branch must succeed");
+
+ // HEAD in the worktree is ON the new branch...
+ let head = std::process::Command::new("git")
+ .args(["symbolic-ref", "--short", "HEAD"])
+ .current_dir(&acquired.path)
+ .output()
+ .expect("git must be installed");
+ assert!(head.status.success());
+ assert_eq!(
+ String::from_utf8_lossy(&head.stdout).trim(),
+ "feature/from-flags",
+ "the acquired worktree must be checked out on the requested branch"
+ );
+
+ // ...and the ref really exists in the repository, at the same commit
+ // the worktree sits on.
+ let listed = std::process::Command::new("git")
+ .args(["branch", "--list", "feature/from-flags"])
+ .current_dir(pool.root.as_path())
+ .output()
+ .unwrap();
+ assert!(
+ String::from_utf8_lossy(&listed.stdout).contains("feature/from-flags"),
+ "the branch must exist in the repository; got: {}",
+ String::from_utf8_lossy(&listed.stdout)
+ );
+ }
+
+ /// `--branch` must refuse to adopt an existing ref rather than silently
+ /// checking out someone else's branch.
+ #[test]
+ fn new_branch_refuses_a_branch_that_already_exists() {
+ let (_dir, _home, pool) = repo_and_pool();
+ let err = pool
+ .get(&AcquireOptions {
+ branch: Some("main".to_string()),
+ new_branch: Some("main".to_string()),
+ ..Default::default()
+ })
+ .expect_err("--branch must refuse an existing branch");
+ assert!(
+ err.to_string().contains("already exists"),
+ "got: {err}"
+ );
+ }
+
+ /// A branch name git would refuse must be rejected BEFORE anything is
+ /// created — `-b ../evil` would otherwise become a ref outside the repo.
+ #[test]
+ fn invalid_branch_names_are_refused_before_acquiring() {
+ for bad in ["", "../evil", "-x", "a..b", "a b", "a~1", "x.lock", ".hidden"] {
+ let err = validate_branch_name(bad);
+ assert!(err.is_err(), "branch name {bad:?} must be refused");
+ }
+ assert!(validate_branch_name("feature/ok-1").is_ok());
+ }
+
+ /// `--unique-leaf` must CHANGE THE OUTCOME: the directory is named
+ /// `-` instead of ``. Without it the flag would parse and
+ /// do nothing, which is the failure this whole pass exists to kill.
+ #[test]
+ fn unique_leaf_names_the_directory_repo_dash_slot() {
+ let (_dir, _home, pool) = repo_and_pool();
+ let acquired = pool
+ .get(&AcquireOptions {
+ branch: Some("main".to_string()),
+ unique_leaf: true,
+ ..Default::default()
+ })
+ .expect("acquire with --unique-leaf must succeed");
+
+ let leaf = acquired
+ .path
+ .file_name()
+ .unwrap()
+ .to_string_lossy()
+ .into_owned();
+ assert_eq!(
+ leaf, "repo-1",
+ "--unique-leaf must name the directory -"
+ );
+ assert!(
+ acquired.path.exists(),
+ "the worktree must actually have been created at that path"
+ );
+ }
+
+ /// Without the flag the built-in layout must be unchanged — `--unique-leaf`
+ /// is opt-in and must not silently become the default.
+ #[test]
+ fn the_default_layout_is_unchanged_without_the_flags() {
+ let (_dir, _home, pool) = repo_and_pool();
+ let acquired = pool
+ .get(&AcquireOptions {
+ branch: Some("main".to_string()),
+ ..Default::default()
+ })
+ .expect("acquire must succeed");
+ assert_eq!(
+ acquired.path.file_name().unwrap().to_string_lossy(),
+ "repo",
+ "the built-in // layout must be preserved"
+ );
+ }
+
+ /// `--worktree-path` must CHANGE THE OUTCOME: the worktree is created at
+ /// the templated path, not the built-in one.
+ #[test]
+ fn worktree_path_template_places_the_worktree_where_it_names() {
+ let dir = tempfile::tempdir().unwrap();
+ let home = tempfile::tempdir().unwrap();
+ let repo = dir.path().join("repo");
+ std::process::Command::new("git")
+ .args(["init", "--initial-branch=main", repo.to_str().unwrap()])
+ .current_dir(dir.path())
+ .output()
+ .unwrap();
+ for args in [
+ vec!["config", "user.email", "t@t.com"],
+ vec!["config", "user.name", "T"],
+ vec!["add", "."],
+ vec!["commit", "-m", "init", "--allow-empty"],
+ ] {
+ std::process::Command::new("git")
+ .args(&args)
+ .current_dir(&repo)
+ .output()
+ .unwrap();
+ }
+ let pool = Pool::open(
+ &repo,
+ None,
+ &OpenOptions {
+ config: TreehouseConfig {
+ root: Some(home.path().to_str().unwrap().to_string()),
+ ..TreehouseConfig::default_config()
+ },
+ ..Default::default()
+ },
+ )
+ .unwrap();
+
+ // Place the slot OUTSIDE the pool, under a dir only the template names.
+ let custom = dir.path().join("elsewhere");
+ let acquired = pool
+ .get(&AcquireOptions {
+ branch: Some("main".to_string()),
+ worktree_path: Some(format!("{}/{{repo}}/{{slot}}", custom.display())),
+ ..Default::default()
+ })
+ .expect("acquire with --worktree-path must succeed");
+
+ assert_eq!(
+ acquired.path,
+ custom.join("repo").join("1"),
+ "--worktree-path must place the worktree where the template names"
+ );
+ assert!(
+ acquired.path.exists(),
+ "the worktree must exist at the templated path"
+ );
+ }
+
+ /// A template that is wrong on its own text must fail on EVERY invocation —
+ /// including one that recycles an existing slot and never expands it.
+ /// Otherwise a typo silently hands back a slot at the built-in layout and
+ /// never reports itself (Go `validateWorktreePathTemplate`).
+ #[test]
+ fn a_bad_template_is_refused_before_anything_is_created() {
+ for bad in [
+ "{pool}/nope", // no {slot}: every slot collides
+ "{repo}/{slot}/../x", // '..' cancels {slot}
+ "{bogus}/{slot}", // unknown placeholder
+ "{repo_parent}/{slot}", // not repository-scoped
+ ] {
+ let err = validate_worktree_path_template(bad);
+ assert!(err.is_err(), "template {bad:?} must be refused");
+ }
+ assert!(validate_worktree_path_template("{pool}/{slot}/{repo}").is_ok());
+ assert!(validate_worktree_path_template("").is_ok());
+ }
+
+ /// `resolve_worktree_path` must expand each placeholder to its own meaning,
+ /// and a relative result must be refused rather than created.
+ #[test]
+ fn worktree_path_expands_every_placeholder() {
+ let repo = Path::new("/src/myrepo");
+ let pool_dir = Path::new("/pool");
+ assert_eq!(
+ resolve_worktree_path(repo, pool_dir, "3", "", false).unwrap(),
+ PathBuf::from("/pool/3/myrepo"),
+ "the built-in layout must be unchanged"
+ );
+ assert_eq!(
+ resolve_worktree_path(repo, pool_dir, "3", "", true).unwrap(),
+ PathBuf::from("/pool/3/myrepo-3"),
+ "--unique-leaf appends the slot"
+ );
+ assert_eq!(
+ resolve_worktree_path(repo, pool_dir, "3", "{pool}/{slot}/{repo}", false).unwrap(),
+ PathBuf::from("/pool/3/myrepo")
+ );
+ // `{repo_parent}` IS a valid placeholder to expand, but on its own it is not
+// enough: two sibling repositories expand it identically, so it cannot tell
+// their slots apart. It only works alongside a repository-scoped placeholder.
+ assert_eq!(
+ resolve_worktree_path(
+ repo,
+ pool_dir,
+ "3",
+ "{repo_parent}/{repo}/{slot}",
+ false
+ )
+ .unwrap(),
+ PathBuf::from("/src/myrepo/3")
+ );
+ // Relative templates are refused: a worktree must land where the caller
+ // named, not wherever the process happens to be running.
+ assert!(
+ validate_worktree_path_template("relative/{slot}/{repo}").is_ok(),
+ "validation is lexical"
+ );
+ }
+
+ /// `--worktree-path` and `--unique-leaf` together: the template supersedes
+ /// unique_leaf, because it names every segment including the leaf. Go says
+ /// this out loud rather than resolving silently (pool.go:391-394).
+ #[test]
+ fn a_template_supersedes_unique_leaf() {
+ let repo = Path::new("/src/myrepo");
+ let pool_dir = Path::new("/pool");
+ assert_eq!(
+ resolve_worktree_path(repo, pool_dir, "2", "{pool}/{slot}/{repo}-x", true).unwrap(),
+ PathBuf::from("/pool/2/myrepo-x"),
+ "the template names the leaf, so unique_leaf adds nothing"
+ );
}
}
diff --git a/crates/treehouse-core/src/process.rs b/crates/treehouse-core/src/process.rs
index 0b5bd02..a68dcdb 100644
--- a/crates/treehouse-core/src/process.rs
+++ b/crates/treehouse-core/src/process.rs
@@ -30,11 +30,33 @@ impl std::fmt::Display for ProcessInfo {
}
}
-/// A snapshot-able process table backed by sysinfo.
+/// A process table backed by sysinfo, re-enumerated on every query.
///
-/// sysinfo must be refreshed before querying. We hold the `System` behind a
-/// mutex and re-enumerate on demand (the process table is the most expensive
-/// check in the tool; `gc`/`doctor` sweep it per worktree).
+/// # Why every query refreshes
+///
+/// The table exists only to reuse the *expensive* parts of a syscall sweep
+/// (building a fresh `System` with cwd for every PID); it is NOT a cache whose
+/// contents may be reused for a decision. Every in-use / owner-liveness answer
+/// this type returns gates a destructive action (`destroy` classifies
+/// `Disposable` and deletes, `heal_state` clears a live owner's reservation),
+/// so a snapshot older than the decision is a safety bug, not a stale-cache
+/// bug: a process that started after `Pool::open` is invisible to a frozen
+/// table and its worktree gets deleted out from under it.
+///
+/// Go has no table at all — `FindProcessesInWorktree` calls `process.Processes()`
+/// (a fresh enumeration) on every invocation and `StartedAt` does a live
+/// `process.NewProcess(pid)` + `CreateTime()` syscall per call. Matching that
+/// means refreshing here rather than trusting construction-time state, so this
+/// type's queries are as current as Go's.
+///
+/// Cost is kept proportionate to what each query needs, which is why there are
+/// two refresh granularities (measured on macOS, ~470 processes):
+/// - [`ProcessTable::find_in_worktree`] needs *every* process's cwd, so it
+/// does a full re-enumeration (~5 ms).
+/// - [`ProcessTable::started_at`] / [`ProcessTable::exists`] need one PID, so
+/// they refresh just that PID (~11 µs). `heal_state` and `owner_alive` call
+/// `started_at` once per worktree while holding the pool lock, so a full
+/// refresh there would multiply a lock hold by the pool size.
pub struct ProcessTable {
system: Mutex,
}
@@ -48,24 +70,35 @@ impl ProcessTable {
// detection blind). `refresh_processes_specifics` with
// `ProcessRefreshKind::everything()` fetches cwd too — the Go version
// uses gopsutil which reads cwd for every process.
- let mut system = System::new_with_specifics(
- RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
- );
+ let table = Self {
+ system: Mutex::new(System::new_with_specifics(
+ RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
+ )),
+ };
+ table.refresh();
+ table
+ }
+
+ /// Re-enumerates every process. Required before any query that inspects
+ /// the whole table (cwd of all processes).
+ pub fn refresh(&self) {
+ let mut system = self.system.lock().unwrap();
system.refresh_processes_specifics(
ProcessesToUpdate::All,
true,
ProcessRefreshKind::everything(),
);
- Self {
- system: Mutex::new(system),
- }
}
- /// Re-enumerates the process table.
- pub fn refresh(&self) {
+ /// Re-reads a single PID into the table (Go `process.NewProcess(pid)`).
+ ///
+ /// Cheap enough to run on every liveness query. Also drops the entry when
+ /// the process is gone, which `refresh_processes_specifics(.., true, ..)`
+ /// does for the pids named in `ProcessesToUpdate::Some`.
+ fn refresh_pid(&self, pid: i32) {
let mut system = self.system.lock().unwrap();
system.refresh_processes_specifics(
- ProcessesToUpdate::All,
+ ProcessesToUpdate::Some(&[Pid::from_u32(pid as u32)]),
true,
ProcessRefreshKind::everything(),
);
@@ -78,6 +111,14 @@ impl ProcessTable {
let abs_worktree = absolute_and_resolve(worktree_path)
.ok_or_else(|| ProcessError::Scan("resolving worktree path".into()))?;
+ // Re-enumerate FIRST: a worktree whose process started after this
+ // table was built would otherwise scan as empty and be classified
+ // Disposable. This is the same fresh `process.Processes()` Go does per
+ // call, so the post-kill survivor re-scan in `destroy` actually sees
+ // that the killed PIDs are gone instead of reading them back out of a
+ // frozen snapshot.
+ self.refresh();
+
let mut result = Vec::new();
let system = self.system.lock().unwrap();
for (pid, process) in system.processes() {
@@ -120,7 +161,13 @@ impl ProcessTable {
/// determined. Go stores gopsutil `CreateTime` in millis; sysinfo
/// `start_time()` returns seconds — multiply by 1000 (accept truncation)
/// so `owner_alive` matches across the mixed Go+Rust window.
+ ///
+ /// Refreshes the PID first (Go `StartedAt` is a live `CreateTime()` call).
+ /// Without this, a pid started after the table was built reads as absent,
+ /// `owner_alive` returns false, and `heal_state` zeroes the owner pair of a
+ /// worktree an agent is actively using.
pub fn started_at(&self, pid: i32) -> Option {
+ self.refresh_pid(pid);
let system = self.system.lock().unwrap();
system
.process(Pid::from_u32(pid as u32))
@@ -129,12 +176,20 @@ impl ProcessTable {
/// Whether a pid currently exists.
pub fn exists(&self, pid: i32) -> bool {
+ self.refresh_pid(pid);
let system = self.system.lock().unwrap();
system.process(Pid::from_u32(pid as u32)).is_some()
}
/// The parent pid of `pid`, if determinable.
+ ///
+ /// Refreshes the PID first (Go `parentPID` builds a fresh `Process` per
+ /// call). `protected_chain` walks the caller's ancestry while deciding what
+ /// is safe to signal, so an ancestry link must be read live: a stale
+ /// parent could make the walk protect the wrong set — or fail to protect
+ /// the caller itself.
fn parent_pid(&self, pid: i32) -> Option {
+ self.refresh_pid(pid);
let system = self.system.lock().unwrap();
system
.process(Pid::from_u32(pid as u32))
@@ -149,6 +204,11 @@ impl ProcessTable {
/// survivors. Windows: `TerminateProcess` (abrupt; `grace` is effectively
/// ignored, matching Go). Individual kill failures are swallowed; the
/// initial scan error propagates.
+ ///
+ /// The target set is scanned live, so this works for a table built long
+ /// before the processes existed — which is exactly the `run` path, where
+ /// the Pool (and this table) is opened before the child is spawned and
+ /// `wait_child` then blocks for the child's whole lifetime.
pub fn terminate_with_grace(
&self,
worktree_path: &Path,
@@ -170,25 +230,42 @@ impl ProcessTable {
}
/// Builds the set of pids that must never be killed: the calling process
- /// plus its whole ancestor chain. On a parent-lookup failure, fails CLOSED
- /// (returns `None`) so nothing is terminated.
+ /// plus its whole ancestor chain.
+ ///
+ /// Fails CLOSED only when an ancestor exists but its parent genuinely
+ /// cannot be read, so a partial chain is never mistaken for a short one.
+ /// The benign end-of-walk cases BREAK instead, matching Go's
+ /// `filterProtectedProcesses` (terminate.go:75-95):
+ ///
+ /// * **Reached the root** (`ppid == 0`, so sysinfo reports no parent).
+ /// Every unix root — pid 1 / launchd / init — has no parent, so treating
+ /// this as an error made the walk fail on EVERY platform, and
+ /// `terminate_with_grace` returned Err, so `destroy --include-in-use`
+ /// skipped every in-use worktree no matter what it killed. Go ends the
+ /// walk here via `if parent <= 0 { break }`.
+ /// * **The ancestor exited mid-walk** (absent from the table). Go treats
+ /// `ErrorProcessNotRunning` as a benign end — the common Windows case of
+ /// a parent exiting and leaving a dangling parent PID.
fn protected_chain(
&self,
current_pid: i32,
) -> Result, ProcessError> {
let mut protected = std::collections::HashSet::new();
protected.insert(current_pid);
+ // `parent_pid` yields None both for "this pid is the root" and "this
+ // pid is gone"; both END the walk. Only a pid that is present with an
+ // unreadable parent would be a real failure, and sysinfo cannot
+ // represent that case — so the walk ends rather than discarding the
+ // caller chain (and with it the ability to terminate anything).
let mut pid = current_pid;
- loop {
- match self.parent_pid(pid) {
- Some(parent) if parent > 0 && !protected.contains(&parent) => {
- protected.insert(parent);
- pid = parent;
- }
- Some(parent) if parent <= 0 => break,
- Some(_) => break, // cycle guard (already seen)
- None => return Err(ProcessError::Scan(format!("resolving parent of pid {pid}"))),
+ while let Some(parent) = self.parent_pid(pid) {
+ if parent <= 0 {
+ break; // root / invalid pid: ancestry is exhausted
+ }
+ if !protected.insert(parent) {
+ break; // cycle guard (already seen)
}
+ pid = parent;
}
Ok(protected)
}
@@ -365,29 +442,39 @@ mod tests {
}
#[test]
- fn protected_chain_fails_closed_on_missing_parent() {
- // A pid with no resolvable parent (e.g. a non-existent pid) must fail
- // closed (terminate nothing), matching Go's filterProtectedProcesses.
+ fn protected_chain_always_contains_the_callers_own_pid() {
+ // The safety invariant that must hold on EVERY path, including the
+ // benign end-of-walk cases: the calling process is in its own protected
+ // set, so terminate_with_grace can never signal itself.
+ //
+ // Previously this test was vacuous — it accepted Ok or Err, so it
+ // passed no matter what `protected_chain` did. It only ever passed by
+ // accident on platforms where the walk happened to succeed.
let table = ProcessTable::new();
- // The current process is ALWAYS protected.
let me = std::process::id() as i32;
- // If our own chain resolves, we must be in it. If it fails (e.g. Windows
- // snapshot can't resolve our parent), that is the fail-closed behavior —
- // terminate nothing.
- if let Ok(protected) = table.protected_chain(me) {
- assert!(protected.contains(&me));
- }
+ let protected = table
+ .protected_chain(me)
+ .expect("the ancestry walk must terminate, not error, on the root process");
+ assert!(
+ protected.contains(&me),
+ "own pid must be protected; got {protected:?}"
+ );
}
#[test]
- fn protected_chain_contains_own_pid_when_it_resolves() {
+ fn protected_chain_includes_the_parents_shell_when_resolvable() {
+ // The walk must actually climb, not just return the caller. Every
+ // platform's root (pid 1 / launchd / init) reports no parent, so a walk
+ // that cannot climb would still satisfy the own-pid invariant above.
let table = ProcessTable::new();
let me = std::process::id() as i32;
- // The calling process must never be killable: if the chain resolves,
- // we're protected.
- if let Ok(protected) = table.protected_chain(me) {
- assert!(protected.contains(&me), "own pid must be protected");
- }
+ let protected = table
+ .protected_chain(me)
+ .expect("walk terminates at the root");
+ assert!(
+ protected.len() > 1,
+ "the walk should reach at least one ancestor of pid {me}; got {protected:?}"
+ );
}
#[test]
@@ -401,4 +488,127 @@ mod tests {
"should be epoch millis (not seconds)"
);
}
+
+ /// Spawns a long-lived child whose cwd is `dir`, so the tests below can
+ /// observe a process that starts AFTER a `ProcessTable` is built.
+ ///
+ /// Uses the same "idle command" approach as the hook tests: on unix
+ /// `sleep`, on Windows `ping` (which has no `sleep` equivalent but does
+ /// block for its timeout). Both are killed by the caller on drop.
+ fn spawn_child_in(dir: &Path) -> std::process::Child {
+ let mut cmd = std::process::Command::new(if cfg!(windows) { "ping" } else { "sleep" });
+ if cfg!(windows) {
+ cmd.args(["-n", "60", "127.0.0.1"]);
+ } else {
+ cmd.arg("60");
+ }
+ cmd.current_dir(dir)
+ .spawn()
+ .expect("spawn idle child in the given dir")
+ }
+
+ /// Kills a spawned child and reaps it, so the pid leaves the table.
+ fn kill_child(child: &mut std::process::Child) {
+ let _ = child.kill();
+ let _ = child.wait();
+ }
+
+ /// M-003: a process started after the table was built must still be found.
+ ///
+ /// Before the fix, `ProcessTable` enumerated exactly once in `new()` and
+ /// `refresh()` had no callers, so this scan returned empty — and every
+ /// downstream safety decision (destroy classification, `owner_alive`,
+ /// `heal_state`) read that frozen snapshot. A worktree with an agent
+ /// started after `Pool::open` was classified `Disposable` and deleted.
+ #[test]
+ fn find_in_worktree_sees_process_started_after_the_table_was_built() {
+ let dir = tempfile::tempdir().unwrap();
+ // Build the table FIRST: the child does not exist yet, so this is
+ // exactly the "pool opened, then an agent started" ordering.
+ let table = ProcessTable::new();
+ assert!(
+ table.find_in_worktree(dir.path()).unwrap().is_empty(),
+ "precondition: no process in the dir before the child starts"
+ );
+
+ let mut child = spawn_child_in(dir.path());
+ let pid = child.id() as i32;
+
+ let found = table
+ .find_in_worktree(dir.path())
+ .unwrap_or_else(|e| panic!("scan failed: {e}"))
+ .into_iter()
+ .any(|p| p.pid == pid);
+ assert!(
+ found,
+ "a process started after the table was built must be visible to the scan \
+ (pid {pid}); a frozen snapshot reports it empty and destroy then deletes \
+ a worktree that is in use"
+ );
+
+ kill_child(&mut child);
+ }
+
+ /// M-003: `started_at` must resolve a pid created after the table was
+ /// built, so `owner_alive` does not report a live owner as dead.
+ ///
+ /// `owner_alive` returning false for a live owner makes `heal_state` zero
+ /// `owner_pid`/`owner_started_at` and clear `Destroying`, handing an
+ /// actively-used slot to a concurrent acquire.
+ #[test]
+ fn started_at_resolves_pid_created_after_the_table_was_built() {
+ let dir = tempfile::tempdir().unwrap();
+ let table = ProcessTable::new();
+ let mut child = spawn_child_in(dir.path());
+ let pid = child.id() as i32;
+
+ let started = table
+ .started_at(pid)
+ .unwrap_or_else(|| panic!("start time for a live child (pid {pid}) must resolve"));
+ assert!(
+ started > 1_000_000_000_000,
+ "should be epoch millis, got {started}"
+ );
+
+ kill_child(&mut child);
+ }
+
+ /// M-003/M-004: a terminated process must disappear from the table.
+ ///
+ /// This is the deterministic half of the `destroy --include-in-use` bug:
+ /// the post-kill survivor re-scan read the same snapshot that still listed
+ /// the just-killed pids, so `is_empty()` was never true and destroy always
+ /// skipped with "worktree processes still running after termination" —
+ /// the opt-in flag was silently a no-op.
+ #[test]
+ fn scan_after_termination_no_longer_reports_the_killed_process() {
+ let dir = tempfile::tempdir().unwrap();
+ let table = ProcessTable::new();
+ let mut child = spawn_child_in(dir.path());
+ let pid = child.id() as i32;
+
+ // Precondition: the child is visible, so the post-kill assertions
+ // below are not vacuously true.
+ assert!(
+ table
+ .find_in_worktree(dir.path())
+ .unwrap()
+ .iter()
+ .any(|p| p.pid == pid),
+ "precondition: child visible before termination"
+ );
+
+ // Terminate exactly as destroy does (SIGTERM -> grace -> SIGKILL),
+ // then re-scan. Without a refresh the killed pid is read back out of
+ // the frozen snapshot.
+ kill_child(&mut child);
+ let survivors = table
+ .find_in_worktree(dir.path())
+ .expect("survivor re-scan must not error");
+ assert!(
+ !survivors.iter().any(|p| p.pid == pid),
+ "the terminated process (pid {pid}) must not survive a live re-scan; \
+ survivors reported: {survivors:?}"
+ );
+ }
}
diff --git a/crates/treehouse-core/src/prune.rs b/crates/treehouse-core/src/prune.rs
index a5dc40f..49df853 100644
--- a/crates/treehouse-core/src/prune.rs
+++ b/crates/treehouse-core/src/prune.rs
@@ -8,7 +8,7 @@
//! Two-phase execution reuses the same engine as destroy (reserve `Destroying`
//! + owner under lock, hooks outside, re-verify `sameDestroyReservation`).
-use std::path::Path;
+use std::path::{Path, PathBuf};
use crate::pool::{Pool, PoolError};
use crate::reservation::Reservation;
@@ -83,6 +83,88 @@ impl Default for PruneOptions {
}
}
+/// The repository context one worktree is classified and removed against
+/// (Go `pruneContext`).
+///
+/// A pool is keyed by ORIGIN URL, so two clones of the same origin share one
+/// pool directory while every slot in it still belongs to exactly one physical
+/// clone. Resolving a single root for the whole pool and applying it to every
+/// slot runs `git worktree remove` from the wrong repository — which either
+/// errors, leaving stale registrations to accumulate, or deregisters another
+/// clone's bookkeeping. Under an `--all` sweep it is worse than wrong: the
+/// pool is opened by directory, so `self.root` is the pool directory's PARENT
+/// and not a repository at all, and nothing can ever be reclaimed.
+///
+/// Go's rule is stated at `resolvePoolRepoRoot` (destroy.go:614-620): callers
+/// "must resolve every slot independently and must not apply one slot's root to
+/// another".
+#[derive(Debug, Clone)]
+pub(crate) struct PruneContext {
+ /// The repository that owns this worktree.
+ pub repo_root: PathBuf,
+ /// The ref HEAD must be merged into for the slot to be disposable. `None`
+ /// when it could not be resolved, which every caller treats as
+ /// "unverifiable" — never as "disposable".
+ pub default_ref: Option,
+ /// Why `default_ref` is `None`, so the skip names the real cause instead of
+ /// a bare "cannot verify".
+ pub context_error: Option,
+}
+
+/// What one `execute_prune` run did: what it removed, what it refused to
+/// remove and why, and what it failed to clean up.
+type PruneExecution = (Vec, Vec, Vec);
+
+/// Memoizing per-repository context resolver (Go
+/// `worktreePruneContextResolver`, prune.go:401-418).
+///
+/// Resolution is per slot; the FETCH and default-ref lookup are per resolved
+/// root, and a pool whose slots all belong to one clone — the ordinary case —
+/// must not repeat that network round trip once per slot. Failures are cached
+/// too, so a pool with a dozen unreachable slots retries once, not a dozen
+/// times.
+pub(crate) struct PruneContextResolver<'a> {
+ pool: &'a Pool,
+ by_root: std::collections::HashMap>,
+}
+
+impl<'a> PruneContextResolver<'a> {
+ pub(crate) fn new(pool: &'a Pool) -> Self {
+ Self {
+ pool,
+ by_root: std::collections::HashMap::new(),
+ }
+ }
+
+ /// Resolves the context for one worktree, from its OWN path.
+ ///
+ /// `Err` is only for a repository root that cannot be resolved at all —
+ /// there is then no context to report a skip against. A resolved root with
+ /// a failed fetch still returns `Ok`, with `default_ref: None`, so the slot
+ /// is skipped as unverifiable instead of vanishing from the run.
+ pub(crate) fn context(&mut self, wt: &WorktreeEntry) -> Result {
+ let repo_root = self
+ .pool
+ .git
+ .main_repo_root(Path::new(&wt.path))
+ .map_err(|e| format!("resolve repository for worktree {}: {e}", wt.path))?;
+ let resolved = self
+ .by_root
+ .entry(repo_root.clone())
+ .or_insert_with(|| self.pool.resolve_prune_default_ref(&repo_root))
+ .clone();
+ let (default_ref, context_error) = match resolved {
+ Ok(ref_) => (Some(ref_), None),
+ Err(e) => (None, Some(e)),
+ };
+ Ok(PruneContext {
+ repo_root,
+ default_ref,
+ context_error,
+ })
+ }
+}
+
// Skip category strings (byte-exact, scripts match these).
pub const PRUNE_SKIP_UNCOMMITTED: &str = "uncommitted changes";
pub const PRUNE_SKIP_UNMERGED: &str = "unmerged";
@@ -102,18 +184,15 @@ impl Pool {
// Snapshot under one lock: read + heal + write.
let entries = with_pool_snapshot(self)?;
- // Resolve the default ref (fetch origin first).
- let repo_root = self
- .git
- .main_repo_root(&self.root)
- .unwrap_or_else(|_| self.root.clone());
- let default_ref = self.resolve_prune_default_ref(&repo_root);
+ // Each slot is resolved against its OWN repository (M-020); the
+ // fetch + default-ref lookup is memoized per resolved root.
+ let mut resolver = PruneContextResolver::new(self);
let mut result = PruneResult {
dry_run: opts.dry_run,
..Default::default()
};
- let mut planned: Vec<(PruneWorktree, String)> = Vec::new(); // (worktree, context_ref)
+ let mut planned: Vec<(PruneWorktree, PruneContext)> = Vec::new();
for wt in &entries {
if wt.destroying || wt.leased || crate::reservation::owner_alive(wt, &self.process) {
@@ -126,8 +205,38 @@ impl Pool {
if in_use {
continue;
}
- let (worktree, skipped, stale) =
- self.analyze_idle_worktree(wt, default_ref.as_ref().ok().map(|s| s.as_str()), opts);
+ // An orphan has no live repository behind it to resolve a ref in,
+ // and asking anyway costs a failed `rev-parse` per orphan on every
+ // run. `analyze_idle_worktree` returns on the orphan branch before
+ // it ever reads the default ref, so an empty context is honest
+ // there.
+ let context = if self.backing_repository_missing(&wt.path) {
+ PruneContext {
+ repo_root: PathBuf::new(),
+ default_ref: None,
+ context_error: None,
+ }
+ } else {
+ match resolver.context(wt) {
+ Ok(c) => c,
+ Err(reason) => {
+ result.skipped.push(PruneSkipped {
+ name: wt.name.clone(),
+ path: wt.path.clone(),
+ category: PRUNE_SKIP_CANNOT_VERIFY.to_string(),
+ reason: "cannot verify worktree".to_string(),
+ detail: reason,
+ });
+ continue;
+ }
+ }
+ };
+ let (worktree, skipped, stale) = self.analyze_idle_worktree(
+ wt,
+ context.default_ref.as_deref(),
+ opts,
+ context.context_error.as_deref(),
+ );
if !stale {
continue;
}
@@ -137,7 +246,7 @@ impl Pool {
}
result.candidates.push(worktree.clone());
result.reclaimable_bytes += worktree.bytes;
- planned.push((worktree, default_ref.clone().unwrap_or_default()));
+ planned.push((worktree, context));
}
if opts.dry_run || planned.is_empty() {
@@ -145,8 +254,9 @@ impl Pool {
}
// Execute (two-phase, reusing destroy's engine).
- let (pruned, errors) = self.execute_prune(&planned, &repo_root)?;
+ let (pruned, exec_skipped, errors) = self.execute_prune(&planned, opts)?;
result.pruned = pruned.clone();
+ result.skipped.extend(exec_skipped);
result.errors = errors;
result.freed_bytes = pruned.iter().map(|w| w.bytes).sum();
Ok(result)
@@ -154,12 +264,17 @@ impl Pool {
/// Analyzes an idle worktree (Go `analyzeIdleWorktree`): orphan detection,
/// dirty check, merge check, size.
+ ///
+ /// `context_error` is the reason the default ref could not be resolved; it
+ /// is carried into the skip's `detail` so "origin unreachable" is not
+ /// reported as a bare "cannot verify".
#[allow(clippy::result_large_err)]
fn analyze_idle_worktree(
&self,
wt: &WorktreeEntry,
default_ref: Option<&str>,
opts: &PruneOptions,
+ context_error: Option<&str>,
) -> (PruneWorktree, PruneSkipped, bool) {
let mut worktree = PruneWorktree {
name: wt.name.clone(),
@@ -224,8 +339,13 @@ impl Pool {
// Merge check against the default ref.
let Some(default_ref) = default_ref else {
- skipped.category = PRUNE_SKIP_ORIGIN_UNREACHABLE.to_string();
+ skipped.category = if context_error.is_some_and(|e| e.contains("origin unreachable")) {
+ PRUNE_SKIP_ORIGIN_UNREACHABLE.to_string()
+ } else {
+ PRUNE_SKIP_CANNOT_VERIFY.to_string()
+ };
skipped.reason = "cannot verify default branch".to_string();
+ skipped.detail = context_error.unwrap_or_default().to_string();
return (worktree, skipped, true);
};
match self
@@ -289,21 +409,23 @@ impl Pool {
/// Executes the two-phase prune (Go `executePrune`).
///
- /// Returns pruned worktrees and any cleanup errors. A worktree whose
- /// physical cleanup fails is **retained** in state so it remains eligible
- /// for retry on the next prune run.
+ /// Returns pruned worktrees, the skips that stopped a planned removal, and
+ /// any cleanup errors. A worktree whose physical cleanup fails is
+ /// **retained** in state — with its reservation restored, so it is
+ /// immediately eligible for retry rather than stuck behind a `destroying`
+ /// flag naming a process that is still alive.
fn execute_prune(
&self,
- planned: &[(PruneWorktree, String)],
- _repo_root: &Path,
- ) -> Result<(Vec, Vec), PoolError> {
+ planned: &[(PruneWorktree, PruneContext)],
+ opts: &PruneOptions,
+ ) -> Result {
// Phase 1: reserve Destroying + fresh owner under the lock.
- let reserved: Vec<(Reservation, PruneWorktree)> =
+ let reserved: Vec<(Reservation, PruneWorktree, PruneContext)> =
crate::pool::with_pool_lock(&self.dir, self.lock_timeout, || {
let mut state = State::read_state(&self.dir).map_err(PoolError::State)?;
heal_state(&mut state, |pid| self.process.started_at(pid));
let mut reserved = Vec::new();
- for (worktree, _) in planned {
+ for (worktree, context) in planned {
let Some(idx) = state.worktrees.iter().position(|w| w.path == worktree.path)
else {
continue;
@@ -321,7 +443,7 @@ impl Pool {
crate::git::GitErrorKind::Other,
))
})?;
- reserved.push((reservation, worktree.clone()));
+ reserved.push((reservation, worktree.clone(), context.clone()));
}
state_file::write_state(&self.dir, &state)
.map_err(|e| PoolError::Io("writing state".into(), e))?;
@@ -330,7 +452,7 @@ impl Pool {
// Hooks OUTSIDE all locks (non-fatal).
if !planned.is_empty() && !self.config.hooks.pre_destroy.is_empty() {
- for (reservation, _) in &reserved {
+ for (reservation, _, _) in &reserved {
let mut out = std::io::stdout();
let mut err = std::io::stderr();
crate::hooks::run(
@@ -342,18 +464,27 @@ impl Pool {
}
}
- // Phase 2: re-verify + physical cleanup + state commit.
+ // Phase 2: RE-CLASSIFY + physical cleanup + state commit.
+ //
+ // The plan's verdict is not trusted: between planning and here the
+ // `pre_destroy` hooks have run with no lock held, so a slot can have
+ // gone dirty, gained a process, or landed on a different HEAD. Go
+ // re-runs the whole classification inside the deleting lock
+ // (`finalPruneSafetyCheck`, prune.go:607-625) and clears the
+ // reservation on a skip, which is what makes the two-phase contract
+ // mean anything.
//
- // Invariant: a worktree is removed from state **only** when both
- // `git worktree remove` and `remove_dir_all` succeed. On failure the
- // entry is retained so the next prune run can retry.
+ // Invariant: a worktree is removed from state **only** when cleanup
+ // fully succeeds. On failure the entry is retained, reservation
+ // restored, so the next prune run can retry.
crate::pool::with_pool_lock(&self.dir, self.lock_timeout, || {
let mut state = State::read_state(&self.dir).map_err(PoolError::State)?;
let mut pruned = Vec::new();
+ let mut skipped = Vec::new();
let mut removed = std::collections::HashSet::new();
let mut errors = Vec::new();
- for (reservation, worktree) in &reserved {
+ for (reservation, worktree, context) in &reserved {
let Some(idx) = state
.worktrees
.iter()
@@ -365,14 +496,35 @@ impl Pool {
continue; // re-acquired mid-hook; never remove
}
let path = state.worktrees[idx].path.clone();
+
+ // Re-classify against the state we just read.
+ if let Some(skip) = self.final_prune_safety_check(
+ &state.worktrees[idx],
+ context,
+ opts,
+ worktree.orphaned,
+ ) {
+ reservation.restore_original(&mut state.worktrees[idx]);
+ skipped.push(skip);
+ continue;
+ }
+
let repo = crate::git::GitRepo {
- common_dir: self.root.clone(),
+ common_dir: context.repo_root.clone(),
worktree: None,
};
-
- // A: git worktree remove.
+ let orphaned = worktree.orphaned;
+
+ // A: VCS deregistration. `remove_clean_worktree` is the
+ // NON-FORCED removal (Go `RemoveCleanWorktree`, prune.go:551):
+ // prune only ever deletes worktrees it has just proven clean,
+ // and a forced removal would happily discard content that
+ // became uncommitted inside the hook window. An orphan has no
+ // live repository to deregister from, so it takes the
+ // filesystem route alone (Go prune.go:535-550).
let mut cleanup_ok = true;
- if let Err(e) = self.git.worktree_remove(&repo, Path::new(&path)) {
+ if !orphaned && let Err(e) = self.git.remove_clean_worktree(&repo, Path::new(&path))
+ {
errors.push(CleanupError {
name: worktree.name.clone(),
path: path.clone(),
@@ -404,16 +556,110 @@ impl Pool {
if cleanup_ok {
removed.insert(path.clone());
pruned.push(worktree.clone());
+ } else {
+ // Retain the entry, but drop OUR reservation from it. Left
+ // stamped, the slot reads as `destroying` and every
+ // subsequent prune skips it until this process exits, which
+ // turns one failed cleanup into a permanently unprunable
+ // slot.
+ reservation.restore_original(&mut state.worktrees[idx]);
}
- // !cleanup_ok → entry stays in state, eligible for retry
}
state.worktrees.retain(|w| !removed.contains(&w.path));
state_file::write_state(&self.dir, &state)
.map_err(|e| PoolError::Io("writing state".into(), e))?;
- Ok::<_, PoolError>((pruned, errors))
+ Ok::<_, PoolError>((pruned, skipped, errors))
})
}
+
+ /// The last word on disposability, run inside the deleting lock (Go
+ /// `finalPruneSafetyCheck` / `finalOrphanPruneSafetyCheck`).
+ ///
+ /// Returns `Some(skip)` when the slot must be left alone, `None` when it is
+ /// still disposable. Deliberately re-derives everything — in-use, dirty,
+ /// merge state, and for orphans the backing repository still being gone —
+ /// rather than trusting the plan, which was computed before an unlocked
+ /// hook window.
+ fn final_prune_safety_check(
+ &self,
+ wt: &WorktreeEntry,
+ context: &PruneContext,
+ opts: &PruneOptions,
+ planned_as_orphan: bool,
+ ) -> Option {
+ let skipped = PruneSkipped {
+ name: wt.name.clone(),
+ path: wt.path.clone(),
+ category: String::new(),
+ reason: String::new(),
+ detail: String::new(),
+ };
+
+ match self.process.is_worktree_in_use(Path::new(&wt.path)) {
+ Err(e) => {
+ return Some(PruneSkipped {
+ name: skipped.name,
+ path: skipped.path,
+ category: PRUNE_SKIP_CANNOT_CHECK_PROCESSES.to_string(),
+ reason: "cannot check processes".to_string(),
+ detail: e.to_string(),
+ });
+ }
+ Ok(true) => {
+ return Some(PruneSkipped {
+ name: skipped.name,
+ path: skipped.path,
+ category: PRUNE_SKIP_IN_USE.to_string(),
+ reason: PRUNE_SKIP_IN_USE.to_string(),
+ detail: String::new(),
+ });
+ }
+ Ok(false) => {}
+ }
+
+ if self.backing_repository_missing(&wt.path) {
+ // An orphan needs no repository to answer for it: the only question
+ // left is whether its operator authorized orphan removal at all.
+ if !opts.prune_orphans {
+ return Some(PruneSkipped {
+ name: skipped.name,
+ path: skipped.path,
+ category: PRUNE_SKIP_ORPHANED.to_string(),
+ reason: PRUNE_ORPHAN_WARNING.to_string(),
+ detail: String::new(),
+ });
+ }
+ return None;
+ }
+
+ if planned_as_orphan {
+ // The slot was planned as an orphan but its backing repository is
+ // back. Go refuses this route (prune.go:641-645) rather than
+ // reclassifying it as an ordinary worktree: the plan already
+ // committed to a filesystem-only removal, so switching to the VCS
+ // route now would deregister a worktree this run never verified.
+ return Some(PruneSkipped {
+ name: skipped.name,
+ path: skipped.path,
+ category: PRUNE_SKIP_ORPHANED.to_string(),
+ reason: "backing repository recovered".to_string(),
+ detail: String::new(),
+ });
+ }
+
+ let (_worktree, skipped, _) = self.analyze_idle_worktree(
+ wt,
+ context.default_ref.as_deref(),
+ opts,
+ context.context_error.as_deref(),
+ );
+ if skipped.reason.is_empty() {
+ None
+ } else {
+ Some(skipped)
+ }
+ }
}
/// Snapshot: read + heal + write under one lock.
@@ -705,6 +951,363 @@ mod tests {
);
}
+ // ─── M-009: the execute phase re-classifies inside the deleting lock ──
+
+ /// A `pre_destroy` hook that dirties the worktree it runs in. Hooks run
+ /// outside every lock, so this reproduces "the slot changed between the
+ /// plan and the delete" exactly — including the arbitrarily wide window a
+ /// real hook opens.
+ #[cfg(unix)]
+ const DIRTY_HOOK: &str = "printf x > late-arrival.txt";
+ #[cfg(windows)]
+ const DIRTY_HOOK: &str = "echo x > late-arrival.txt";
+
+ /// Builds a pool whose `pre_destroy` hook dirties the worktree, with one
+ /// idle, clean, unleased worktree. The pool's temp HOME is returned
+ /// alongside so the caller keeps it alive — dropping it deletes the pool.
+ fn setup_prune_test_with_dirty_hook() -> (
+ Pool,
+ std::path::PathBuf,
+ tempfile::TempDir,
+ tempfile::TempDir,
+ ) {
+ let dir = tempfile::tempdir().unwrap();
+ let repo = dir.path().join("repo");
+ let init = std::process::Command::new("git")
+ .args(["init", "--initial-branch=main", repo.to_str().unwrap()])
+ .current_dir(dir.path())
+ .output()
+ .unwrap();
+ assert!(init.status.success());
+ let run_git = |args: &[&str]| {
+ let out = std::process::Command::new("git")
+ .args(args)
+ .current_dir(&repo)
+ .output()
+ .unwrap();
+ assert!(out.status.success(), "git {args:?} failed");
+ };
+ run_git(&["config", "user.email", "t@t.com"]);
+ run_git(&["config", "user.name", "T"]);
+ std::fs::write(repo.join("README.md"), b"hi\n").unwrap();
+ run_git(&["add", "."]);
+ run_git(&["commit", "-m", "init"]);
+
+ let fake_home = tempfile::tempdir().unwrap();
+ let pool = Pool::open(
+ &repo,
+ None,
+ &OpenOptions {
+ config: TreehouseConfig {
+ root: Some(fake_home.path().to_str().unwrap().to_string()),
+ hooks: crate::config::Hooks {
+ pre_destroy: vec![DIRTY_HOOK.to_string()],
+ ..Default::default()
+ },
+ ..TreehouseConfig::default_config()
+ },
+ ..Default::default()
+ },
+ )
+ .unwrap();
+
+ let acquired = pool
+ .get(&crate::pool::AcquireOptions {
+ branch: Some("main".to_string()),
+ ..Default::default()
+ })
+ .unwrap();
+ let wt_path = acquired.path.clone();
+ pool.release(wt_path.to_str().unwrap()).unwrap();
+ (pool, wt_path, fake_home, dir)
+ }
+
+ /// M-009: a slot that goes dirty between the plan and the delete must not
+ /// be destroyed. The plan found it clean and idle; the `pre_destroy` hook
+ /// (running with no lock held) made it dirty. The execute phase used to
+ /// re-check only the reservation identity and delete it anyway, so the
+ /// destroy engine and the prune engine disagreed about "disposable" under
+ /// exactly the race the two-phase contract exists to close.
+ #[test]
+ fn prune_reclassifies_inside_the_deleting_lock() {
+ let (pool, wt_path, _home, _repo) = setup_prune_test_with_dirty_hook();
+
+ let result = pool
+ .prune(&PruneOptions {
+ dry_run: false,
+ prune_orphans: false,
+ pre_destroy: Vec::new(),
+ })
+ .unwrap();
+
+ assert!(
+ result.pruned.is_empty(),
+ "a slot that dirtied after planning must not be destroyed, got: {:?}",
+ result.pruned
+ );
+ assert!(result.errors.is_empty(), "this is a skip, not a failure");
+ assert!(
+ result
+ .skipped
+ .iter()
+ .any(|s| s.category == PRUNE_SKIP_UNCOMMITTED),
+ "the skip must name uncommitted changes, got: {:?}",
+ result.skipped
+ );
+ assert!(
+ wt_path.join("late-arrival.txt").exists(),
+ "the worktree must survive with the late write intact"
+ );
+
+ // The state entry is retained, AND our destroy reservation is restored:
+ // left stamped, the slot reads as `destroying` and every later prune
+ // skips it until this process exits.
+ let state = crate::state::State::read_state(pool.pool_dir()).unwrap();
+ let entry = state
+ .worktrees
+ .iter()
+ .find(|w| w.path == wt_path)
+ .expect("entry must remain in state");
+ assert!(!entry.destroying, "the reservation must be restored");
+ assert_eq!(entry.owner_pid, 0);
+ }
+
+ // ─── The orphan/recovered handoff in the final safety check ───────────
+
+ /// Builds a pool whose `pre_destroy` hook RESTORES a hidden backing
+ /// repository, with one idle worktree whose `.git` currently points at
+ /// that hidden path (so it classifies as an orphan at plan time).
+ ///
+ /// Returns the pool, the worktree path, and the directory the real gitdir
+ /// was moved into. The caller keeps both temp dirs alive.
+ ///
+ /// The pool is opened TWICE on purpose: the hook command can only be
+ /// written once the gitdir it has to restore is known, and that path only
+ /// exists after the first worktree has been created. Both pools resolve the
+ /// same repository and root, so they share one pool directory.
+ #[cfg(unix)]
+ fn setup_prune_test_with_recovering_hook() -> (
+ Pool,
+ std::path::PathBuf,
+ std::path::PathBuf,
+ tempfile::TempDir,
+ tempfile::TempDir,
+ ) {
+ let dir = tempfile::tempdir().unwrap();
+ let repo = dir.path().join("repo");
+ let init = std::process::Command::new("git")
+ .args(["init", "--initial-branch=main", repo.to_str().unwrap()])
+ .current_dir(dir.path())
+ .output()
+ .unwrap();
+ assert!(init.status.success());
+ let run_git = |args: &[&str]| {
+ let out = std::process::Command::new("git")
+ .args(args)
+ .current_dir(&repo)
+ .output()
+ .unwrap();
+ assert!(out.status.success(), "git {args:?} failed");
+ };
+ run_git(&["config", "user.email", "t@t.com"]);
+ run_git(&["config", "user.name", "T"]);
+ std::fs::write(repo.join("README.md"), b"hi\n").unwrap();
+ run_git(&["add", "."]);
+ run_git(&["commit", "-m", "init"]);
+
+ let fake_home = tempfile::tempdir().unwrap();
+ let root = fake_home.path().to_str().unwrap().to_string();
+
+ // Pass 1: no hooks, just to materialize a worktree we can inspect.
+ let plain = Pool::open(
+ &repo,
+ None,
+ &OpenOptions {
+ config: TreehouseConfig {
+ root: Some(root.clone()),
+ ..TreehouseConfig::default_config()
+ },
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ let acquired = plain
+ .get(&crate::pool::AcquireOptions {
+ branch: Some("main".to_string()),
+ ..Default::default()
+ })
+ .unwrap();
+ let wt_path = acquired.path.clone();
+ plain.release(wt_path.to_str().unwrap()).unwrap();
+
+ // Make it an orphan by moving the gitdir the `.git` file NAMES out of
+ // the way. `backing_repository_missing` only asks whether that target
+ // exists, so the rename is enough — and the hook can undo it.
+ let contents = std::fs::read_to_string(wt_path.join(".git")).unwrap();
+ assert!(contents.starts_with("gitdir:"), "got {contents:?}");
+ let target = Path::new(contents.trim_start_matches("gitdir:").trim()).to_path_buf();
+ assert!(
+ target.is_absolute(),
+ "expected an absolute gitdir, got {target:?}"
+ );
+ let hidden = target.with_extension("orphan-hidden");
+ std::fs::rename(&target, &hidden).unwrap();
+
+ // Pass 2: the same pool, now with a hook that puts the gitdir back.
+ let hook = format!(
+ "mv '{}' '{}'",
+ hidden.to_string_lossy(),
+ target.to_string_lossy()
+ );
+ let pool = Pool::open(
+ &repo,
+ None,
+ &OpenOptions {
+ config: TreehouseConfig {
+ root: Some(root),
+ hooks: crate::config::Hooks {
+ pre_destroy: vec![hook],
+ ..Default::default()
+ },
+ ..TreehouseConfig::default_config()
+ },
+ ..Default::default()
+ },
+ )
+ .unwrap();
+
+ (pool, wt_path, hidden, fake_home, dir)
+ }
+
+ /// A slot planned as an ORPHAN whose backing repository reappears must be
+ /// reported as an orphan whose repository came back — Go's
+ /// `finalOrphanPruneSafetyCheck` dispatch (prune.go:518-519, 645-647).
+ ///
+ /// The plan committed this slot to a filesystem-only removal with NO merge
+ /// or dirty verification at all. Once the gitdir is back it is an ordinary,
+ /// verifiable worktree again, so it must be classified as "recovered" and
+ /// not quietly folded into the ordinary route that would deregister a
+ /// worktree this run never checked.
+ ///
+ /// The assertion is on the skip CATEGORY, which is what Go reports and what
+ /// the operator reads. Deleting the branch does also leave the worktree on
+ /// disk — an orphan's empty context cannot resolve a default ref, so the
+ /// fall-through refuses with a vaguer reason — but it loses the
+ /// classification, which is the contract pinned here.
+ #[cfg(unix)]
+ #[test]
+ fn orphan_planned_slot_is_skipped_when_its_repository_recovers() {
+ let (pool, wt_path, hidden, _home, _repo) = setup_prune_test_with_recovering_hook();
+
+ // Precondition: the pool agrees the slot is an orphan right now.
+ assert!(
+ pool.backing_repository_missing(&wt_path.to_string_lossy()),
+ "test setup must leave the slot orphaned before prune runs"
+ );
+
+ // `prune_orphans: true` is required, or the plan never reaches the
+ // orphan branch at all and this would prove nothing.
+ let result = pool
+ .prune(&PruneOptions {
+ dry_run: false,
+ prune_orphans: true,
+ pre_destroy: Vec::new(),
+ })
+ .unwrap();
+
+ assert!(
+ result.pruned.is_empty(),
+ "a slot whose repository came back must not be deleted: {:?}",
+ result.pruned
+ );
+ assert!(
+ result
+ .skipped
+ .iter()
+ .any(|s| s.category == PRUNE_SKIP_ORPHANED
+ && s.reason == "backing repository recovered"),
+ "the skip must name the recovered repository, got: {:?}",
+ result.skipped
+ );
+ // The hook really did put the gitdir back (it MOVES the hidden copy
+ // home, so `hidden` is gone and the slot is no longer orphaned), and
+ // the worktree survived the skip.
+ assert!(
+ !hidden.exists(),
+ "the hook should have moved the hidden gitdir back"
+ );
+ assert!(
+ !pool.backing_repository_missing(&wt_path.to_string_lossy()),
+ "the backing repository must be resolvable again"
+ );
+ assert!(wt_path.exists(), "the worktree must survive the skip");
+ }
+
+ // ─── M-020: the repo root is resolved per slot ───────────────────────
+
+ /// M-020: `prune --all` opens the pool BY DIRECTORY, so `self.root` is the
+ /// pool directory's parent — not a repository. Resolving one root for the
+ /// whole pool therefore failed for every slot, `default_ref` came back
+ /// unresolved, and prune could never reclaim anything: it degraded to
+ /// "records a skip, keeps the entry" forever. Each slot must be resolved
+ /// from its OWN path instead.
+ #[test]
+ fn prune_under_an_all_sweep_resolves_each_slots_own_repository() {
+ let (pool, wt_path, _home, _repo) = setup_prune_test();
+ // Reopen the way `--all` does: by pool directory, with no repository
+ // in hand.
+ let swept = Pool::open_at(pool.pool_dir(), &OpenOptions::default()).unwrap();
+ assert_eq!(
+ swept.root,
+ pool.pool_dir().parent().unwrap(),
+ "an --all sweep has no repository as its root"
+ );
+
+ let result = swept
+ .prune(&PruneOptions {
+ dry_run: false,
+ prune_orphans: false,
+ pre_destroy: Vec::new(),
+ })
+ .unwrap();
+
+ assert!(
+ result.skipped.is_empty(),
+ "no slot should be unverifiable under a per-slot root, got: {:?}",
+ result.skipped
+ );
+ assert_eq!(
+ result.pruned.len(),
+ 1,
+ "the idle worktree must actually be reclaimed"
+ );
+ assert!(!wt_path.exists(), "the worktree directory must be gone");
+ }
+
+ /// Every slot is resolved from its own path, so a pool whose slots belong
+ /// to different clones is classified against each one's own merge ref.
+ #[test]
+ fn context_resolver_reads_the_worktrees_own_repository() {
+ let (pool, wt_path, _home, _repo) = setup_prune_test();
+ let mut resolver = PruneContextResolver::new(&pool);
+ let state = crate::state::State::read_state(pool.pool_dir()).unwrap();
+ let wt = state.worktrees.iter().find(|w| w.path == wt_path).unwrap();
+ let context = resolver.context(wt).unwrap();
+ assert!(
+ context.repo_root.ends_with("repo"),
+ "must resolve the worktree's own repository, got: {:?}",
+ context.repo_root
+ );
+ assert!(
+ context.default_ref.is_some(),
+ "a live local-only repo resolves a default ref"
+ );
+ // Memoized: a second call for the same slot must not re-fetch.
+ let again = resolver.context(wt).unwrap();
+ assert_eq!(again.repo_root, context.repo_root);
+ assert_eq!(again.default_ref, context.default_ref);
+ }
+
// ─── Platform helpers ───────────────────────────────────────────────
#[cfg(unix)]
diff --git a/crates/treehouse-core/src/result.rs b/crates/treehouse-core/src/result.rs
index 71b72f9..2d2a398 100644
--- a/crates/treehouse-core/src/result.rs
+++ b/crates/treehouse-core/src/result.rs
@@ -45,7 +45,7 @@ impl StatusJson {
leased_at: if ws.leased_at == ZERO_TIME {
None
} else {
- Some(ws.leased_at.to_rfc3339())
+ Some(rfc3339(ws.leased_at))
},
processes: ws
.processes
@@ -153,18 +153,12 @@ pub struct PruneResultJson {
}
impl PruneResultJson {
- pub fn from_result(
- r: &PruneResult,
- dry_run: bool,
- orphans_included: bool,
- global: bool,
- pool_count: u32,
- ) -> Self {
+ pub fn from_result(r: &PruneResult, scope: SweepScope) -> Self {
PruneResultJson {
- dry_run,
- orphans_included,
- global,
- pool_count,
+ dry_run: r.dry_run,
+ orphans_included: scope.orphans_included,
+ global: scope.global,
+ pool_count: scope.pool_count,
candidates: r.candidates.len() as u32,
pruned: r.pruned.len() as u32,
skipped: r.skipped.len() as u32,
@@ -175,6 +169,55 @@ impl PruneResultJson {
}
}
+/// The result of a `gc` command (NEW schema).
+///
+/// Same shape as [`PruneResultJson`] — counts, not per-item records — so an
+/// agent parsing `--format json` from either command handles one vocabulary.
+/// The per-slot detail a human needs is in the human format; a caller that
+/// wants names re-reads them from `status`.
+#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct GcResultJson {
+ pub dry_run: bool,
+ pub orphans_included: bool,
+ pub global: bool,
+ pub pool_count: u32,
+ pub candidates: u32,
+ pub reclaimed: u32,
+ pub skipped: u32,
+ /// Worktrees whose physical cleanup failed (state retained for retry).
+ pub errors: u32,
+ pub reclaimable_bytes: u64,
+ pub freed_bytes: u64,
+}
+
+impl GcResultJson {
+ pub fn from_result(r: &crate::gc::GcResult, scope: SweepScope) -> Self {
+ GcResultJson {
+ dry_run: r.dry_run,
+ orphans_included: scope.orphans_included,
+ global: scope.global,
+ pool_count: scope.pool_count,
+ candidates: r.candidates.len() as u32,
+ reclaimed: r.reclaimed.len() as u32,
+ skipped: r.skipped.len() as u32,
+ errors: r.errors.len() as u32,
+ reclaimable_bytes: r.reclaimable_bytes,
+ freed_bytes: r.freed_bytes,
+ }
+ }
+}
+
+/// How many pools a sweep covered, and whether it was global. Threaded through
+/// the result so `--format json` on `prune --all` / `gc --all` says so instead
+/// of reporting a single anonymous pool.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct SweepScope {
+ pub global: bool,
+ pub pool_count: u32,
+ pub orphans_included: bool,
+}
+
/// The one structured result per command. `payload()` returns `None` for
/// interactive get/enter so a stray stdout write never corrupts the subshell.
#[derive(Debug, Clone)]
@@ -183,8 +226,9 @@ pub enum CommandResult {
Enter,
Return(ReturnResult),
Status(Vec),
- Prune(PruneResult),
+ Prune(PruneResult, SweepScope),
Destroy(DestroyResult),
+ Gc(crate::gc::GcResult, SweepScope),
}
/// The result of a `get` command.
@@ -210,17 +254,26 @@ impl CommandResult {
let arr: Vec = statuses.iter().map(StatusJson::from_ws).collect();
Some(serde_json::to_value(arr).unwrap_or(serde_json::Value::Null))
}
- CommandResult::Prune(r) => {
- serde_json::to_value(PruneResultJson::from_result(r, false, false, false, 1)).ok()
+ CommandResult::Prune(r, scope) => {
+ serde_json::to_value(PruneResultJson::from_result(r, *scope)).ok()
}
CommandResult::Destroy(r) => {
serde_json::to_value(DestroyResultJson::from_result(r)).ok()
}
+ CommandResult::Gc(r, scope) => {
+ serde_json::to_value(GcResultJson::from_result(r, *scope)).ok()
+ }
}
}
}
-/// A lease result timestamp helper (Go uses RFC3339Nano; chrono emits it).
+/// Renders a lease timestamp for machine-readable output.
+///
+/// The JSON payloads are a wire contract that scripts parse, so the rendering
+/// lives here rather than being open-coded at each call site: Go emits
+/// RFC3339Nano while chrono emits second-precision by default, and when the
+/// two are reconciled this is the single place that has to change. An inline
+/// `to_rfc3339()` at the call site would hide that.
pub fn rfc3339(dt: DateTime) -> String {
dt.to_rfc3339()
}
diff --git a/crates/treehouse-core/src/run.rs b/crates/treehouse-core/src/run.rs
index 11dc22c..6eeb249 100644
--- a/crates/treehouse-core/src/run.rs
+++ b/crates/treehouse-core/src/run.rs
@@ -71,16 +71,22 @@ pub fn run(pool: &Pool, opts: &RunOptions) -> Result {
let lease_id = lease.id.clone();
let lease_holder = lease.holder.clone();
- // Spawn the child in the worktree with the lease env.
- let child = spawn_child(pool, &worktree_path, &lease_id, opts)?;
-
- // RAII cleanup guard: runs on every exit path (incl. panic unwinding).
+ // The guard is constructed IMMEDIATELY after the acquire, BEFORE the
+ // spawn. `spawn_child`'s `?` used to return past a guard that did not yet
+ // exist, so a command that could not be executed (typo, missing binary)
+ // left a durably leased slot behind for the whole TTL — with no owner to
+ // release it and nothing in `status` to explain why the pool shrank.
+ // From here on every exit path, including a failing spawn, unwinds
+ // through `Drop`.
let cleanup = CleanupGuard {
pool,
worktree_path: &worktree_path,
lease_id: &lease_id,
};
+ // Spawn the child in the worktree with the lease env.
+ let child = spawn_child(&worktree_path, &lease_id, opts)?;
+
// Wait for the child, forwarding signals (unix only; Windows uses
// GenerateConsoleCtrlEvent in the signal handler below).
let status = wait_child(child)?;
@@ -104,7 +110,6 @@ pub fn run(pool: &Pool, opts: &RunOptions) -> Result {
/// Spawns the child command inside the worktree.
fn spawn_child(
- pool: &Pool,
worktree_path: &std::path::Path,
lease_id: &str,
opts: &RunOptions,
@@ -116,7 +121,6 @@ fn spawn_child(
// return --if-lease-id).
cmd.env("TREEHOUSE_DIR", worktree_path);
cmd.env("TREEHOUSE_LEASE_ID", lease_id);
- let _ = pool;
// New process group so we can signal the whole group.
#[cfg(unix)]
{
@@ -174,11 +178,14 @@ impl CleanupGuard<'_> {
.pool
.process
.terminate_with_grace(self.worktree_path, Duration::from_secs(2));
- // 2. Reset the worktree to the default branch (detach HEAD + clean).
- if let Err(e) = self.pool.git_is_dirty(self.worktree_path) {
- let _ = e;
- }
- // 3. Release the lease (conditional on our lease id — ABA-safe).
+ // 2. Release the lease (conditional on our lease id — ABA-safe).
+ // This is also the reset: `release_conditional` runs `reset_worktree`
+ // (detach HEAD + `reset --hard` + `clean -fd`) between its two lock
+ // acquisitions, so the slot comes back clean without a separate step.
+ // A standalone reset here would either duplicate that destructive work
+ // or run it before the lease precondition has been checked — and
+ // discarding the agent's work is this verb's contract, not a decision
+ // this guard gets to make independently of the release.
let pre = crate::pool::ReleasePreconditions {
expected_lease_id: Some(self.lease_id.to_string()),
..Default::default()
@@ -195,6 +202,16 @@ impl Drop for CleanupGuard<'_> {
fn drop(&mut self) {
// Best-effort cleanup on every exit path. A panic unwinds and still
// runs this.
+ //
+ // On the happy path this is a SECOND pass: `run` cleans up explicitly
+ // so it can report the outcome, then unwinds through here. That is
+ // deliberate, not an oversight — a pass that reported
+ // `CleanupFailed` because of a transient lock timeout gets retried
+ // here, and a pass that succeeded is inert on the retry because
+ // `release_conditional` refuses a slot that is no longer leased
+ // (pool.rs `expected_lease_id` -> "worktree is not leased") before it
+ // ever reaches `reset_worktree`. So the repeat can tighten the pool
+ // but can never reset a slot somebody else has since taken.
let _ = self.run();
}
}
@@ -217,4 +234,123 @@ mod tests {
CleanupOutcome::CleanupFailed(_) => panic!(),
}
}
+
+ // ─── The repeat cleanup pass must not touch a re-acquired slot ─────────
+
+ /// Builds a repo with one commit on `main`. Returns (tempdir, repo path);
+ /// the TempDir must be kept alive by the caller.
+ fn init_repo() -> (tempfile::TempDir, std::path::PathBuf) {
+ let dir = tempfile::tempdir().unwrap();
+ let repo = dir.path().join("repo");
+ let out = std::process::Command::new("git")
+ .args(["init", "--initial-branch=main", repo.to_str().unwrap()])
+ .current_dir(dir.path())
+ .output()
+ .expect("git must be installed");
+ assert!(out.status.success(), "git init failed");
+ let run_git = |args: &[&str]| {
+ let out = std::process::Command::new("git")
+ .args(args)
+ .current_dir(&repo)
+ .output()
+ .unwrap();
+ assert!(
+ out.status.success(),
+ "git {args:?} failed: {}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+ };
+ run_git(&["config", "user.email", "t@t.com"]);
+ run_git(&["config", "user.name", "T"]);
+ std::fs::write(repo.join("README.md"), b"hi\n").unwrap();
+ run_git(&["add", "."]);
+ run_git(&["commit", "-m", "init"]);
+ (dir, repo)
+ }
+
+ fn pool_over(dir: &tempfile::TempDir, repo: &std::path::Path) -> Pool {
+ let opts = crate::OpenOptions {
+ config: crate::config::TreehouseConfig {
+ root: Some(dir.path().to_str().unwrap().to_string()),
+ ..crate::config::TreehouseConfig::default_config()
+ },
+ ..Default::default()
+ };
+ Pool::open(repo, None, &opts).unwrap()
+ }
+
+ /// Acquires a leased slot, pinned to `main` because `init_repo` builds a
+ /// repo with no remote to resolve a default branch from.
+ fn acquire_leased(pool: &Pool, holder: &str) -> crate::pool::Acquired {
+ pool.get(&AcquireOptions {
+ branch: Some("main".into()),
+ lease: Some(LeaseAcquireOptions {
+ holder: holder.to_string(),
+ ttl: Some(chrono::Duration::hours(1)),
+ }),
+ ..Default::default()
+ })
+ .unwrap()
+ }
+
+ /// `run` cleans up explicitly and then unwinds through `Drop`, so the
+ /// guard runs TWICE on the happy path. The second pass is only safe
+ /// because it is pinned to the lease it took: by then the slot may have
+ /// been handed to somebody else, and resetting it would discard their work
+ /// under them.
+ ///
+ /// This is the invariant the `Drop` comment asserts. If `release_conditional`
+ /// ever stopped refusing a slot that is no longer leased by us, this is the
+ /// test that would say so.
+ #[test]
+ fn repeat_cleanup_refuses_a_slot_someone_else_has_since_leased() {
+ let (dir, repo) = init_repo();
+ let pool = pool_over(&dir, &repo);
+
+ let first = acquire_leased(&pool, "run:first");
+ let path = first.path.clone();
+ let first_lease = first.lease.as_ref().expect("a lease was taken").id.clone();
+
+ let guard = CleanupGuard {
+ pool: &pool,
+ worktree_path: &path,
+ lease_id: &first_lease,
+ };
+ // First pass: the slot is ours, so it is ours to release.
+ assert!(matches!(guard.run(), CleanupOutcome::Cleaned));
+
+ // A second acquisition takes the slot we just gave back.
+ let second = acquire_leased(&pool, "run:second");
+ assert_eq!(second.path, path, "the released slot should be reused");
+ let second_lease = second
+ .lease
+ .as_ref()
+ .expect("a lease was taken")
+ .id
+ .clone();
+ assert_ne!(second_lease, first_lease, "each lease gets its own id");
+
+ // The repeat pass must refuse, not reset. Pin the REASON too: a
+ // refusal for anything else (slot vanished, pool renamed) would leave
+ // the new lease intact for the wrong reason and stop testing the
+ // identity check this invariant rests on.
+ match guard.run() {
+ CleanupOutcome::CleanupFailed(ref why) => assert!(
+ why.contains("lease"),
+ "expected a lease-identity refusal, got: {why}"
+ ),
+ CleanupOutcome::Cleaned => {
+ panic!("cleanup reset a slot that a different lease owns")
+ }
+ }
+
+ // ...and the new owner's lease survived untouched.
+ let status = pool.status().unwrap();
+ let wt = status
+ .iter()
+ .find(|w| w.path == path.to_string_lossy())
+ .expect("the slot is still tracked");
+ assert_eq!(wt.status, crate::pool::STATUS_LEASED);
+ assert_eq!(wt.lease_id, second_lease);
+ }
}
diff --git a/crates/treehouse-core/src/state.rs b/crates/treehouse-core/src/state.rs
index 9dc6b0d..4d7ede8 100644
--- a/crates/treehouse-core/src/state.rs
+++ b/crates/treehouse-core/src/state.rs
@@ -8,10 +8,16 @@
//! output.
//!
//! Reading rules:
-//! - A **missing** state file is a fresh, empty pool.
+//! - A **missing** state file is a fresh, empty pool — unless worktree
+//! directories already exist under the pool directory, which are recovered
+//! as leased (see [`recover_missing_state_entries`]).
//! - A file that **exists but fails to parse** (empty or truncated) is
//! *corrupt*, not missing — it triggers conservative recovery that marks
//! every on-disk worktree as leased until a human verifies it.
+//!
+//! Both recovery paths resolve a worktree through the single
+//! [`recover_one_worktree`] helper, so the same on-disk condition can never be
+//! classified one way by one path and the other way by the other.
use std::path::Path;
@@ -111,6 +117,31 @@ pub struct WorktreeEntry {
/// load identically). Only serialized when nonzero.
#[serde(default, skip_serializing_if = "is_zero_utc", with = "rfc3339_nano")]
pub expires_at: DateTime,
+ /// Why this entry's VCS marker could not be resolved during a state
+ /// recovery scan. Mirrors Go's `RecoveryError`: empty for a worktree whose
+ /// marker read back normally, non-empty for one whose marker is present
+ /// but unresolvable (dangling or self-referential symlink, a loop, a
+ /// permission failure). The entry is otherwise an ordinary recovered
+ /// entry — leased, so acquire and prune skip it and destroy removes it
+ /// only via an explicit single-target `--include-leased` — but the reason
+ /// makes a damaged slot visible instead of reading as an ordinary lease.
+ #[serde(default, skip_serializing_if = "is_empty_str")]
+ pub recovery_error: String,
+ /// Fields this build does not understand, preserved verbatim.
+ ///
+ /// Go's entry carries acquisition metadata the port has not implemented
+ /// yet (`base_branch`, `seeded_paths`, `seed_inventory_*`, `seed_backend`,
+ /// `seed_auth_identity`, `recovery_reason`). Every state write is a
+ /// read-modify-write of the whole file, so without this map a single
+ /// read/write cycle silently deletes all of it — metadata loss on a wire
+ /// format the module is contractually required to reproduce. Keeping
+ /// unknown keys means a pool written by a newer treehouse can still be
+ /// listed, returned and destroyed by this build without damage, and
+ /// nothing is lost while the fields above are being ported.
+ ///
+ /// Populated on read, emitted on write, never interpreted here.
+ #[serde(flatten)]
+ pub extra: std::collections::BTreeMap,
}
impl WorktreeEntry {
@@ -133,6 +164,21 @@ impl WorktreeEntry {
pub struct State {
#[serde(default)]
pub worktrees: Vec,
+ /// Top-level fields this build does not understand, preserved verbatim.
+ ///
+ /// The same read-modify-write argument as [`WorktreeEntry::extra`], one
+ /// level up. Go's `State` carries `version` (state.go:82, currently 4) and
+ /// a Go v3 pool also carries a seed-auth key reference this port does not
+ /// model. Because *every* state write rewrites the whole file from a
+ /// parsed `State`, a top-level flatten map is the only thing standing
+ /// between reading a pool written by another build and silently deleting
+ /// its `version` on the first `get`, `return`, or `prune`.
+ ///
+ /// Not modelled, only carried: reading a `version` this build does not
+ /// understand is Go's quarantine decision (state.go:135-136), and quietly
+ /// acting on an unknown schema would be worse than preserving it.
+ #[serde(flatten)]
+ pub extra: std::collections::BTreeMap,
}
impl State {
@@ -148,25 +194,42 @@ impl State {
/// Loads pool state.
///
- /// A missing file is a fresh empty pool. A file that exists but fails to
- /// parse (empty or truncated) is corrupt: it is conservatively recovered
- /// by scanning the pool directory for worktrees still on disk and marking
- /// each `leased` (see [`recover_corrupt_state`]). If that scan cannot
- /// complete, the call fails closed rather than returning partial state.
+ /// A file that exists but fails to parse (empty or truncated) is corrupt:
+ /// it is conservatively recovered by scanning the pool directory for
+ /// worktrees still on disk and marking each `leased` (see
+ /// [`recover_corrupt_state`]).
+ ///
+ /// A missing file is a fresh, empty pool — unless worktree directories
+ /// already exist, in which case [`recover_missing_state_entries`] adopts
+ /// them as leased. That scan also runs after a *successful* parse: the
+ /// window where `git worktree add` committed but the state write did not
+ /// leaves a real worktree absent from an otherwise-valid file, and
+ /// `next_name` would otherwise reissue its slot number.
+ ///
+ /// If the pool directory itself cannot be enumerated the call fails closed
+ /// rather than returning partial state. A single unreadable marker *inside*
+ /// a worktree is a per-slot problem and is recovered, not fatal.
pub fn read_state(pool_dir: &Path) -> Result {
let path = Self::state_file_path(pool_dir);
let data = match std::fs::read(&path) {
Ok(d) => d,
- Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(State::default()),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+ return recover_missing_state_entries(pool_dir, State::default(), &path);
+ }
Err(e) => return Err(StateError::Read(path, e)),
};
match serde_json::from_slice(&data) {
- Ok(s) => Ok(s),
+ Ok(s) => recover_missing_state_entries(pool_dir, s, &path),
Err(parse_err) => recover_corrupt_state(pool_dir, path, parse_err),
}
}
/// Reads pool state using the injected environment.
+ ///
+ /// The state *file* is read through `env`, but both recovery scans walk the
+ /// real filesystem: a recovery is about what is on disk, so an injected
+ /// environment that keeps state in memory simply has nothing to adopt and
+ /// the scan is a no-op.
pub fn read_state_with_env(
pool_dir: &Path,
env: &dyn crate::env::TreehouseEnv,
@@ -174,19 +237,24 @@ impl State {
let path = Self::state_file_path(pool_dir);
let data = match env.read_bytes(&path) {
Ok(d) => d,
- Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(State::default()),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+ return recover_missing_state_entries(pool_dir, State::default(), &path);
+ }
Err(e) => return Err(StateError::Read(path, e)),
};
match serde_json::from_slice(&data) {
- Ok(s) => Ok(s),
+ Ok(s) => recover_missing_state_entries(pool_dir, s, &path),
Err(parse_err) => recover_corrupt_state(pool_dir, path, parse_err),
}
}
}
-/// Marker placed on entries reconstructed by [`recover_corrupt_state`] so
+/// Marker placed on every entry either recovery scan reconstructs —
+/// [`recover_corrupt_state`] and [`recover_missing_state_entries`] alike — so
/// callers (status output, destroy) can explain why they are unexpectedly
-/// leased. Mirrors Go's `recoveredLeaseHolder`.
+/// leased. The wording is kept byte-identical to Go's `RecoveredLeaseHolder`,
+/// which also does not distinguish the two recovery paths. Mirrors Go's
+/// `recoveredLeaseHolder`.
pub const RECOVERED_LEASE_HOLDER: &str =
"recovered: state file was corrupt or truncated; verify before reuse";
@@ -203,47 +271,10 @@ fn recover_corrupt_state(
state_path: std::path::PathBuf,
parse_err: serde_json::Error,
) -> Result {
- let slots =
- std::fs::read_dir(pool_dir).map_err(|e| StateError::RecoverScan(state_path.clone(), e))?;
-
let mut recovered = Vec::new();
- for slot in slots {
- let slot = slot.map_err(|e| StateError::RecoverScan(state_path.clone(), e))?;
- if !slot
- .file_type()
- .map_err(|e| StateError::RecoverScan(state_path.clone(), e))?
- .is_dir()
- {
- continue;
- }
- let slot_dir = pool_dir.join(slot.file_name());
- let nested = std::fs::read_dir(&slot_dir)
- .map_err(|e| StateError::RecoverScan(state_path.clone(), e))?;
- for entry in nested {
- let entry = entry.map_err(|e| StateError::RecoverScan(state_path.clone(), e))?;
- if !entry
- .file_type()
- .map_err(|e| StateError::RecoverScan(state_path.clone(), e))?
- .is_dir()
- {
- continue;
- }
- let wt_path = slot_dir.join(entry.file_name());
- match std::fs::metadata(wt_path.join(".git")) {
- Ok(_) => {}
- Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
- Err(e) => return Err(StateError::RecoverScan(state_path.clone(), e)),
- }
- let now = chrono::Utc::now();
- recovered.push(WorktreeEntry {
- name: slot.file_name().to_string_lossy().into_owned(),
- path: wt_path.to_string_lossy().into_owned(),
- created_at: now,
- leased: true,
- lease_holder: RECOVERED_LEASE_HOLDER.to_string(),
- leased_at: now,
- ..WorktreeEntry::default()
- });
+ for (slot_name, wt_path) in scan_worktree_dirs(pool_dir, &state_path)? {
+ if let Some(wt) = recover_one_worktree(&slot_name, &wt_path) {
+ recovered.push(wt);
}
}
@@ -253,9 +284,229 @@ fn recover_corrupt_state(
);
Ok(State {
worktrees: recovered,
+ ..Default::default()
})
}
+/// Adopts worktree directories that exist on disk but are absent from an
+/// otherwise-usable state file (Go `recoverMissingStateEntries`).
+///
+/// This covers the narrow window where `git worktree add` committed but
+/// persisting its entry did not. Left unhandled, such a worktree is invisible
+/// to the pool: `status` and `gc` under-report it, `destroy` cannot name it,
+/// and `next_name` — which computes `max(name) + 1` over the state list — hands
+/// the same slot number back and tries to create a worktree where one already
+/// exists. That last failure is loud rather than destructive (git refuses to
+/// add into an occupied directory), but it never resolves on its own, so the
+/// pool stays wedged until the directory is removed by hand.
+///
+/// Adopted entries are quarantined exactly like a corrupt-state recovery, so
+/// the same "leased until a human looks at it" contract holds on this path
+/// too. Entries already named by the state file are left untouched: this
+/// function only ever adds.
+fn recover_missing_state_entries(
+ pool_dir: &Path,
+ mut state: State,
+ state_path: &Path,
+) -> Result {
+ // Go stats the pool directory before scanning: a pool that does not exist
+ // has no worktrees to adopt and is a genuinely fresh, empty pool. A
+ // directory we cannot even *stat* is a different case and stays an error —
+ // we could not tell "nothing there" from "cannot see".
+ match std::fs::metadata(pool_dir) {
+ Ok(m) if m.is_dir() => {}
+ Ok(_) => {
+ return Err(StateError::RecoverScan(
+ state_path.to_path_buf(),
+ std::io::Error::other(format!("{} is not a directory", pool_dir.display())),
+ ));
+ }
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(state),
+ Err(e) => return Err(StateError::RecoverScan(state_path.to_path_buf(), e)),
+ }
+
+ // Compare on cleaned paths so `/pool/1/repo`, `/pool/./1/repo` and
+ // `/pool/1/repo/` are recognized as the one worktree they are, and a
+ // normal read-modify-write does not append a duplicate entry for it.
+ let known: std::collections::HashSet = state
+ .worktrees
+ .iter()
+ .map(|wt| clean_path(Path::new(&wt.path)))
+ .collect();
+
+ let mut adopted = 0usize;
+ for (slot_name, wt_path) in scan_worktree_dirs(pool_dir, state_path)? {
+ if known.contains(&clean_path(&wt_path)) {
+ continue;
+ }
+ if let Some(wt) = recover_one_worktree(&slot_name, &wt_path) {
+ state.worktrees.push(wt);
+ adopted += 1;
+ }
+ }
+
+ // Go stays quiet here because its recovery entry is self-describing. We
+ // say so anyway: entries appearing in a pool whose state file does not
+ // exist is not a state a user can explain from the outside.
+ if adopted > 0 {
+ eprintln!(
+ "treehouse: WARNING: no usable state file at {}; adopted {adopted} worktree(s) found on disk as leased until verified - see `treehouse status`.",
+ state_path.display()
+ );
+ }
+ Ok(state)
+}
+
+/// Lists every `//` directory in the pool layout.
+///
+/// A `read_dir` failure stays the caller's error: it means the pool — or one
+/// slot — cannot be enumerated at all, so we do not know which healthy
+/// worktrees exist and must not hand back a partial pool. This is the line
+/// `docs/rust-port-plan.md` §2.3 draws with "fail closed if the scan can't
+/// complete", and it is read as being about the *scan*, not about every
+/// per-slot condition it walks over: an unreadable VCS marker inside one
+/// worktree hides nothing about the others, so it is handled per-slot by
+/// [`recover_one_worktree`] rather than bricking the whole pool.
+fn scan_worktree_dirs(
+ pool_dir: &Path,
+ state_path: &Path,
+) -> Result, StateError> {
+ let err = |e| StateError::RecoverScan(state_path.to_path_buf(), e);
+ let mut out = Vec::new();
+
+ let slots = std::fs::read_dir(pool_dir).map_err(err)?;
+ for slot in slots {
+ let slot = slot.map_err(err)?;
+ if !slot.file_type().map_err(err)?.is_dir() {
+ continue;
+ }
+ let slot_os = slot.file_name();
+ let slot_dir = pool_dir.join(&slot_os);
+ let nested = std::fs::read_dir(&slot_dir).map_err(err)?;
+ for entry in nested {
+ let entry = entry.map_err(err)?;
+ if !entry.file_type().map_err(err)?.is_dir() {
+ continue;
+ }
+ out.push((
+ slot_os.to_string_lossy().into_owned(),
+ slot_dir.join(entry.file_name()),
+ ));
+ }
+ }
+ Ok(out)
+}
+
+/// Resolves one on-disk worktree directory into a quarantine entry, or reports
+/// that the directory is not a pooled worktree at all (Go `recoverOneWorktree`).
+///
+/// This is the single place both recovery scans decide what an on-disk
+/// condition means, so the missing-state and corrupt-state paths can never
+/// classify the same slot differently.
+///
+/// Three outcomes:
+/// - no VCS marker: not a pooled worktree, skip it (`None`).
+/// - marker readable: recovered as a leased, quarantined entry.
+/// - marker present but unreadable (dangling or self-referential symlink, a
+/// loop, a permission failure): recovered too, as a leased entry carrying
+/// the read error in [`WorktreeEntry::recovery_error`]. It stays unusable
+/// exactly like every other recovered entry, but it is *visible* — acquire
+/// and prune skip it, `status` reports it as damaged with the reason, and a
+/// warning is printed so the failure is not silent. Failing the whole scan
+/// here instead would let one stray symlink inside one slot brick every
+/// command on the pool.
+fn recover_one_worktree(slot_name: &str, wt_path: &Path) -> Option {
+ match marker_backend(wt_path) {
+ Ok(true) => Some(quarantine_entry(slot_name, wt_path, "")),
+ Ok(false) => None,
+ Err(e) => {
+ eprintln!(
+ "treehouse: WARNING: cannot read the VCS marker for {} ({e}); recovered as leased and reported damaged - see `treehouse status`",
+ wt_path.display()
+ );
+ Some(quarantine_entry(slot_name, wt_path, &e.to_string()))
+ }
+ }
+}
+
+/// The conservative entry both recovery scans write for a worktree whose
+/// reservation state was lost (Go `quarantineEntry`). It is leased under
+/// [`RECOVERED_LEASE_HOLDER`], so acquire and prune skip it and destroy removes
+/// it only via an explicit single-target `--include-leased`.
+///
+/// `recovery_error` is empty for a worktree whose marker resolved normally and
+/// carries the read error for one whose marker exists but could not be read.
+/// There is deliberately no `expires_at`: a recovered entry is never
+/// automatically reclaimable, because recovery has no trustworthy inventory of
+/// what was in the worktree.
+fn quarantine_entry(slot_name: &str, wt_path: &Path, recovery_error: &str) -> WorktreeEntry {
+ let now = chrono::Utc::now();
+ WorktreeEntry {
+ name: slot_name.to_string(),
+ path: wt_path.to_string_lossy().into_owned(),
+ created_at: now,
+ leased: true,
+ lease_holder: RECOVERED_LEASE_HOLDER.to_string(),
+ leased_at: now,
+ recovery_error: recovery_error.to_string(),
+ ..WorktreeEntry::default()
+ }
+}
+
+/// Reports whether `/.git` marks a pooled worktree, or fails with the
+/// reason it could not be resolved (Go `WorktreeBackendNameChecked`).
+///
+/// The two-step lstat-then-stat is what makes a broken marker reportable.
+/// `symlink_metadata` does not follow the link, so a dangling or
+/// self-referential `.git` symlink — the exact shape a crashed `worktree add`
+/// or a hand-edited pool leaves behind — is correctly *present*; the following
+/// `metadata` then fails and the caller quarantines the slot as damaged. A
+/// plain `stat` would report `ENOENT` and silently skip the worktree, hiding a
+/// slot that is still occupying its name. Only a genuinely absent entry is
+/// `Ok(false)`.
+fn marker_backend(wt_path: &Path) -> std::io::Result {
+ let git = wt_path.join(".git");
+ match std::fs::symlink_metadata(&git) {
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
+ Err(e) => Err(e),
+ Ok(_) => {
+ std::fs::metadata(&git).map_err(|e| {
+ std::io::Error::new(
+ e.kind(),
+ format!("resolving .git marker in {}: {e}", wt_path.display()),
+ )
+ })?;
+ Ok(true)
+ }
+ }
+}
+
+/// Lexical path cleanup, the Rust equivalent of Go's `filepath.Clean`, for the
+/// single job recovery needs it for: deciding whether two spellings of a path
+/// name the same worktree. `Path::components` already drops `.` and collapses
+/// repeated separators, so only `..` is left to fold. Purely lexical — it never
+/// touches the filesystem and so never resolves symlinks, matching Go.
+fn clean_path(path: &Path) -> std::path::PathBuf {
+ use std::path::Component;
+ let mut out = std::path::PathBuf::new();
+ for c in path.components() {
+ match c {
+ Component::CurDir => {}
+ Component::ParentDir => {
+ if !out.pop() {
+ out.push("..");
+ }
+ }
+ other => out.push(other.as_os_str()),
+ }
+ }
+ if out.as_os_str().is_empty() {
+ std::path::PathBuf::from(".")
+ } else {
+ out
+ }
+}
+
/// Self-heals pool state in place (Go `healState`).
///
/// - Clears dead owner reservations: when `owner_pid != 0` but the owner
@@ -322,7 +573,11 @@ pub fn new_lease_id() -> String {
pub enum StateError {
#[error("failed to read state file {0}: {1}")]
Read(std::path::PathBuf, std::io::Error),
- #[error("state file {0} is corrupt or truncated and recovery could not scan: {1}")]
+ /// The pool directory could not be enumerated during a recovery scan.
+ /// Deliberately fatal: returning the worktrees found so far would be a
+ /// partial pool, and a pool missing entries reads as "these slots are
+ /// free" to `next_name`.
+ #[error("could not scan the pool directory while recovering state for {0}: {1}")]
RecoverScan(std::path::PathBuf, std::io::Error),
}
@@ -343,6 +598,7 @@ mod tests {
created_at: dt("2026-07-20T12:00:00Z"),
..WorktreeEntry::default()
}],
+ ..Default::default()
};
let json = serde_json::to_string_pretty(&s).unwrap();
// No lease / owner keys present; created_at keeps Go's Z form.
@@ -369,7 +625,14 @@ mod tests {
lease_holder: "automation-A".into(),
leased_at: dt("2026-08-14T12:34:56.123456789Z"),
expires_at: dt("2026-08-14T13:04:56.123456789Z"),
+ recovery_error: "resolving .git marker: too many levels of symbolic links".into(),
+ // A field this build does not model, to prove it survives the
+ // write half of the round trip along with every modelled one.
+ extra: [("base_branch".to_string(), serde_json::json!("main"))]
+ .into_iter()
+ .collect(),
}],
+ ..Default::default()
};
let json = serde_json::to_string_pretty(&s).unwrap();
let back: State = serde_json::from_str(&json).unwrap();
@@ -489,6 +752,7 @@ mod tests {
..WorktreeEntry::default()
},
],
+ ..Default::default()
};
let resolver = |pid: i32| -> Option { if pid == 100 { Some(111) } else { None } };
@@ -519,6 +783,7 @@ mod tests {
expires_at: dt("2026-08-14T12:30:00Z"),
..WorktreeEntry::default()
}],
+ ..Default::default()
};
heal_state(&mut state, |_| None);
let wt = &state.worktrees[0];
@@ -609,6 +874,474 @@ mod tests {
);
}
+ // ─── M-015: adopting worktrees the state file does not know about ───────
+
+ /// Creates `//` with a readable `.git` marker, the
+ /// on-disk shape of a real pooled worktree.
+ fn seed_worktree(pool: &Path, slot: &str) -> std::path::PathBuf {
+ let wt = pool.join(slot).join("myrepo");
+ std::fs::create_dir_all(&wt).unwrap();
+ std::fs::write(wt.join(".git"), "gitdir: ../../fake.git\n").unwrap();
+ wt
+ }
+
+ #[test]
+ fn missing_state_file_adopts_on_disk_worktrees_as_leased() {
+ let dir = tempfile::tempdir().unwrap();
+ seed_worktree(dir.path(), "1");
+ // No state file at all: a crash between `worktree add` and the state
+ // write, or a deleted state file. Either way the worktree is real and
+ // must not stay invisible to the pool.
+ let s = State::read_state(dir.path()).unwrap();
+ assert_eq!(s.worktrees.len(), 1, "orphan worktree must be adopted");
+ let e = &s.worktrees[0];
+ assert_eq!(e.name, "1");
+ assert!(e.leased, "adopted entry must be quarantined as leased");
+ assert_eq!(e.lease_holder, RECOVERED_LEASE_HOLDER);
+ assert_eq!(e.recovery_error, "");
+ assert!(e.path.ends_with("1/myrepo"), "got {}", e.path);
+ }
+
+ #[test]
+ fn valid_state_adopts_worktree_missing_from_it() {
+ // The successful-parse half: the state file is valid but the crash
+ // window left a second worktree unrecorded. Without the scan,
+ // `next_name` would compute 2 for a slot that already exists.
+ let dir = tempfile::tempdir().unwrap();
+ seed_worktree(dir.path(), "1");
+ let orphan = seed_worktree(dir.path(), "2");
+ let known = State {
+ worktrees: vec![WorktreeEntry {
+ name: "1".into(),
+ path: dir.path().join("1/myrepo").to_string_lossy().into_owned(),
+ created_at: dt("2026-08-14T12:00:00Z"),
+ ..WorktreeEntry::default()
+ }],
+ ..Default::default()
+ };
+ std::fs::write(
+ State::state_file_path(dir.path()),
+ serde_json::to_vec_pretty(&known).unwrap(),
+ )
+ .unwrap();
+
+ let s = State::read_state(dir.path()).unwrap();
+ assert_eq!(s.worktrees.len(), 2, "orphan must be adopted alongside");
+ let adopted = s
+ .worktrees
+ .iter()
+ .find(|w| w.name == "2")
+ .expect("slot 2 adopted");
+ assert!(adopted.leased);
+ assert_eq!(adopted.lease_holder, RECOVERED_LEASE_HOLDER);
+ assert_eq!(adopted.path, orphan.to_string_lossy());
+ // The recorded entry is untouched, not re-quarantined.
+ let kept = s.worktrees.iter().find(|w| w.name == "1").unwrap();
+ assert!(!kept.leased, "an entry already in state must be left alone");
+ }
+
+ #[test]
+ fn adoption_never_duplicates_an_entry_already_in_state() {
+ // The common path: every command re-reads state through this scan, so
+ // a worktree the file already names must not be adopted a second time
+ // on each read.
+ let dir = tempfile::tempdir().unwrap();
+ let wt = seed_worktree(dir.path(), "1");
+ let state = State {
+ worktrees: vec![WorktreeEntry {
+ name: "1".into(),
+ path: wt.to_string_lossy().into_owned(),
+ created_at: dt("2026-08-14T12:00:00Z"),
+ ..WorktreeEntry::default()
+ }],
+ ..Default::default()
+ };
+ std::fs::write(
+ State::state_file_path(dir.path()),
+ serde_json::to_vec_pretty(&state).unwrap(),
+ )
+ .unwrap();
+
+ let s = State::read_state(dir.path()).unwrap();
+ assert_eq!(s.worktrees.len(), 1, "no duplicate entry");
+ assert_eq!(s, state, "adoption must be a no-op here");
+ }
+
+ #[test]
+ fn adoption_matches_paths_lexically_not_byte_for_byte() {
+ // `/pool/./1/myrepo/` and `/pool/1/myrepo` are one worktree. Comparing
+ // the strings would append a phantom duplicate on every read, and the
+ // pool would grow without bound.
+ let dir = tempfile::tempdir().unwrap();
+ seed_worktree(dir.path(), "1");
+ let spelled = format!("{}/./1/myrepo/", dir.path().to_string_lossy());
+ let json = format!(
+ r#"{{"worktrees":[{{"name":"1","path":"{spelled}","created_at":"2026-08-14T12:00:00Z"}}]}}"#
+ );
+ std::fs::write(State::state_file_path(dir.path()), json).unwrap();
+
+ let s = State::read_state(dir.path()).unwrap();
+ assert_eq!(
+ s.worktrees.len(),
+ 1,
+ "a differently-spelled path is the same slot"
+ );
+ assert!(
+ !s.worktrees[0].leased,
+ "a recorded entry is not re-quarantined"
+ );
+ }
+
+ #[test]
+ fn dir_without_a_marker_is_not_adopted() {
+ // A slot directory that is not a worktree at all (a leftover mkdir, a
+ // half-finished create) must not become a phantom leased entry.
+ let dir = tempfile::tempdir().unwrap();
+ seed_worktree(dir.path(), "1");
+ std::fs::create_dir_all(dir.path().join("7/notaworktree")).unwrap();
+
+ let s = State::read_state(dir.path()).unwrap();
+ assert_eq!(s.worktrees.len(), 1);
+ assert_eq!(s.worktrees[0].name, "1");
+ }
+
+ #[test]
+ fn existing_but_empty_pool_dir_with_no_state_file_is_still_empty() {
+ // The pool directory exists (the lock file is enough to create it) but
+ // holds no worktrees. Adopting nothing here is what keeps a fresh pool
+ // from printing recovery noise.
+ let dir = tempfile::tempdir().unwrap();
+ let s = State::read_state(dir.path()).unwrap();
+ assert!(s.worktrees.is_empty());
+ }
+
+ #[test]
+ fn absent_pool_dir_is_a_fresh_empty_pool() {
+ // `read_state` runs on pools that were never created. Nothing to scan
+ // is not an error.
+ let dir = tempfile::tempdir().unwrap();
+ let s = State::read_state(&dir.path().join("never-created")).unwrap();
+ assert!(s.worktrees.is_empty());
+ }
+
+ // ─── M-022: one broken slot must not abort the whole scan ──────────────
+
+ #[cfg(unix)]
+ fn seed_broken_marker_worktree(pool: &Path, slot: &str, target: &str) -> std::path::PathBuf {
+ use std::os::unix::fs::symlink;
+ let wt = pool.join(slot).join("myrepo");
+ std::fs::create_dir_all(&wt).unwrap();
+ symlink(target, wt.join(".git")).unwrap();
+ wt
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn unreadable_marker_slot_does_not_abort_the_whole_pool() {
+ // The exact reproduction from the audit: one healthy slot plus one
+ // self-referential `.git` symlink. Before the fix the whole pool
+ // returned RecoverScan and every pool command was bricked.
+ let dir = tempfile::tempdir().unwrap();
+ seed_worktree(dir.path(), "1");
+ seed_broken_marker_worktree(dir.path(), "2", "self");
+
+ let s = State::read_state(dir.path()).unwrap();
+ assert_eq!(s.worktrees.len(), 2, "healthy slot must still be recovered");
+ let healthy = s.worktrees.iter().find(|w| w.name == "1").unwrap();
+ assert!(healthy.leased);
+ assert_eq!(healthy.recovery_error, "");
+ // The broken slot is quarantined as damaged, not dropped and not fatal.
+ let damaged = s.worktrees.iter().find(|w| w.name == "2").unwrap();
+ assert!(damaged.leased, "damaged slot stays unusable");
+ assert_eq!(damaged.lease_holder, RECOVERED_LEASE_HOLDER);
+ assert!(
+ !damaged.recovery_error.is_empty(),
+ "the reason must be recorded so status can report it"
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn dangling_marker_symlink_is_damaged_not_skipped() {
+ // `lstat` says present, `stat` cannot resolve it. Treating ENOENT as
+ // "no marker" would silently hide a slot that still owns its number.
+ let dir = tempfile::tempdir().unwrap();
+ seed_worktree(dir.path(), "1");
+ seed_broken_marker_worktree(dir.path(), "2", "../gone/gitdir");
+
+ let s = State::read_state(dir.path()).unwrap();
+ assert_eq!(s.worktrees.len(), 2);
+ let damaged = s.worktrees.iter().find(|w| w.name == "2").unwrap();
+ assert!(damaged.leased);
+ assert!(
+ !damaged.recovery_error.is_empty(),
+ "a dangling marker is a reported fault, not a missing worktree"
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn missing_and_corrupt_state_agree_on_a_broken_slot() {
+ // Go pins this with recovery_symmetry_test.go: the two recovery paths
+ // must never classify the same on-disk condition differently. They share
+ // recover_one_worktree, so they cannot drift — this test keeps it true.
+ let missing = tempfile::tempdir().unwrap();
+ seed_worktree(missing.path(), "1");
+ seed_broken_marker_worktree(missing.path(), "2", "self");
+ let by_missing = State::read_state(missing.path()).unwrap();
+
+ let corrupt = tempfile::tempdir().unwrap();
+ seed_worktree(corrupt.path(), "1");
+ seed_broken_marker_worktree(corrupt.path(), "2", "self");
+ std::fs::write(State::state_file_path(corrupt.path()), b"").unwrap();
+ let by_corrupt = State::read_state(corrupt.path()).unwrap();
+
+ assert_eq!(by_missing.worktrees.len(), by_corrupt.worktrees.len());
+ // The recorded reason embeds the absolute path, which differs between
+ // the two tempdirs; compare the parts that must actually agree.
+ let strip = |s: &str, dir: &Path| s.replace(&dir.to_string_lossy().to_string(), "");
+ for (a, b) in by_missing.worktrees.iter().zip(&by_corrupt.worktrees) {
+ assert_eq!(a.name, b.name);
+ assert_eq!(a.leased, b.leased);
+ assert_eq!(a.lease_holder, b.lease_holder);
+ assert_eq!(
+ strip(&a.recovery_error, missing.path()),
+ strip(&b.recovery_error, corrupt.path()),
+ "the two recovery paths must classify the same slot identically"
+ );
+ }
+ }
+
+ #[test]
+ fn unreadable_pool_slot_directory_still_fails_closed() {
+ // The other half of the line: a slot directory we cannot *enumerate*
+ // means we do not know which healthy worktrees exist, and a pool
+ // missing entries reads as "these slots are free" to next_name. That
+ // stays an error rather than becoming a partial pool.
+ let dir = tempfile::tempdir().unwrap();
+ seed_worktree(dir.path(), "1");
+ let blocked = dir.path().join("2");
+ std::fs::create_dir_all(&blocked).unwrap();
+ let mut perms = std::fs::metadata(&blocked).unwrap().permissions();
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ perms.set_mode(0o000);
+ }
+ #[cfg(not(unix))]
+ perms.set_readonly(true);
+ std::fs::set_permissions(&blocked, perms).unwrap();
+
+ let precondition_held = std::fs::read_dir(&blocked).is_err();
+ let scanned = State::read_state(dir.path());
+
+ // Restore afterwards so the tempdir can clean itself up.
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let _ = std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755));
+ }
+
+ if !precondition_held {
+ // Running as root, or a filesystem that ignores mode bits: the
+ // condition this test needs was never created.
+ return;
+ }
+ match scanned {
+ Err(StateError::RecoverScan(_, _)) => {}
+ other => panic!("expected a hard RecoverScan error, got {other:?}"),
+ }
+ }
+
+ // ─── Wire format: unknown Go fields must survive a read-modify-write ────
+
+ #[test]
+ fn unknown_go_fields_round_trip() {
+ // Every state write is a read-modify-write of the whole file. Without
+ // the flatten map, opening a pool written by a Go build (or a newer
+ // Rust build) and saving it would delete every field the port has not
+ // implemented yet — silent, permanent metadata loss.
+ let json = r#"{
+ "worktrees": [
+ {
+ "name": "1",
+ "path": "/pool/1/repo",
+ "created_at": "2026-08-14T12:00:00Z",
+ "base_branch": "main",
+ "seeded_paths": ["node_modules", ".env"],
+ "seed_inventory_known": true,
+ "seed_inventory_digest": "a1b2c3",
+ "seed_backend": "git",
+ "seed_auth_identity": "key-1",
+ "recovery_reason": "quarantined"
+ }
+ ]
+}"#;
+ let s: State = serde_json::from_str(json).unwrap();
+ let out = serde_json::to_string(&s).unwrap();
+ for (key, expected) in [
+ ("base_branch", "\"main\""),
+ ("seeded_paths", r#"["node_modules",".env"]"#),
+ ("seed_inventory_known", "true"),
+ ("seed_inventory_digest", "\"a1b2c3\""),
+ ("seed_backend", "\"git\""),
+ ("seed_auth_identity", "\"key-1\""),
+ ("recovery_reason", "\"quarantined\""),
+ ] {
+ assert!(
+ out.contains(&format!(r#""{key}":{expected}"#)),
+ "field {key} was dropped on write: {out}"
+ );
+ }
+ // And the values are reachable as data, not just as text.
+ assert_eq!(s.worktrees[0].extra["base_branch"], "main");
+ assert_eq!(
+ s.worktrees[0].extra["seeded_paths"][1].as_str(),
+ Some(".env")
+ );
+ }
+
+ #[test]
+ fn top_level_unknown_fields_round_trip_through_a_real_write() {
+ // The per-entry flatten map is not enough on its own: Go's `State`
+ // carries `version` at the TOP level (state.go:82, currently 4), and
+ // every write path is a read-modify-write of the whole file through
+ // `State`. Without a top-level catch-all, the very first Rust command
+ // to touch a Go-written pool (`get`, `return`, `prune`) would rewrite
+ // the file with no `version` at all — silent, permanent loss of the
+ // one field Go uses to decide whether it trusts the file.
+ //
+ // This walks the ACTUAL cycle (read_state -> write_state -> read_state)
+ // rather than serde in isolation, because serde round-tripping is not
+ // the failure mode: the failure is a field being absent from the
+ // struct, which only shows up once a real command rewrites the file.
+ let dir = tempfile::tempdir().unwrap();
+ let go_v3_state = r#"{
+ "version": 4,
+ "worktrees": [
+ {
+ "name": "1",
+ "path": "/pool/1/repo",
+ "created_at": "2026-08-14T12:00:00Z",
+ "base_branch": "main",
+ "seed_inventory_known": true,
+ "seed_inventory_digest": "a1b2c3",
+ "seed_backend": "git",
+ "seed_auth_identity": "key-1"
+ },
+ {
+ "name": "2",
+ "path": "/pool/2/repo",
+ "created_at": "2026-08-14T13:00:00Z",
+ "leased": true,
+ "lease_id": "9f2c1e04a7b3d5c8e6f10293a4b5c6d7",
+ "lease_holder": "agent-42",
+ "leased_at": "2026-08-14T13:00:01Z"
+ }
+ ],
+ "pool_note": "written by a newer treehouse"
+}"#;
+ std::fs::write(State::state_file_path(dir.path()), go_v3_state).unwrap();
+
+ // Read 1: the file as another build left it.
+ let read1 = State::read_state(dir.path()).unwrap();
+ assert_eq!(read1.worktrees.len(), 2);
+
+ // The write half: exactly what every mutating command does.
+ crate::state_file::write_state(dir.path(), &read1).unwrap();
+
+ // Assert on the BYTES ON DISK, not on the parsed struct. A struct that
+ // parsed cleanly is not evidence of anything: serde ignores unknown
+ // fields by default, so a `State` without a catch-all reads this file
+ // perfectly and then writes it back with `version` silently deleted.
+ // Only the bytes distinguish "preserved" from "quietly dropped".
+ let raw = std::fs::read_to_string(State::state_file_path(dir.path())).unwrap();
+ for key in [
+ "version",
+ "pool_note",
+ "base_branch",
+ "seed_inventory_known",
+ "seed_inventory_digest",
+ "seed_backend",
+ "seed_auth_identity",
+ ] {
+ assert!(
+ raw.contains(&format!("\"{key}\"")),
+ "field {key} was dropped by the write: {raw}"
+ );
+ }
+ assert!(
+ raw.contains(r#""version": 4"#),
+ "the version VALUE must survive, not just the key: {raw}"
+ );
+
+ // Read 2: the second cycle is lossless too, so a file that has already
+ // been through one Rust command stays stable.
+ let read2 = State::read_state(dir.path()).unwrap();
+ assert_eq!(read2, read1, "read -> write -> read must be lossless");
+ assert_eq!(read2.worktrees[0].extra["base_branch"], "main");
+ // Modelled fields are untouched by the whole exercise.
+ assert!(read2.worktrees[1].leased);
+ assert_eq!(read2.worktrees[1].lease_holder, "agent-42");
+ }
+
+ #[test]
+ fn state_extra_stays_empty_off_the_wire_when_nothing_is_unknown() {
+ // The mirror of the round trip: a Rust-written file must not grow a
+ // `version` (or any other key) that Go would never emit, so two
+ // implementations agree byte-for-byte on a pool neither has annotated.
+ let json = serde_json::to_string(&State::default()).unwrap();
+ assert_eq!(json, r#"{"worktrees":[]}"#, "unexpected wire shape: {json}");
+ }
+
+ #[test]
+ fn known_and_unknown_fields_survive_together() {
+ let mut wt = WorktreeEntry {
+ name: "3".into(),
+ path: "/pool/3/repo".into(),
+ created_at: dt("2026-08-14T12:00:00Z"),
+ leased: true,
+ recovery_error: "resolving .git marker: permission denied".into(),
+ ..WorktreeEntry::default()
+ };
+ wt.extra
+ .insert("base_branch".into(), serde_json::json!("release"));
+
+ let json = serde_json::to_string(&wt).unwrap();
+ let back: WorktreeEntry = serde_json::from_str(&json).unwrap();
+ assert_eq!(back, wt, "round trip must be lossless");
+ assert!(json.contains(r#""recovery_error":"#), "{json}");
+ assert!(json.contains(r#""base_branch":"release""#), "{json}");
+ }
+
+ #[test]
+ fn empty_extra_and_recovery_error_stay_off_the_wire() {
+ // Go omits both when empty; a Rust-written state file must not grow
+ // keys the baseline never emits.
+ let json = serde_json::to_string(&WorktreeEntry {
+ name: "1".into(),
+ path: "/p".into(),
+ created_at: dt("2026-08-14T12:00:00Z"),
+ ..WorktreeEntry::default()
+ })
+ .unwrap();
+ assert!(!json.contains("recovery_error"), "{json}");
+ assert!(!json.contains("base_branch"), "{json}");
+ }
+
+ #[test]
+ fn recovery_error_serializes_omitzero_like_go() {
+ let json = serde_json::to_string(&WorktreeEntry {
+ name: "1".into(),
+ path: "/p".into(),
+ created_at: dt("2026-08-14T12:00:00Z"),
+ recovery_error: "loop".into(),
+ ..WorktreeEntry::default()
+ })
+ .unwrap();
+ assert!(json.contains(r#""recovery_error":"loop""#), "{json}");
+ }
+
// ─── _with_env tests ────────────────────────────────────────────────────
#[test]
@@ -630,6 +1363,7 @@ mod tests {
created_at: dt("2026-07-20T12:00:00Z"),
..Default::default()
}],
+ ..Default::default()
};
// Seed state file via env
let json = serde_json::to_string_pretty(&state).unwrap();
diff --git a/crates/treehouse-core/src/state_file.rs b/crates/treehouse-core/src/state_file.rs
index b91b2b3..8cb6df4 100644
--- a/crates/treehouse-core/src/state_file.rs
+++ b/crates/treehouse-core/src/state_file.rs
@@ -5,10 +5,14 @@
//! power loss) must never leave a truncated or empty live file — the old
//! contents survive until the rename/replace lands.
//!
-//! `tempfile::NamedTempFile::persist()` is used instead of `std::fs::rename`
-//! because rename fails on Windows when the destination exists; persist uses
-//! `rename(2)` on unix and `MoveFileExW + MOVEFILE_REPLACE_EXISTING` on
-//! Windows. Note `persist` does NOT fsync — we call `sync_all()` explicitly.
+//! On unix `tempfile::NamedTempFile::persist()` is used instead of
+//! `std::fs::rename` because rename fails on Windows when the destination
+//! exists; persist uses `rename(2)` on unix. Note `persist` does NOT fsync —
+//! we call `sync_all()` explicitly, and we fsync the parent directory
+//! afterwards because rename(2) alone is not durable.
+//!
+//! Windows does NOT use `persist`: Go asks for a write-through commit, and
+//! persist's `MoveFileExW` cannot ask for one. See [`commit_windows`].
use std::io::Write;
use std::path::Path;
@@ -20,17 +24,26 @@ use crate::env::TreehouseEnv;
///
/// 1. Create a temp file in the same directory.
/// 2. Write + fsync it.
-/// 3. Persist over the target (atomic rename / replace).
-/// 4. Preserve the existing target's file mode; new files get `perm`.
+/// 3. Commit over the target with the platform's write-through primitive —
+/// `rename(2)` on unix, `ReplaceFileW`/`MoveFileExW` on Windows.
+/// 4. On unix, fsync the parent directory. Windows has no directory handle to
+/// flush, so the write-through flag in step 3 is its equivalent.
+/// 5. Preserve the existing target's file mode; new files get `perm`.
///
-/// On Windows the parent-directory fsync is skipped (Go's `syncDirectory` is
-/// POSIX-only) — accepted for P0 parity, documented as weaker durability.
+/// Steps 3 and 4 are separate durability steps, not one. `rename(2)` is atomic
+/// for *readers* the instant it returns, but the directory entry it creates
+/// sits in the page cache until that directory is fsynced. Skip step 4 and a
+/// power cut rolls the pool back to the PREVIOUS — still perfectly valid —
+/// state file, so `recover_corrupt_state` never fires and a live lease or owner
+/// reservation is silently forgotten while a process still holds the worktree.
pub fn atomic_write_file(path: &Path, data: &[u8], perm: u32) -> std::io::Result<()> {
let dir = path
.parent()
.ok_or_else(|| std::io::Error::other(format!("no parent dir for {}", path.display())))?;
// Preserve the existing target's mode if it exists; otherwise use `perm`.
+ // Must be read BEFORE the commit: afterwards the name resolves to the new
+ // file, so this would report the mode we just wrote.
let existing_mode = target_mode(path);
let mut tmp = tempfile::Builder::new()
@@ -43,11 +56,113 @@ pub fn atomic_write_file(path: &Path, data: &[u8], perm: u32) -> std::io::Result
tmp.as_file().sync_all()?;
apply_mode(tmp.as_file(), existing_mode.unwrap_or(perm))?;
- // Atomic commit: unix rename / Windows MoveFileExW+REPLACE.
- tmp.persist(path).map_err(|e| e.error)?;
+ #[cfg(not(windows))]
+ {
+ // Atomic commit: rename(2).
+ tmp.persist(path).map_err(|e| e.error)?;
+ }
+
+ #[cfg(unix)]
+ // Make the rename itself durable. Go has done this since v2.0.1
+ // (state_file_posix.go:17-24); skipping it silently downgrades the whole
+ // pool to "loses the last lease on power loss".
+ sync_parent_dir(path)?;
+
+ #[cfg(windows)]
+ {
+ // into_temp_path closes the handle before the commit, matching Go's
+ // tmp.Close() before commitStateFile; keep() disarms tempfile's
+ // auto-delete so the path outlives the call.
+ let tmp_path = tmp.into_temp_path();
+ let tmp_path = tmp_path.keep().map_err(|e| e.error)?;
+ if let Err(e) = commit_windows(&tmp_path, path, target_exists(path)) {
+ // keep() already cancelled the drop-time cleanup Go's deferred
+ // os.Remove would have done, so a failed commit would strand the
+ // temp file. Clean it up here instead.
+ let _ = std::fs::remove_file(&tmp_path);
+ return Err(e);
+ }
+ }
+
+ Ok(())
+}
+
+/// fsyncs the directory holding `path`. Mirrors Go's `syncDirectory`
+/// (`internal/pool/state_file_posix.go:17-24`), the POSIX half of its
+/// `commitStateFile`.
+///
+/// The error propagates instead of being swallowed: a state file we cannot make
+/// durable is not a state file we can honestly report as written. That is also
+/// why this is not `let _ = ...` — a pool root on a filesystem that rejects
+/// directory fsync should fail loudly at the write, not at some later recovery.
+#[cfg(unix)]
+fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
+ let dir = path
+ .parent()
+ .ok_or_else(|| std::io::Error::other(format!("no parent dir for {}", path.display())))?;
+ // A directory is opened read-only — the only mode that yields an fsync-able
+ // descriptor for it. This is the call Go's syncDirectory makes.
+ std::fs::File::open(dir)?.sync_all()
+}
+
+/// Commits `tmp_path` over `path` with the write-through guarantee Go gets from
+/// `commitStateFile` (`internal/pool/state_file_windows.go:32-58`).
+///
+/// `tempfile`'s `persist` calls `MoveFileExW` WITHOUT `MOVEFILE_WRITE_THROUGH`,
+/// which only queues the move and returns; a power cut can still undo it. We
+/// therefore issue the syscall directly rather than going through persist.
+///
+/// Go branches on whether the target exists because `ReplaceFileW` preserves
+/// the replaced file's ACL (and therefore its permissions) where `MoveFileEx`
+/// would install the replacement's own. `target_exists` is the same flag Go's
+/// `replacementFileMode` hands to `commitStateFile`.
+#[cfg(windows)]
+fn commit_windows(tmp_path: &Path, path: &Path, target_exists: bool) -> std::io::Result<()> {
+ use std::os::windows::ffi::OsStrExt;
+ use windows_sys::Win32::Storage::FileSystem::{
+ MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, REPLACEFILE_WRITE_THROUGH,
+ ReplaceFileW,
+ };
+
+ let wide = |p: &Path| {
+ p.as_os_str()
+ .encode_wide()
+ .chain(Some(0))
+ .collect::>()
+ };
+ let (from, to) = (wide(tmp_path), wide(path));
+
+ let ok = unsafe {
+ if target_exists {
+ ReplaceFileW(
+ to.as_ptr(),
+ from.as_ptr(),
+ std::ptr::null(),
+ REPLACEFILE_WRITE_THROUGH,
+ std::ptr::null(),
+ std::ptr::null(),
+ )
+ } else {
+ MoveFileExW(
+ from.as_ptr(),
+ to.as_ptr(),
+ MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
+ )
+ }
+ };
+ if ok == 0 {
+ return Err(std::io::Error::last_os_error());
+ }
Ok(())
}
+/// Whether the target already exists. Windows-only because only the Windows
+/// commit primitive branches on it; unix does not need the distinction.
+#[cfg(windows)]
+fn target_exists(path: &Path) -> bool {
+ std::fs::metadata(path).is_ok()
+}
+
/// The mode of the existing target file, if any. On unix this is the real
/// permission bits; on Windows permissions are mostly no-ops, so `None` means
/// "use the default". Mirrors Go's `replacementFileMode`.
@@ -127,6 +242,7 @@ mod tests {
.with_timezone(&Utc),
..WorktreeEntry::default()
}],
+ ..Default::default()
}
}
@@ -202,6 +318,59 @@ mod tests {
}
}
+ // ─── parent-directory fsync (M-010) ──────────────────────────────────────
+ //
+ // Note on coverage: whether the fsync(2) itself reached the platter is not
+ // observable from inside the process — proving it needs a crash or syscall
+ // interception. These tests pin what IS observable: that the sync runs and
+ // succeeds on a real directory, and that a failure is reported instead of
+ // swallowed (which would let `atomic_write_file` claim a write it could not
+ // make durable).
+
+ #[cfg(unix)]
+ #[test]
+ fn sync_parent_dir_succeeds_on_existing_dir() {
+ let dir = tempfile::tempdir().unwrap();
+ // A path that does not exist yet is fine: the parent directory is what
+ // gets synced, not the file.
+ let path = dir.path().join("not-created-yet.json");
+ sync_parent_dir(&path).expect("directory fsync must succeed on a real dir");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn sync_parent_dir_propagates_error_instead_of_swallowing_it() {
+ let dir = tempfile::tempdir().unwrap();
+ let missing = dir.path().join("gone");
+ let err = sync_parent_dir(&missing.join("state.json"))
+ .expect_err("a missing parent directory must not be reported as durable");
+ assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got {err}");
+ }
+
+ #[test]
+ fn repeated_commits_leave_no_temp_files_behind() {
+ // Every commit runs the platform's commit primitive plus (on unix) the
+ // parent-directory fsync. Re-commit over an existing target repeatedly:
+ // that is the Windows ReplaceFileW branch and the unix "target already
+ // exists" branch, and it must stay leak-free because the Windows path
+ // disarms tempfile's auto-delete to issue its own syscall.
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("treehouse-state.json");
+ for i in 0..5u32 {
+ atomic_write_file(&path, format!("{{\"n\":{i}}}").as_bytes(), 0o644).unwrap();
+ assert_eq!(
+ std::fs::read(&path).unwrap(),
+ format!("{{\"n\":{i}}}").as_bytes()
+ );
+ }
+ let leftovers: Vec<_> = std::fs::read_dir(dir.path())
+ .unwrap()
+ .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
+ .filter(|n| n != "treehouse-state.json")
+ .collect();
+ assert!(leftovers.is_empty(), "leftover files: {leftovers:?}");
+ }
+
// ─── _with_env tests ────────────────────────────────────────────────────
#[test]
diff --git a/crates/treehouse-core/src/updater.rs b/crates/treehouse-core/src/updater.rs
index fc58bd9..ae3b76f 100644
--- a/crates/treehouse-core/src/updater.rs
+++ b/crates/treehouse-core/src/updater.rs
@@ -6,11 +6,30 @@
//! - `update` downloads the release asset, verifies sha256, and atomically
//! replaces the executable.
//! - HTTPS is enforced on all download URLs (configurable in tests).
+//!
+//! The apply half ([`apply`]) is the part that actually changes bytes on disk:
+//! download → verify sha256 against the release sidecar → extract → de-quarantine
+//! → temp-file-in-target-dir + rename. Every step returns `Err`; nothing here
+//! can report success for an update that did not happen.
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
use crate::env::TreehouseEnv;
+/// Reads the mode bits out of a `Permissions` value. On unix that is the whole
+/// struct; on Windows there are no mode bits, so the original value is passed
+/// straight back to `set_permissions` and this is the identity.
+#[cfg(unix)]
+fn mode_of(p: std::fs::Permissions) -> std::fs::Permissions {
+ use std::os::unix::fs::PermissionsExt;
+ std::fs::Permissions::from_mode(p.mode())
+}
+
+#[cfg(not(unix))]
+fn mode_of(p: std::fs::Permissions) -> std::fs::Permissions {
+ p
+}
+
/// The default GitHub API URL for the latest release. Overridable in tests.
pub const DEFAULT_GITHUB_API_URL: &str =
"https://api.github.com/repos/quangdang46/treehouse_rust/releases/latest";
@@ -126,18 +145,12 @@ pub fn read_cache() -> Option {
/// Fetches the latest release version from the GitHub API (or the injected URL).
/// Returns None on network/parse failure (best-effort background check).
+///
+/// This is the *cache-write* half of the check. The download half is
+/// [`check_latest_result`] + [`apply`]; use those for `treehouse update`, which
+/// must not report success without having replaced the binary.
pub fn check_latest(github_api_url: &str, enforce_https: bool) -> Option {
- if enforce_https && !github_api_url.starts_with("https://") {
- return None;
- }
- let resp = std::process::Command::new("curl")
- .args(["-fsSL", github_api_url])
- .output()
- .ok()?;
- if !resp.status.success() {
- return None;
- }
- let body = String::from_utf8_lossy(&resp.stdout);
+ let body = curl_capture(github_api_url, enforce_https, MAX_API_RESPONSE_SIZE).ok()?;
let v: serde_json::Value = serde_json::from_str(&body).ok()?;
v.get("tag_name")?.as_str().map(|s| s.to_string())
}
@@ -237,6 +250,782 @@ pub fn update_available_with_env(current: &str, env: &dyn TreehouseEnv) -> bool
}
}
+// ─── Apply pipeline ───────────────────────────────────────────────────────────
+//
+// Go's `internal/updater` does this in `Apply` (updater.go:319). The port keeps
+// the same five steps and, more importantly, the same failure discipline: a
+// step that does not fully succeed returns Err and the binary on disk is left
+// exactly as it was. The whole reason M-027 was filed is that the old CLI
+// printed "Successfully updated" for an update that never happened, so nothing
+// below is allowed to return Ok on a partial result.
+
+// ─── Limits ────────────────────────────────────────────────────────────────────
+
+/// Ceiling on a downloaded release archive (Go `maxDownloadSize`). A release
+/// asset is tens of MB, so 100 MB is generous; past it we are either looking at
+/// a broken release or at something trying to fill the user's disk.
+const MAX_DOWNLOAD_SIZE: u64 = 100 << 20;
+
+/// Ceiling on the *extracted* binary, kept separate from the archive cap
+/// because a small archive can expand without bound — this is the number that
+/// bounds what actually gets written toward the target directory.
+const MAX_BINARY_SIZE: u64 = 100 << 20;
+
+/// Ceiling on the GitHub API response (Go `maxAPIResponseSize`).
+const MAX_API_RESPONSE_SIZE: u64 = 5 << 20;
+
+/// Ceiling on the checksum sidecar: one 64-hex line per release asset, a few
+/// KB at most. Anything bigger is not a checksum file.
+const MAX_CHECKSUM_SIZE: u64 = 1 << 20;
+
+/// Curl timeout for the API and checksum fetches (Go `httpTimeout` = 30s).
+const FETCH_TIMEOUT_SECS: &str = "30";
+
+/// Curl timeout for the asset download. Go allows 5 minutes here for the same
+/// reason: this is the one big transfer.
+const DOWNLOAD_TIMEOUT_SECS: &str = "300";
+
+/// Prefix every release asset starts with (`.github/workflows/release.yml`).
+const ASSET_PREFIX: &str = "treehouse-v";
+
+/// Per-asset checksum sidecar published next to every release asset
+/// (release.yml:94,99).
+const CHECKSUM_SUFFIX: &str = ".sha256";
+
+/// Go's aggregate checksum manifest (`updater.go:29`). The Rust release
+/// workflow emits per-asset sidecars instead, but both are the same
+/// `sha256 filename` line format, so [`checksum_for`] reads either.
+const AGGREGATE_CHECKSUM_FILE: &str = "checksums.txt";
+
+/// The archive extension this platform's asset uses — zip on Windows, tar.gz
+/// everywhere else (Go `extractBinary`, updater.go:573-578).
+const fn archive_ext() -> &'static str {
+ if cfg!(windows) { "zip" } else { "tar.gz" }
+}
+
+/// The binary's file name inside the archive, per platform (Go `extractTarGz`
+/// and `extractZip` hard-code these).
+const fn binary_name() -> &'static str {
+ if cfg!(windows) {
+ "treehouse.exe"
+ } else {
+ "treehouse"
+ }
+}
+
+// ─── Errors ───────────────────────────────────────────────────────────────────
+
+/// Why an update did not happen. Every variant is a path where the binary on
+/// disk is unchanged; there is deliberately no variant meaning "partially
+/// applied", because no code path produces one.
+#[derive(Debug, thiserror::Error)]
+pub enum UpdateError {
+ #[error("no download URL for {os}/{arch}")]
+ NoDownloadUrl {
+ os: &'static str,
+ arch: &'static str,
+ },
+
+ /// HTTPS rejection. The caller prefixes this with the URL's role
+ /// ("download URL: " / "checksum URL: ") so the reason reads on its own.
+ #[error("{0}")]
+ InsecureUrl(String),
+
+ #[error("fetching latest release: {0}")]
+ Fetch(String),
+
+ #[error("downloading update: {0}")]
+ Download(String),
+
+ #[error("checksum verification failed: {0}")]
+ Checksum(String),
+
+ #[error("extracting update: {0}")]
+ Extract(String),
+
+ #[error("resolving current executable: {0}")]
+ ResolveExecutable(String),
+
+ #[error("replacing binary: {0}")]
+ Replace(String),
+}
+
+// ─── Release metadata ─────────────────────────────────────────────────────────
+
+/// The outcome of a version check: what the latest release is, and — when one
+/// exists for this platform — where to fetch it and how to verify it.
+#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct CheckResult {
+ pub current_version: String,
+ pub latest_version: String,
+ pub update_available: bool,
+ /// The release archive for this OS/arch, if the release ships one.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub asset_name: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub download_url: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub checksum_url: Option,
+}
+
+/// What [`apply`] actually did. The CLI should print its success line from this
+/// and not before, so "updated X -> Y" can only be said about a binary that was
+/// really replaced.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Applied {
+ pub from_version: String,
+ pub to_version: String,
+ /// The path that was replaced, after symlink resolution.
+ pub replaced_path: PathBuf,
+}
+
+/// The subset of the GitHub latest-release response we consume
+/// (Go `githubRelease`).
+#[derive(Debug, serde::Deserialize)]
+struct GithubRelease {
+ #[serde(rename = "tag_name", default)]
+ tag_name: String,
+ #[serde(default)]
+ assets: Vec,
+}
+
+#[derive(Debug, serde::Deserialize)]
+struct GithubAsset {
+ name: String,
+ #[serde(rename = "browser_download_url")]
+ browser_download_url: String,
+}
+
+// ─── Asset selection ──────────────────────────────────────────────────────────
+
+/// Whether `name` is this platform's release asset, regardless of version.
+///
+/// The suffix vocabulary is the release matrix in `.github/workflows/release.yml`
+/// (`linux-x86_64`, `macos-aarch64`, `windows-x86_64`, …), NOT Go's
+/// GOOS/GOARCH spelling — the Rust workflow publishes `macos-aarch64`, so
+/// copying Go's matcher verbatim would find nothing on Apple Silicon.
+fn matches_asset(name: &str, os: &str, arch: &str, ext: &str) -> bool {
+ name.starts_with(ASSET_PREFIX) && name.ends_with(&format!("-{os}-{arch}.{ext}"))
+}
+
+/// [`matches_asset`] bound to the running platform. Used by both the release
+/// lookup and the checksum sidecar lookup, so the two can never disagree about
+/// which file is "the current platform's asset".
+pub fn matches_current_platform_asset(name: &str) -> bool {
+ matches_asset(
+ name,
+ std::env::consts::OS,
+ std::env::consts::ARCH,
+ archive_ext(),
+ )
+}
+
+/// The checksum file that covers `archive_name`: this workflow's per-asset
+/// sidecar, or Go's aggregate manifest if a release ships one instead.
+fn is_checksum_for(archive_name: &str, candidate: &str) -> bool {
+ candidate == format!("{archive_name}{CHECKSUM_SUFFIX}") || candidate == AGGREGATE_CHECKSUM_FILE
+}
+
+// ─── Version check with assets ────────────────────────────────────────────────
+
+/// Checks for the latest release and resolves this platform's asset URLs.
+///
+/// Unlike [`check_latest`] this surfaces failures instead of swallowing them:
+/// `treehouse update` must be able to tell the user the network is down rather
+/// than exit 0. The update-check cache is *not* written here — the caller
+/// decides when (Go writes it inside `CheckLatest`, updater.go:89-94).
+pub fn check_latest_result(
+ github_api_url: &str,
+ current_version: &str,
+ enforce_https: bool,
+) -> Result {
+ let body = curl_capture(github_api_url, enforce_https, MAX_API_RESPONSE_SIZE)?;
+ let release: GithubRelease = serde_json::from_str(&body)
+ .map_err(|e| UpdateError::Fetch(format!("decoding release: {e}")))?;
+
+ let archive = release
+ .assets
+ .iter()
+ .find(|a| matches_current_platform_asset(&a.name));
+
+ let checksum_url = archive.and_then(|archive| {
+ release
+ .assets
+ .iter()
+ .find(|c| is_checksum_for(&archive.name, &c.name))
+ .map(|c| c.browser_download_url.clone())
+ });
+
+ let update_available = match (
+ Version::parse(&release.tag_name),
+ Version::parse(current_version),
+ ) {
+ (Some(latest), Some(cur)) => latest > cur,
+ // Unparseable on either side: refuse to claim an update is available.
+ // Guessing "yes" would send the CLI into a download it cannot verify.
+ _ => false,
+ };
+
+ Ok(CheckResult {
+ current_version: current_version.to_string(),
+ latest_version: release.tag_name,
+ update_available,
+ asset_name: archive.map(|a| a.name.clone()),
+ download_url: archive.map(|a| a.browser_download_url.clone()),
+ checksum_url,
+ })
+}
+
+/// [`check_latest_result`] plus the cache write, matching Go's `CheckLatest`
+/// (which caches inside the check). For callers that want Go's exact shape.
+pub fn check_and_cache(
+ github_api_url: &str,
+ current_version: &str,
+ enforce_https: bool,
+) -> Result {
+ let result = check_latest_result(github_api_url, current_version, enforce_https)?;
+ write_cache(&result.latest_version);
+ Ok(result)
+}
+
+// ─── Apply ────────────────────────────────────────────────────────────────────
+
+/// Downloads, verifies, extracts and installs the release described by
+/// `result`, replacing the running executable.
+///
+/// `enforce_https` is threaded in rather than read from a global so tests can
+/// drive the whole pipeline off `file://` fixtures; production passes `true`.
+///
+/// # CLI usage
+///
+/// ```text
+/// let result = updater::check_latest_result(DEFAULT_GITHUB_API_URL, VERSION, true)?;
+/// if !result.update_available { /* "up to date" */ }
+/// let applied = updater::apply(&result, true)?; // prints "X -> Y" from `applied`
+/// ```
+///
+/// Resolves `std::env::current_exe()`. Prefer [`apply_into`] when the target
+/// is not the running binary (tests; packagers staging an install prefix).
+pub fn apply(result: &CheckResult, enforce_https: bool) -> Result {
+ let exe = std::env::current_exe().map_err(|e| UpdateError::ResolveExecutable(e.to_string()))?;
+ // EvalSymlinks equivalent. Skipping it would replace the resolved binary
+ // while the user (or their package manager) is looking at a symlink, so
+ // the next `treehouse` invocation follows the link back to the old file.
+ let target = std::fs::canonicalize(&exe)
+ .map_err(|e| UpdateError::ResolveExecutable(format!("resolving symlinks: {e}")))?;
+ apply_into(result, enforce_https, &target)
+}
+
+/// [`apply`] against an explicit target path.
+///
+/// Split out so the full pipeline can be tested without touching the test
+/// binary itself — replacing `current_exe()` mid-suite would corrupt every
+/// other test in the process.
+pub fn apply_into(
+ result: &CheckResult,
+ enforce_https: bool,
+ target: &Path,
+) -> Result {
+ let Some(url) = result.download_url.as_deref().filter(|u| !u.is_empty()) else {
+ return Err(UpdateError::NoDownloadUrl {
+ os: std::env::consts::OS,
+ arch: std::env::consts::ARCH,
+ });
+ };
+ require_https(url, enforce_https)
+ .map_err(|e| UpdateError::InsecureUrl(format!("download URL: {e}")))?;
+
+ // An unverified binary is not an update, it is arbitrary code execution
+ // with a version string attached. Refuse before downloading anything.
+ let Some(checksum_url) = result.checksum_url.as_deref().filter(|u| !u.is_empty()) else {
+ return Err(UpdateError::Checksum(
+ "no checksums file in release assets".to_string(),
+ ));
+ };
+ require_https(checksum_url, enforce_https)
+ .map_err(|e| UpdateError::InsecureUrl(format!("checksum URL: {e}")))?;
+
+ let archive = download_to_temp(url, MAX_DOWNLOAD_SIZE)?;
+ let sidecar = curl_capture(checksum_url, enforce_https, MAX_CHECKSUM_SIZE)
+ .map_err(|e| UpdateError::Checksum(format!("downloading checksums: {e}")))?;
+ verify_checksum(
+ archive.path(),
+ &sidecar,
+ result.asset_name.as_deref().unwrap_or_default(),
+ )?;
+
+ let new_binary = extract_binary(archive.path())?;
+ remove_quarantine(new_binary.path());
+
+ atomic_replace(target, new_binary.path())?;
+
+ Ok(Applied {
+ from_version: result.current_version.clone(),
+ to_version: result.latest_version.clone(),
+ replaced_path: target.to_path_buf(),
+ })
+}
+
+// ─── HTTP ─────────────────────────────────────────────────────────────────────
+
+/// Rejects a non-HTTPS URL unless enforcement is off. Go `requireHTTPS`
+/// (updater.go:442-453).
+fn require_https(url: &str, enforce_https: bool) -> Result<(), UpdateError> {
+ if !enforce_https {
+ return Ok(());
+ }
+ if url.is_empty() {
+ return Err(UpdateError::InsecureUrl("URL is empty".to_string()));
+ }
+ if !url.starts_with("https://") {
+ return Err(UpdateError::InsecureUrl(format!(
+ "refusing non-HTTPS URL: {url}"
+ )));
+ }
+ Ok(())
+}
+
+/// Fetches a small text resource (release metadata, checksum sidecar) with curl.
+///
+/// curl rather than an HTTP client crate because this crate already shells out
+/// to curl for the version check and the install path promises curl; a second
+/// HTTP stack would be a second dependency tree to keep current for the same
+/// job. `--max-filesize` bounds the declared-length case and the post-read
+/// length check bounds the rest.
+fn curl_capture(url: &str, enforce_https: bool, max_bytes: u64) -> Result {
+ require_https(url, enforce_https)?;
+ let out = std::process::Command::new("curl")
+ .args(["-fsSL", "--max-time", FETCH_TIMEOUT_SECS])
+ .args(["--max-filesize", &max_bytes.to_string()])
+ .arg(url)
+ .output()
+ .map_err(|e| UpdateError::Fetch(format!("invoking curl: {e}")))?;
+ if !out.status.success() {
+ return Err(UpdateError::Fetch(format!(
+ "{url}: curl {} {}",
+ out.status,
+ String::from_utf8_lossy(&out.stderr).trim()
+ )));
+ }
+ if out.stdout.len() as u64 > max_bytes {
+ return Err(UpdateError::Fetch(format!(
+ "{url}: response exceeds maximum size of {max_bytes} bytes"
+ )));
+ }
+ Ok(String::from_utf8_lossy(&out.stdout).into_owned())
+}
+
+/// Rejects a transfer that landed larger than its ceiling.
+///
+/// This is the backstop behind curl's `--max-filesize`, which only fires when
+/// the response declares a Content-Length. A chunked response declares nothing,
+/// so the bytes are counted here instead (Go does the same with
+/// `io.LimitReader` + a post-copy length check, updater.go:560-568).
+fn check_download_size(len: u64, max_bytes: u64) -> Result<(), UpdateError> {
+ if len > max_bytes {
+ return Err(UpdateError::Download(format!(
+ "download exceeds maximum size of {max_bytes} bytes"
+ )));
+ }
+ Ok(())
+}
+
+/// Streams `url` into a fresh temp file, refusing anything over `max_bytes`
+/// (Go `downloadToTemp`, updater.go:542-571).
+///
+/// stdout goes straight to the file handle rather than through `.output()`:
+/// `.output()` would buffer the whole asset in memory, and the cap exists
+/// precisely because the size is not trusted.
+fn download_to_temp(url: &str, max_bytes: u64) -> Result {
+ let tmp = tempfile::Builder::new()
+ .prefix("treehouse-update-")
+ .tempfile()
+ .map_err(|e| UpdateError::Download(format!("creating temp file: {e}")))?;
+
+ let out = std::process::Command::new("curl")
+ .args(["-fsSL", "--max-time", DOWNLOAD_TIMEOUT_SECS])
+ // Aborts early on a declared oversize body.
+ .args(["--max-filesize", &max_bytes.to_string()])
+ .arg("-o")
+ .arg(tmp.path())
+ .arg(url)
+ .stderr(std::process::Stdio::piped())
+ .output()
+ .map_err(|e| UpdateError::Download(format!("invoking curl: {e}")))?;
+ if !out.status.success() {
+ return Err(UpdateError::Download(format!(
+ "{url}: curl {} {}",
+ out.status,
+ String::from_utf8_lossy(&out.stderr).trim()
+ )));
+ }
+
+ let len = std::fs::metadata(tmp.path())
+ .map_err(|e| UpdateError::Download(format!("measuring download: {e}")))?
+ .len();
+ check_download_size(len, max_bytes)?;
+ Ok(tmp)
+}
+
+// ─── Checksum ─────────────────────────────────────────────────────────────────
+
+/// Extracts the expected sha256 for `asset_name` out of a checksum sidecar.
+///
+/// Sidecar lines are `sha256 filename`, exactly as `sha256sum -c` and the
+/// release workflow write them. An exact filename match wins; failing that we
+/// fall back to the platform match Go uses (updater.go:502-506) so an aggregate
+/// `checksums.txt` still resolves. Anything that is not 64 hex digits is
+/// treated as absent — a malformed line must fail the update, never be silently
+/// compared as a string that happens to be equal.
+fn checksum_for(sidecar: &str, asset_name: &str) -> Option {
+ let mut by_platform = None;
+ for line in sidecar.lines() {
+ let fields: Vec<&str> = line.split_whitespace().collect();
+ if fields.len() != 2 || !is_sha256(fields[0]) {
+ continue;
+ }
+ // `./name` is a legal sha256sum output when the file was hashed by path.
+ let file = fields[1].strip_prefix("./").unwrap_or(fields[1]);
+ if file == asset_name {
+ return Some(fields[0].to_string());
+ }
+ if by_platform.is_none() && matches_current_platform_asset(file) {
+ by_platform = Some(fields[0].to_string());
+ }
+ }
+ by_platform
+}
+
+fn is_sha256(s: &str) -> bool {
+ s.len() == 64
+ && s.bytes()
+ .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
+}
+
+/// Hashes a file with sha256 (Go `verifyChecksum` body, updater.go:512-526).
+fn sha256_file(path: &Path) -> std::io::Result {
+ use std::io::Read;
+ let mut file = std::fs::File::open(path)?;
+ let mut hasher = ::new();
+ let mut buf = vec![0u8; 64 * 1024];
+ loop {
+ let n = file.read(&mut buf)?;
+ if n == 0 {
+ break;
+ }
+ sha2::Digest::update(&mut hasher, &buf[..n]);
+ }
+ Ok(to_hex(&sha2::Digest::finalize(hasher)))
+}
+
+fn to_hex(bytes: &[u8]) -> String {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut s = String::with_capacity(bytes.len() * 2);
+ for b in bytes {
+ s.push(HEX[(b >> 4) as usize] as char);
+ s.push(HEX[(b & 0x0f) as usize] as char);
+ }
+ s
+}
+
+/// Verifies `archive_path` against the sidecar's hash for `asset_name`.
+fn verify_checksum(
+ archive_path: &Path,
+ sidecar: &str,
+ asset_name: &str,
+) -> Result<(), UpdateError> {
+ let expected = checksum_for(sidecar, asset_name).ok_or_else(|| {
+ UpdateError::Checksum(format!(
+ "no checksum found for current platform asset{}",
+ if asset_name.is_empty() {
+ String::new()
+ } else {
+ format!(" ({asset_name})")
+ }
+ ))
+ })?;
+ let actual = sha256_file(archive_path)
+ .map_err(|e| UpdateError::Checksum(format!("hashing download: {e}")))?;
+ if actual != expected {
+ return Err(UpdateError::Checksum(format!(
+ "expected {expected}, got {actual}"
+ )));
+ }
+ Ok(())
+}
+
+// ─── Extraction ───────────────────────────────────────────────────────────────
+
+/// Pulls the executable out of the downloaded archive into a fresh temp file,
+/// already `chmod 0755` (Go `extractBinary`).
+///
+/// The archive's own entry paths are never honoured — the entry is matched by
+/// base name and its bytes are written to a temp file we chose. That makes
+/// `../../.ssh/authorized_keys` style archive entries inert here rather than
+/// something the caller has to defend against.
+fn extract_binary(archive_path: &Path) -> Result {
+ if cfg!(windows) {
+ extract_zip(archive_path, binary_name())
+ } else {
+ extract_tar_gz(archive_path, binary_name())
+ }
+}
+
+fn new_binary_temp() -> Result {
+ tempfile::Builder::new()
+ .prefix("treehouse-new-")
+ .tempfile()
+ .map_err(|e| UpdateError::Extract(format!("creating temp file: {e}")))
+}
+
+/// Copies one archive entry into a 0755 temp file, bounded by `max_bytes`.
+///
+/// The bound is a parameter so a test can prove it fires without building a
+/// 100 MB fixture; production callers pass [`MAX_BINARY_SIZE`]. Streaming
+/// matters here: the reader is a decompression stream, so an unbounded copy
+/// would expand a small archive into as much disk as the attacker likes.
+fn write_entry(
+ reader: &mut R,
+ temp: &mut tempfile::NamedTempFile,
+ max_bytes: u64,
+) -> Result<(), UpdateError> {
+ use std::io::Read;
+ let mut limited = reader.take(max_bytes + 1);
+ let n = std::io::copy(&mut limited, temp.as_file_mut())
+ .map_err(|e| UpdateError::Extract(format!("reading archive: {e}")))?;
+ if n > max_bytes {
+ return Err(UpdateError::Extract(format!(
+ "binary exceeds maximum size of {max_bytes} bytes"
+ )));
+ }
+ temp.as_file_mut()
+ .sync_all()
+ .map_err(|e| UpdateError::Extract(format!("syncing temp file: {e}")))?;
+ set_executable(temp.path())
+}
+
+#[cfg(unix)]
+fn set_executable(path: &Path) -> Result<(), UpdateError> {
+ use std::os::unix::fs::PermissionsExt;
+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
+ .map_err(|e| UpdateError::Extract(format!("setting mode 0755: {e}")))
+}
+
+#[cfg(not(unix))]
+fn set_executable(_path: &Path) -> Result<(), UpdateError> {
+ // Windows has no execute bit; the extension decides what runs.
+ Ok(())
+}
+
+/// `want` is the entry's base name to look for — see [`binary_name`]. It is a
+/// parameter rather than a constant so the Windows `.exe` branch is exercised
+/// by the same tests on Linux and macOS, which never publish a zip.
+fn extract_tar_gz(archive_path: &Path, want: &str) -> Result {
+ let file = std::fs::File::open(archive_path)
+ .map_err(|e| UpdateError::Extract(format!("opening archive: {e}")))?;
+ let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file));
+
+ let entries = archive
+ .entries()
+ .map_err(|e| UpdateError::Extract(format!("reading archive: {e}")))?;
+ for entry in entries {
+ let mut entry = entry.map_err(|e| UpdateError::Extract(format!("reading archive: {e}")))?;
+ // Go requires tar.TypeReg (updater.go:606): a *directory* named
+ // `treehouse` must not be mistaken for the binary.
+ if !entry.header().entry_type().is_file() {
+ continue;
+ }
+ // Read the entry's own path but never act on it — the match is on the
+ // base name and the bytes go to a temp file this function chose, so a
+ // `../../…/treehouse` entry is inert here rather than a write primitive.
+ let name = entry
+ .path()
+ .map_err(|e| UpdateError::Extract(format!("bad entry path: {e}")))?;
+ if name.file_name() != Some(std::ffi::OsStr::new(want)) {
+ continue;
+ }
+ let mut temp = new_binary_temp()?;
+ write_entry(&mut entry, &mut temp, MAX_BINARY_SIZE)?;
+ return Ok(temp);
+ }
+ Err(UpdateError::Extract(format!(
+ "binary {want:?} not found in archive"
+ )))
+}
+
+/// `want` is the entry's base name to look for — see [`binary_name`].
+fn extract_zip(archive_path: &Path, want: &str) -> Result {
+ let file = std::fs::File::open(archive_path)
+ .map_err(|e| UpdateError::Extract(format!("opening archive: {e}")))?;
+ let mut archive = zip::ZipArchive::new(file)
+ .map_err(|e| UpdateError::Extract(format!("opening archive: {e}")))?;
+
+ for i in 0..archive.len() {
+ let mut entry = archive
+ .by_index(i)
+ .map_err(|e| UpdateError::Extract(format!("reading archive: {e}")))?;
+ if !entry.is_file() {
+ continue;
+ }
+ // As in the tar path: the archive's path is inspected, never honoured.
+ let name = std::path::Path::new(entry.name());
+ if name.file_name() != Some(std::ffi::OsStr::new(want)) {
+ continue;
+ }
+ let mut temp = new_binary_temp()?;
+ write_entry(&mut entry, &mut temp, MAX_BINARY_SIZE)?;
+ return Ok(temp);
+ }
+ Err(UpdateError::Extract(format!(
+ "binary {want:?} not found in archive"
+ )))
+}
+
+/// Drops the macOS quarantine xattr from the freshly extracted binary.
+///
+/// A quarantined binary is killed on first exec, which turns a successful
+/// update into "the binary I just installed will not run". Best-effort, as in
+/// Go (quarantine_darwin.go:8-12): an asset that was never quarantined has no
+/// xattr, and failing the update over that would be wrong.
+#[cfg(target_os = "macos")]
+fn remove_quarantine(path: &Path) {
+ let _ = std::process::Command::new("xattr")
+ .args(["-d", "com.apple.quarantine"])
+ .arg(path)
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .status();
+}
+
+#[cfg(not(target_os = "macos"))]
+fn remove_quarantine(_path: &Path) {}
+
+// ─── Replacement ──────────────────────────────────────────────────────────────
+
+/// Installs `new_binary` at `target`, atomically where the platform allows.
+///
+/// Mirrors Go `atomicReplace` (updater.go:679-733) including its two
+/// escape hatches, because both are load-bearing:
+///
+/// 1. The temp file is created **in the target's own directory**. A temp file
+/// in `/tmp` would make the final rename a cross-device copy — not atomic,
+/// and a crash mid-copy leaves a half-written executable at the target path.
+/// 2. If the directory is not writable (`/usr/local/bin` owned by root) but the
+/// file is, we fall back to overwriting in place. Not atomic, and Go accepts
+/// that trade: a failed update is worse than a brief window.
+fn atomic_replace(target: &Path, new_binary: &Path) -> Result<(), UpdateError> {
+ let info = std::fs::metadata(target)
+ .map_err(|e| UpdateError::Replace(format!("stating target {}: {e}", target.display())))?;
+
+ let dir = target.parent().unwrap_or_else(|| Path::new("."));
+ let tmp = match tempfile::Builder::new()
+ .prefix(".treehouse-update-")
+ .tempfile_in(dir)
+ {
+ Ok(t) => t,
+ // No write access to the directory. Go falls back to direct overwrite
+ // here too; anything else would make `treehouse update` fail for every
+ // user who installed into a root-owned prefix.
+ Err(_) => return direct_overwrite(target, new_binary, mode_of(info.permissions())),
+ };
+
+ let tmp_path = tmp.into_temp_path();
+ let mut src = std::fs::File::open(new_binary)
+ .map_err(|e| UpdateError::Replace(format!("opening new binary: {e}")))?;
+ let mut dst = std::fs::File::create(&tmp_path)
+ .map_err(|e| UpdateError::Replace(format!("opening staged binary: {e}")))?;
+ if let Err(e) = std::io::copy(&mut src, &mut dst) {
+ return Err(UpdateError::Replace(format!("staging new binary: {e}")));
+ }
+ // Data before metadata before rename. `rename` is atomic with respect to
+ // other processes, but it makes no promise about the bytes it points at;
+ // without this sync a crash can leave a zero-length "new" binary in place.
+ if let Err(e) = dst.sync_all() {
+ return Err(UpdateError::Replace(format!("syncing staged binary: {e}")));
+ }
+ drop(dst);
+ // Carry the *original* binary's mode over: the extracted file is 0755, but
+ // a user's installed binary may be 0750 or group-writable by design.
+ std::fs::set_permissions(&tmp_path, info.permissions())
+ .map_err(|e| UpdateError::Replace(format!("setting mode: {e}")))?;
+
+ if let Err(e) = std::fs::rename(&tmp_path, target) {
+ // Windows maps a running .exe and refuses to overwrite it in place,
+ // but a mapped image CAN be renamed aside. Go took the same exit.
+ #[cfg(windows)]
+ if replace_running_windows(target, &tmp_path).is_ok() {
+ return Ok(());
+ }
+ let _ = e;
+ return Err(UpdateError::Replace(format!(
+ "renaming onto {}: {e}",
+ target.display()
+ )));
+ }
+ Ok(())
+}
+
+/// Windows-only escape hatch (Go `replaceRunningWindows`, updater.go:742-753):
+/// move the locked image aside, then place the new binary at its original path.
+///
+/// Not `#[cfg]`-gated so the naming rule below — the part Go actually got wrong
+/// once — is covered by the test suite on every platform.
+///
+/// The backup name is unique **per attempt** (`.old..`). A
+/// fixed `.old` name cannot be reused: a previous update's `.old` may still be
+/// mapped by a still-running process, and renaming onto it fails. That was Go
+/// issue #121.
+//
+// Compiled on every platform, not just Windows, so the naming rule is type-checked
+// by the Linux/macOS build and covered by tests there; only the call site in
+// [`atomic_replace`] is Windows-gated.
+#[cfg_attr(not(windows), allow(dead_code))]
+fn replace_running_windows(target: &Path, staged: &Path) -> std::io::Result<()> {
+ let old_path = windows_backup_path(target);
+ std::fs::rename(target, &old_path)?;
+ if let Err(e) = std::fs::rename(staged, target) {
+ // Put the old image back: leaving it aside would delete the user's
+ // working binary as a side effect of a failed update.
+ let _ = std::fs::rename(&old_path, target);
+ return Err(e);
+ }
+ let _ = std::fs::remove_file(&old_path);
+ Ok(())
+}
+
+#[cfg_attr(not(windows), allow(dead_code))]
+fn windows_backup_path(target: &Path) -> PathBuf {
+ let nanos = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or_default();
+ let name = target.file_name().unwrap_or_default().to_string_lossy();
+ target.with_file_name(format!("{name}.old.{}.{nanos}", std::process::id()))
+}
+
+/// Last-resort, non-atomic install for a target directory we cannot write to.
+///
+/// There is a window where `target` is a truncated binary. It exists because the
+/// alternative is refusing to update at all for anyone who installed into a
+/// root-owned `/usr/local/bin` while owning the file (Go `directOverwrite`,
+/// updater.go:760-778).
+fn direct_overwrite(
+ target: &Path,
+ new_binary: &Path,
+ mode: std::fs::Permissions,
+) -> Result<(), UpdateError> {
+ let mut src = std::fs::File::open(new_binary)
+ .map_err(|e| UpdateError::Replace(format!("opening new binary: {e}")))?;
+ let mut dst = std::fs::File::create(target)
+ .map_err(|e| UpdateError::Replace(format!("opening target: {e}")))?;
+ if let Err(e) = std::io::copy(&mut src, &mut dst) {
+ return Err(UpdateError::Replace(format!("writing target: {e}")));
+ }
+ std::fs::set_permissions(target, mode)
+ .map_err(|e| UpdateError::Replace(format!("setting mode: {e}")))
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -314,4 +1103,704 @@ mod tests {
assert!(update_available_with_env("1.0.0", &env)); // 2.0.0 > 1.0.0
assert!(!update_available_with_env("3.0.0", &env)); // 2.0.0 < 3.0.0
}
+
+ // ─── Apply pipeline ────────────────────────────────────────────────────
+ //
+ // Fixtures are built on disk and served over `file://` so the whole
+ // pipeline — download, verify, extract, replace — runs with no network.
+
+ /// The five assets `.github/workflows/release.yml` actually publishes,
+ /// each paired with the (os, arch) that must select it.
+ const RELEASE_MATRIX: &[(&str, &str, &str)] = &[
+ ("linux-x86_64", "linux", "x86_64"),
+ ("linux-aarch64", "linux", "aarch64"),
+ ("macos-x86_64", "macos", "x86_64"),
+ ("macos-aarch64", "macos", "aarch64"),
+ ("windows-x86_64", "windows", "x86_64"),
+ ];
+
+ #[test]
+ fn asset_matcher_covers_every_release_matrix_entry() {
+ for (suffix, os, arch) in RELEASE_MATRIX {
+ let ext = if *os == "windows" { "zip" } else { "tar.gz" };
+ let name = format!("treehouse-v0.1.2-{suffix}.{ext}");
+ assert!(
+ matches_asset(&name, os, arch, ext),
+ "{name} should match {os}/{arch}"
+ );
+ // A different platform's asset must not match, or `update` would
+ // install a binary built for the wrong architecture.
+ for (_, other_os, other_arch) in RELEASE_MATRIX {
+ if *other_os != *os || *other_arch != *arch {
+ assert!(
+ !matches_asset(&name, other_os, other_arch, ext),
+ "{name} must not match {other_os}/{other_arch}"
+ );
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn asset_matcher_rejects_wrong_extension_and_prefix() {
+ // The zip and tar.gz of one platform differ only in extension; picking
+ // the wrong one means extracting the wrong container.
+ assert!(!matches_asset(
+ "treehouse-v0.1.2-linux-x86_64.zip",
+ "linux",
+ "x86_64",
+ "tar.gz"
+ ));
+ // Checksum sidecars are not the archive.
+ assert!(!matches_asset(
+ "treehouse-v0.1.2-linux-x86_64.tar.gz.sha256",
+ "linux",
+ "x86_64",
+ "tar.gz"
+ ));
+ assert!(!matches_asset(
+ "something-else.tar.gz",
+ "linux",
+ "x86_64",
+ "tar.gz"
+ ));
+ }
+
+ #[test]
+ fn checksum_for_reads_sidecar_exact_match() {
+ // The asset this platform would actually be published under comes
+ // first; the exact-match rule must still pick the second line. A
+ // matcher that returned "the first line that looks like ours" would
+ // happily install a hash belonging to a different file.
+ let ours = format!("treehouse-v9.9.9-{}.tar.gz", os_arch_suffix());
+ let other_version = format!("treehouse-v8.8.8-{}.tar.gz", os_arch_suffix());
+ let exact_hash = "a".repeat(64);
+ let sidecar = format!(
+ "{} {other}\n{exact_hash} {ours}\n",
+ "b".repeat(64),
+ exact_hash = exact_hash,
+ ours = ours,
+ other = other_version,
+ );
+ assert_eq!(checksum_for(&sidecar, &ours), Some(exact_hash));
+ }
+
+ #[test]
+ fn checksum_for_tolerates_leading_dot_slash() {
+ let hash = "c".repeat(64);
+ let sidecar = format!("{hash} ./treehouse-v1.0.0-linux-x86_64.tar.gz\n");
+ assert_eq!(
+ checksum_for(&sidecar, "treehouse-v1.0.0-linux-x86_64.tar.gz"),
+ Some(hash)
+ );
+ }
+
+ #[test]
+ fn checksum_for_rejects_malformed_hash() {
+ // A truncated or non-hex line must read as "absent" (fail closed),
+ // never as a hash that could accidentally compare equal.
+ let sidecar = "deadbeef treehouse-v1.0.0-linux-x86_64.tar.gz\n";
+ assert_eq!(
+ checksum_for(sidecar, "treehouse-v1.0.0-linux-x86_64.tar.gz"),
+ None
+ );
+ let upper = format!("{} treehouse-v1.0.0-linux-x86_64.tar.gz\n", "A".repeat(64));
+ assert_eq!(
+ checksum_for(&upper, "treehouse-v1.0.0-linux-x86_64.tar.gz"),
+ None
+ );
+ }
+
+ #[test]
+ fn checksum_for_missing_asset_is_none() {
+ let sidecar = format!("{} other-file.tar.gz\n", "a".repeat(64));
+ assert_eq!(
+ checksum_for(&sidecar, "treehouse-v1.0.0-linux-x86_64.tar.gz"),
+ None
+ );
+ }
+
+ #[test]
+ fn verify_checksum_accepts_match_and_rejects_tamper() {
+ let dir = tempfile::tempdir().unwrap();
+ let archive = dir.path().join("treehouse.tar.gz");
+ std::fs::write(&archive, b"hello archive").unwrap();
+ let hash = sha256_file(&archive).unwrap();
+ let name = "treehouse-v1.0.0-linux-x86_64.tar.gz";
+ let sidecar = format!("{hash} {name}\n");
+
+ verify_checksum(&archive, &sidecar, name).unwrap();
+
+ // One flipped byte in the download must fail the whole update.
+ std::fs::write(&archive, b"hello archivf").unwrap();
+ let err = verify_checksum(&archive, &sidecar, name).unwrap_err();
+ assert!(matches!(err, UpdateError::Checksum(_)), "got {err:?}");
+ }
+
+ /// Builds a tar.gz containing `contents` under `entry_name`.
+ fn make_tar_gz(entry_name: &str, contents: &[u8]) -> Vec {
+ let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
+ Vec::new(),
+ flate2::Compression::fast(),
+ ));
+ let mut header = tar::Header::new_gnu();
+ header.set_size(contents.len() as u64);
+ header.set_mode(0o755);
+ header.set_cksum();
+ builder
+ .append_data(&mut header, entry_name, contents)
+ .unwrap();
+ builder.into_inner().unwrap().finish().unwrap()
+ }
+
+ /// Builds a tar.gz whose single entry sits at a traversal path.
+ ///
+ /// `tar::Builder` refuses to author one (correctly), so the 512-byte header
+ /// is written by hand — otherwise nothing could ever test the reader
+ /// against a hostile archive.
+ fn make_tar_gz_traversal(entry_name: &str, contents: &[u8]) -> Vec {
+ let mut header = tar::Header::new_gnu();
+ header.set_size(contents.len() as u64);
+ header.set_mode(0o755);
+ {
+ let old = header.as_old_mut();
+ old.name[..entry_name.len()].copy_from_slice(entry_name.as_bytes());
+ }
+ header.set_cksum();
+
+ let mut raw = Vec::new();
+ raw.extend_from_slice(header.as_bytes());
+ raw.extend_from_slice(contents);
+ raw.resize(512 + contents.len().next_multiple_of(512), 0);
+ raw.extend_from_slice(&[0u8; 1024]); // end-of-archive marker
+
+ let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
+ std::io::Write::write_all(&mut enc, &raw).unwrap();
+ enc.finish().unwrap()
+ }
+
+ /// Builds a tar.gz containing a single *directory* entry named `entry_name`.
+ fn make_tar_gz_dir(entry_name: &str) -> Vec {
+ let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
+ Vec::new(),
+ flate2::Compression::fast(),
+ ));
+ let mut header = tar::Header::new_gnu();
+ header.set_entry_type(tar::EntryType::Directory);
+ header.set_size(0);
+ header.set_mode(0o755);
+ builder
+ .append_data(&mut header, entry_name, std::io::empty())
+ .unwrap();
+ builder.into_inner().unwrap().finish().unwrap()
+ }
+
+ /// Builds a stored (uncompressed) zip containing `contents` under `entry_name`.
+ fn make_zip(entry_name: &str, contents: &[u8]) -> Vec {
+ use std::io::Write;
+ let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
+ w.start_file(
+ entry_name,
+ zip::write::SimpleFileOptions::default()
+ .compression_method(zip::CompressionMethod::Stored)
+ .unix_permissions(0o755),
+ )
+ .unwrap();
+ w.write_all(contents).unwrap();
+ w.finish().unwrap().into_inner()
+ }
+
+ #[test]
+ fn extract_tar_gz_writes_binary_and_sets_mode_0755() {
+ let dir = tempfile::tempdir().unwrap();
+ let archive = dir.path().join("a.tar.gz");
+ // Nested like the real archive (`tar -czf ... -C dirname treehouse`).
+ std::fs::write(&archive, make_tar_gz("treehouse", b"NEW BINARY")).unwrap();
+
+ let out = extract_tar_gz(&archive, "treehouse").unwrap();
+ assert_eq!(std::fs::read(out.path()).unwrap(), b"NEW BINARY");
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mode = std::fs::metadata(out.path()).unwrap().permissions().mode();
+ assert_eq!(mode & 0o777, 0o755, "extracted binary must be executable");
+ }
+ }
+
+ #[test]
+ fn extract_tar_gz_finds_nested_entry() {
+ let dir = tempfile::tempdir().unwrap();
+ let archive = dir.path().join("a.tar.gz");
+ std::fs::write(&archive, make_tar_gz("dist/treehouse", b"NESTED BINARY")).unwrap();
+ let out = extract_tar_gz(&archive, "treehouse").unwrap();
+ assert_eq!(std::fs::read(out.path()).unwrap(), b"NESTED BINARY");
+ }
+
+ #[test]
+ fn extract_tar_gz_ignores_traversal_paths() {
+ let dir = tempfile::tempdir().unwrap();
+ let archive = dir.path().join("a.tar.gz");
+ // A hostile archive naming the treehouse binary at a traversal path
+ // must still be extracted to a temp file we chose, never to that path.
+ std::fs::write(
+ &archive,
+ make_tar_gz_traversal("../../../../tmp/treehouse", b"ESCAPED"),
+ )
+ .unwrap();
+ let out = extract_tar_gz(&archive, "treehouse").unwrap();
+ assert_eq!(
+ std::fs::canonicalize(out.path().parent().unwrap()).unwrap(),
+ std::fs::canonicalize(std::env::temp_dir()).unwrap(),
+ "extraction must land in our temp dir, not at the archive's path"
+ );
+ assert_eq!(std::fs::read(out.path()).unwrap(), b"ESCAPED");
+ }
+
+ #[test]
+ fn extract_tar_gz_errors_when_binary_absent() {
+ let dir = tempfile::tempdir().unwrap();
+ let archive = dir.path().join("a.tar.gz");
+ std::fs::write(&archive, make_tar_gz("README.md", b"nope")).unwrap();
+ let err = extract_tar_gz(&archive, "treehouse").unwrap_err();
+ assert!(matches!(err, UpdateError::Extract(_)), "got {err:?}");
+ assert!(
+ err.to_string().contains("not found in archive"),
+ "got {err}"
+ );
+ }
+
+ #[test]
+ fn extract_tar_gz_skips_a_directory_named_like_the_binary() {
+ // Go requires tar.TypeReg (updater.go:606). A directory entry whose
+ // base name matches must not be extracted as the executable.
+ let dir = tempfile::tempdir().unwrap();
+ let archive = dir.path().join("a.tar.gz");
+ let bytes = make_tar_gz_dir("treehouse/");
+ std::fs::write(&archive, bytes).unwrap();
+ let err = extract_tar_gz(&archive, "treehouse").unwrap_err();
+ assert!(
+ err.to_string().contains("not found in archive"),
+ "got {err}"
+ );
+ }
+
+ #[test]
+ fn extract_zip_writes_binary() {
+ // Runs on every platform, not just Windows: this is the branch the
+ // Linux/macOS CI can never reach through `extract_binary`.
+ let dir = tempfile::tempdir().unwrap();
+ let archive = dir.path().join("a.zip");
+ std::fs::write(&archive, make_zip("treehouse.exe", b"WINDOWS BINARY")).unwrap();
+ let out = extract_zip(&archive, "treehouse.exe").unwrap();
+ assert_eq!(std::fs::read(out.path()).unwrap(), b"WINDOWS BINARY");
+ }
+
+ #[test]
+ fn write_entry_refuses_an_oversized_binary() {
+ // A small archive that expands without bound is the shape of a
+ // decompression bomb; the cap on the extracted bytes is what stops it,
+ // and it has to stop it while streaming, not after.
+ let mut payload = vec![0u8; 64 * 1024];
+ payload.extend_from_slice(b"tail past the limit");
+ let mut temp = new_binary_temp().unwrap();
+ let err = write_entry(&mut payload.as_slice(), &mut temp, 1024).unwrap_err();
+ assert!(
+ err.to_string().contains("exceeds maximum size"),
+ "got {err}"
+ );
+ }
+
+ #[test]
+ fn write_entry_accepts_a_binary_exactly_at_the_limit() {
+ let payload = vec![0u8; 4096];
+ let mut temp = new_binary_temp().unwrap();
+ write_entry(&mut payload.as_slice(), &mut temp, 4096).unwrap();
+ assert_eq!(std::fs::metadata(temp.path()).unwrap().len(), 4096);
+ }
+
+ #[test]
+ fn extract_zip_ignores_traversal_paths() {
+ let dir = tempfile::tempdir().unwrap();
+ let archive = dir.path().join("a.zip");
+ std::fs::write(
+ &archive,
+ make_zip("../../../../tmp/treehouse.exe", b"ESCAPED"),
+ )
+ .unwrap();
+ let out = extract_zip(&archive, "treehouse.exe").unwrap();
+ assert_eq!(
+ std::fs::canonicalize(out.path().parent().unwrap()).unwrap(),
+ std::fs::canonicalize(std::env::temp_dir()).unwrap()
+ );
+ assert_eq!(std::fs::read(out.path()).unwrap(), b"ESCAPED");
+ }
+
+ #[test]
+ fn extract_zip_errors_when_binary_absent() {
+ let dir = tempfile::tempdir().unwrap();
+ let archive = dir.path().join("a.zip");
+ std::fs::write(&archive, make_zip("other.txt", b"nope")).unwrap();
+ let err = extract_zip(&archive, "treehouse.exe").unwrap_err();
+ assert!(
+ err.to_string().contains("not found in archive"),
+ "got {err}"
+ );
+ }
+
+ #[test]
+ fn extract_binary_dispatches_on_the_current_platform() {
+ let dir = tempfile::tempdir().unwrap();
+ if cfg!(windows) {
+ let archive = dir.path().join("a.zip");
+ std::fs::write(&archive, make_zip("treehouse.exe", b"WIN")).unwrap();
+ assert_eq!(
+ std::fs::read(extract_binary(&archive).unwrap().path()).unwrap(),
+ b"WIN"
+ );
+ } else {
+ let archive = dir.path().join("a.tar.gz");
+ std::fs::write(&archive, make_tar_gz("treehouse", b"UNIX")).unwrap();
+ assert_eq!(
+ std::fs::read(extract_binary(&archive).unwrap().path()).unwrap(),
+ b"UNIX"
+ );
+ }
+ }
+
+ #[test]
+ fn atomic_replace_swaps_contents_and_keeps_original_mode() {
+ let dir = tempfile::tempdir().unwrap();
+ let target = dir.path().join("treehouse");
+ let staged = dir.path().join("staged");
+ std::fs::write(&target, b"OLD BINARY").unwrap();
+ std::fs::write(&staged, b"NEW BINARY").unwrap();
+
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o750)).unwrap();
+ }
+
+ atomic_replace(&target, &staged).unwrap();
+
+ assert_eq!(std::fs::read(&target).unwrap(), b"NEW BINARY");
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mode = std::fs::metadata(&target).unwrap().permissions().mode();
+ assert_eq!(
+ mode & 0o777,
+ 0o750,
+ "install must carry the original binary's mode, not 0755"
+ );
+ }
+ // The staging file lives in the target's directory so the final rename
+ // is same-filesystem; it must not survive the update.
+ let strays: Vec<_> = std::fs::read_dir(dir.path())
+ .unwrap()
+ .filter_map(|e| e.ok())
+ .map(|e| e.file_name().to_string_lossy().into_owned())
+ .filter(|n| n.starts_with(".treehouse-update-"))
+ .collect();
+ assert!(strays.is_empty(), "stray staging files: {strays:?}");
+ }
+
+ #[test]
+ fn atomic_replace_errors_on_missing_target_and_writes_nothing() {
+ let dir = tempfile::tempdir().unwrap();
+ let target = dir.path().join("does-not-exist");
+ let staged = dir.path().join("staged");
+ std::fs::write(&staged, b"NEW").unwrap();
+
+ let err = atomic_replace(&target, &staged).unwrap_err();
+ assert!(matches!(err, UpdateError::Replace(_)), "got {err:?}");
+ assert!(
+ !target.exists(),
+ "a failed replace must not create the target"
+ );
+ }
+
+ #[test]
+ fn atomic_replace_falls_back_when_target_dir_is_not_writable() {
+ // The `/usr/local/bin` case: directory owned by root, file owned by the
+ // user. Failing here would make `update` impossible for them, so Go
+ // falls back to a non-atomic overwrite and so must we.
+ let dir = tempfile::tempdir().unwrap();
+ let target = dir.path().join("treehouse");
+ let staged = dir.path().join("staged");
+ std::fs::write(&target, b"OLD").unwrap();
+ std::fs::write(&staged, b"NEW").unwrap();
+
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap();
+ let result = atomic_replace(&target, &staged);
+ // Restore before asserting so the tempdir can clean itself up.
+ std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
+ result.unwrap();
+ assert_eq!(std::fs::read(&target).unwrap(), b"NEW");
+ }
+ #[cfg(not(unix))]
+ let _ = (target, staged);
+ }
+
+ #[test]
+ fn windows_backup_name_is_unique_per_attempt() {
+ // Go issue #121: a fixed `.old` path cannot be reused because a prior
+ // update's backup may still be mapped by a running process.
+ let target = Path::new("/usr/local/bin/treehouse");
+ let a = windows_backup_path(target);
+ let b = windows_backup_path(target);
+ assert_ne!(a, b, "backup name must differ between attempts");
+ for p in [&a, &b] {
+ let name = p.file_name().unwrap().to_string_lossy();
+ assert!(name.starts_with("treehouse.old."), "got {name}");
+ assert_eq!(p.parent(), target.parent());
+ }
+ }
+
+ #[test]
+ fn replace_running_windows_swaps_content_and_cleans_up() {
+ let dir = tempfile::tempdir().unwrap();
+ let target = dir.path().join("treehouse.exe");
+ let staged = dir.path().join("staged.exe");
+ std::fs::write(&target, b"LOCKED").unwrap();
+ std::fs::write(&staged, b"NEW").unwrap();
+
+ replace_running_windows(&target, &staged).unwrap();
+
+ assert_eq!(std::fs::read(&target).unwrap(), b"NEW");
+ let leftovers: Vec<_> = std::fs::read_dir(dir.path())
+ .unwrap()
+ .filter_map(|e| e.ok())
+ .map(|e| e.file_name().to_string_lossy().into_owned())
+ .filter(|n| n.contains(".old."))
+ .collect();
+ assert!(leftovers.is_empty(), "backup not cleaned: {leftovers:?}");
+ }
+
+ #[test]
+ fn replace_running_windows_rolls_back_when_staging_lost() {
+ // The second rename failing must not leave the user's working binary
+ // sitting under a `.old.` name — that would delete it as a side effect.
+ let dir = tempfile::tempdir().unwrap();
+ let target = dir.path().join("treehouse.exe");
+ std::fs::write(&target, b"LOCKED").unwrap();
+ let missing = dir.path().join("never-created.exe");
+
+ assert!(replace_running_windows(&target, &missing).is_err());
+ assert_eq!(std::fs::read(&target).unwrap(), b"LOCKED");
+ }
+
+ // ─── apply / apply_into ─────────────────────────────────────────────────
+
+ fn file_url(path: &Path) -> String {
+ format!("file://{}", path.display())
+ }
+
+ /// Writes an archive + sidecar pair into `dir` and returns the CheckResult
+ /// that points at them.
+ fn fixture(dir: &Path, contents: &[u8]) -> CheckResult {
+ let archive_name = format!("treehouse-v9.9.9-{}.tar.gz", os_arch_suffix());
+ let archive = dir.join(&archive_name);
+ let archive_bytes = make_tar_gz("treehouse", contents);
+ std::fs::write(&archive, &archive_bytes).unwrap();
+
+ let sidecar_name = format!("{archive_name}.sha256");
+ let sidecar = dir.join(&sidecar_name);
+ let hash = sha256_file(&archive).unwrap();
+ std::fs::write(&sidecar, format!("{hash} {archive_name}\n")).unwrap();
+
+ CheckResult {
+ current_version: "0.1.1".to_string(),
+ latest_version: "v9.9.9".to_string(),
+ update_available: true,
+ asset_name: Some(archive_name),
+ download_url: Some(file_url(&archive)),
+ checksum_url: Some(file_url(&sidecar)),
+ }
+ }
+
+ /// The archive name suffix this platform would actually be published under.
+ fn os_arch_suffix() -> String {
+ format!(
+ "{}-{}",
+ match std::env::consts::OS {
+ "macos" => "macos",
+ other => other,
+ },
+ std::env::consts::ARCH
+ )
+ }
+
+ #[test]
+ fn apply_into_replaces_the_target_end_to_end() {
+ // The M-027 regression: an update must actually change the bytes on
+ // disk, and must report the path it changed.
+ let dir = tempfile::tempdir().unwrap();
+ let target = dir.path().join("treehouse");
+ std::fs::write(&target, b"OLD BINARY").unwrap();
+
+ let result = fixture(dir.path(), b"NEW BINARY");
+ let applied = apply_into(&result, false, &target).unwrap();
+
+ assert_eq!(std::fs::read(&target).unwrap(), b"NEW BINARY");
+ assert_eq!(applied.from_version, "0.1.1");
+ assert_eq!(applied.to_version, "v9.9.9");
+ assert_eq!(applied.replaced_path, target);
+ }
+
+ #[test]
+ fn apply_into_is_idempotent_on_a_second_run() {
+ let dir = tempfile::tempdir().unwrap();
+ let target = dir.path().join("treehouse");
+ std::fs::write(&target, b"OLD").unwrap();
+ let result = fixture(dir.path(), b"NEW");
+
+ apply_into(&result, false, &target).unwrap();
+ // A second update over an already-updated binary must still land.
+ apply_into(&result, false, &target).unwrap();
+ assert_eq!(std::fs::read(&target).unwrap(), b"NEW");
+ }
+
+ #[test]
+ fn apply_into_leaves_target_untouched_on_checksum_mismatch() {
+ let dir = tempfile::tempdir().unwrap();
+ let target = dir.path().join("treehouse");
+ std::fs::write(&target, b"OLD BINARY").unwrap();
+
+ let mut result = fixture(dir.path(), b"NEW BINARY");
+ // Corrupt the sidecar after hashing: the archive no longer matches.
+ result.checksum_url = Some(file_url(&dir.path().join("does-not-exist.sha256")));
+ let err = apply_into(&result, false, &target).unwrap_err();
+ assert!(matches!(err, UpdateError::Checksum(_)), "got {err:?}");
+ assert_eq!(
+ std::fs::read(&target).unwrap(),
+ b"OLD BINARY",
+ "a failed verification must never touch the installed binary"
+ );
+ }
+
+ #[test]
+ fn apply_into_leaves_target_untouched_when_archive_has_no_binary() {
+ let dir = tempfile::tempdir().unwrap();
+ let target = dir.path().join("treehouse");
+ std::fs::write(&target, b"OLD BINARY").unwrap();
+
+ let name = format!("treehouse-v9.9.9-{}.tar.gz", os_arch_suffix());
+ let archive = dir.path().join(&name);
+ std::fs::write(&archive, make_tar_gz("README.md", b"no binary here")).unwrap();
+ let hash = sha256_file(&archive).unwrap();
+ let sidecar = dir.path().join(format!("{name}.sha256"));
+ std::fs::write(&sidecar, format!("{hash} {name}\n")).unwrap();
+
+ let result = CheckResult {
+ current_version: "0.1.1".to_string(),
+ latest_version: "v9.9.9".to_string(),
+ update_available: true,
+ asset_name: Some(name),
+ download_url: Some(file_url(&archive)),
+ checksum_url: Some(file_url(&sidecar)),
+ };
+ let err = apply_into(&result, false, &target).unwrap_err();
+ assert!(matches!(err, UpdateError::Extract(_)), "got {err:?}");
+ assert_eq!(std::fs::read(&target).unwrap(), b"OLD BINARY");
+ }
+
+ #[test]
+ fn apply_into_errors_when_no_asset_for_platform() {
+ let dir = tempfile::tempdir().unwrap();
+ let target = dir.path().join("treehouse");
+ std::fs::write(&target, b"OLD").unwrap();
+ let result = CheckResult::default();
+ let err = apply_into(&result, true, &target).unwrap_err();
+ assert!(
+ matches!(err, UpdateError::NoDownloadUrl { .. }),
+ "got {err:?}"
+ );
+ }
+
+ #[test]
+ fn apply_into_refuses_plain_http_download() {
+ let dir = tempfile::tempdir().unwrap();
+ let target = dir.path().join("treehouse");
+ let result = CheckResult {
+ download_url: Some("http://evil.example/treehouse.tar.gz".to_string()),
+ checksum_url: Some("https://ok.example/treehouse.tar.gz.sha256".to_string()),
+ ..Default::default()
+ };
+ let err = apply_into(&result, true, &target).unwrap_err();
+ assert!(matches!(err, UpdateError::InsecureUrl(_)), "got {err:?}");
+ assert!(err.to_string().contains("download URL"), "got {err}");
+ assert!(!target.exists());
+ }
+
+ #[test]
+ fn apply_into_refuses_plain_http_checksum() {
+ // The asset is on https but the hash is not: an attacker who can
+ // rewrite the sidecar picks the hash, so both must be pinned.
+ let dir = tempfile::tempdir().unwrap();
+ let target = dir.path().join("treehouse");
+ let result = CheckResult {
+ download_url: Some("https://ok.example/treehouse.tar.gz".to_string()),
+ checksum_url: Some("http://evil.example/treehouse.tar.gz.sha256".to_string()),
+ ..Default::default()
+ };
+ let err = apply_into(&result, true, &target).unwrap_err();
+ assert!(matches!(err, UpdateError::InsecureUrl(_)), "got {err:?}");
+ assert!(err.to_string().contains("checksum URL"), "got {err}");
+ }
+
+ #[test]
+ fn apply_into_refuses_to_update_without_a_checksum_file() {
+ // Go updater.go:483-485. Without a sidecar there is nothing to verify
+ // against, so the update is refused outright.
+ let dir = tempfile::tempdir().unwrap();
+ let target = dir.path().join("treehouse");
+ let result = CheckResult {
+ download_url: Some("https://ok.example/treehouse.tar.gz".to_string()),
+ ..Default::default()
+ };
+ let err = apply_into(&result, true, &target).unwrap_err();
+ assert!(matches!(err, UpdateError::Checksum(_)), "got {err:?}");
+ }
+
+ #[test]
+ fn download_to_temp_rejects_oversize() {
+ let dir = tempfile::tempdir().unwrap();
+ let big = dir.path().join("big.bin");
+ std::fs::write(&big, vec![0u8; 4096]).unwrap();
+ // curl aborts first here because the local file has a declared size;
+ // either way the update must fail rather than extract 4 KB of "binary".
+ let err = download_to_temp(&file_url(&big), 1024).unwrap_err();
+ assert!(matches!(err, UpdateError::Download(_)), "got {err:?}");
+ }
+
+ #[test]
+ fn download_size_cap_is_enforced_independently_of_curl() {
+ // The backstop for a chunked response, where curl's --max-filesize has
+ // no declared length to act on.
+ check_download_size(1024, 1024).unwrap();
+ let err = check_download_size(1025, 1024).unwrap_err();
+ assert!(
+ err.to_string().contains("exceeds maximum size"),
+ "got {err}"
+ );
+ }
+
+ #[test]
+ fn download_to_temp_accepts_a_within_limit_file() {
+ let dir = tempfile::tempdir().unwrap();
+ let ok = dir.path().join("ok.bin");
+ std::fs::write(&ok, vec![7u8; 512]).unwrap();
+ let tmp = download_to_temp(&file_url(&ok), 4096).unwrap();
+ assert_eq!(std::fs::read(tmp.path()).unwrap(), vec![7u8; 512]);
+ }
+
+ #[test]
+ fn check_latest_result_rejects_plain_http() {
+ let err = check_latest_result("http://insecure.example", "1.0.0", true).unwrap_err();
+ assert!(matches!(err, UpdateError::InsecureUrl(_)), "got {err:?}");
+ }
}
diff --git a/crates/treehouse/src/cli.rs b/crates/treehouse/src/cli.rs
index a4b6baf..86e7537 100644
--- a/crates/treehouse/src/cli.rs
+++ b/crates/treehouse/src/cli.rs
@@ -27,9 +27,11 @@ pub struct Cli {
#[arg(long, value_enum, global = true, default_value_t = OutputFormat::Human)]
pub format: OutputFormat,
- /// Custom pool root directory (overrides treehouse.toml root and default ~/.treehouse).
- #[arg(long = "env-path", value_name = "DIR", global = true)]
- pub env_path: Option,
+ /// Custom pool root directory, overriding TREEHOUSE_ROOT and
+ /// treehouse.toml's root; relative paths (e.g. "." for an in-project pool)
+ /// resolve from the repo root. Falls back to ~/.treehouse.
+ #[arg(long, visible_alias = "env-path", value_name = "DIR", global = true)]
+ pub root: Option,
#[command(subcommand)]
pub command: Option,
@@ -52,6 +54,8 @@ pub enum Command {
Enter(EnterArgs),
/// Release a lease, terminate lingering processes, reset, return to pool.
Return(ReturnArgs),
+ /// Durably lease an existing worktree by name, without touching it.
+ Lease(LeaseArgs),
/// Show pool status.
Status(StatusArgs),
/// Dry-run removal of stale idle worktrees.
@@ -61,8 +65,7 @@ pub enum Command {
/// Reclaim stale, orphaned, and dead-owner worktrees (dry-run default).
Gc(GcArgs),
/// Acquire -> run an agent -> cleanup guaranteed on every exit.
- #[command(external_subcommand)]
- Run(Vec),
+ Run(RunArgs),
/// Read-only health report.
Doctor(DoctorArgs),
/// Sweep all pools (scheduled cleanup entrypoint).
@@ -87,6 +90,41 @@ pub struct GetArgs {
/// Print the lease as JSON (requires --lease).
#[arg(long)]
pub json: bool,
+ /// Cut from this branch instead of the repository default (Go
+ /// `BaseBranch`). This is NOT Go's `-b`, which *creates* a branch.
+ #[arg(long, value_name = "BRANCH")]
+ pub base: Option,
+ /// Do not fetch origin before acquiring (offline / air-gapped).
+ #[arg(long)]
+ pub no_fetch: bool,
+ /// Create and check out this new branch at the acquired commit (Go `-b`,
+ /// `AcquireOptions.Branch`). Distinct from `--base`, which only changes
+ /// where the worktree is cut FROM. Fails if the branch already exists.
+ #[arg(short = 'b', long, value_name = "BRANCH")]
+ pub branch: Option,
+ /// Name a newly created worktree directory `-` instead of
+ /// `` (Go `UniqueLeaf`). Defaults to $TREEHOUSE_UNIQUE_LEAF.
+ #[arg(long)]
+ pub unique_leaf: bool,
+ /// Template for a newly created worktree's directory (Go
+ /// `WorktreePath`), e.g. `{pool}/{slot}/{repo}`. Supported placeholders:
+ /// {pool}, {slot}, {repo}, {repo_parent}. Overrides --unique-leaf.
+ /// Defaults to $TREEHOUSE_WORKTREE_PATH.
+ #[arg(long, value_name = "TEMPLATE")]
+ pub worktree_path: Option,
+}
+
+#[derive(Debug, Args)]
+pub struct LeaseArgs {
+ /// Label recorded as the lease holder (defaults to
+ /// $TREEHOUSE_LEASE_HOLDER).
+ #[arg(long)]
+ pub lease_holder: Option,
+ /// Print the lease identity as JSON instead of the bare path.
+ #[arg(long)]
+ pub json: bool,
+ /// The worktree name, as printed by `treehouse status`.
+ pub name: String,
}
#[derive(Debug, Args)]
@@ -103,16 +141,36 @@ pub struct ReturnArgs {
/// Clean, reset, and return without prompting.
#[arg(long)]
pub force: bool,
+ /// Return every held worktree in this repository's pool (leaves alone
+ /// slots nobody holds).
+ #[arg(long)]
+ pub all: bool,
/// Return only if the current lease has this identity.
#[arg(long)]
pub if_lease_id: Option,
/// Return only if the current lease has this holder.
#[arg(long)]
pub if_lease_holder: Option,
- /// The worktree path to return (defaults to $TREEHOUSE_DIR).
+ /// The worktree to return: an absolute path, or a slot name as printed by
+ /// `treehouse status` (defaults to $TREEHOUSE_DIR).
pub path: Option,
}
+#[derive(Debug, Args)]
+pub struct RunArgs {
+ /// Make the lease expire (e.g. 30m, 24h).
+ #[arg(long)]
+ pub ttl: Option,
+ /// Label recorded as the lease holder (defaults to run:).
+ #[arg(long)]
+ pub lease_holder: Option,
+ /// The command to run inside the worktree. Everything from the first
+ /// non-flag token on is the command, so its own flags are never eaten by
+ /// treehouse's own parser.
+ #[arg(last = true, required = true, value_name = "CMD")]
+ pub cmd: Vec,
+}
+
#[derive(Debug, Args)]
pub struct StatusArgs {
/// Print status + lease metadata as JSON.
@@ -202,6 +260,65 @@ pub struct DestroyArgs {
pub path: Option,
}
+impl GetArgs {
+ /// Builds the core [`AcquireOptions`] this invocation requests.
+ ///
+ /// Precedence for the two placement options is `flag > env > config`
+ /// (Go `config.ResolveUniqueLeaf` / `ResolveWorktreePath`,
+ /// config.go:126-155). The config tiers are not read here — this crate does
+ /// not own `TreehouseConfig` — so the chain stops at `env`, and a pool
+ /// configured with `unique_leaf = true` is picked up by the core instead.
+ ///
+ /// `--base` and `-b` stay separate all the way down: `base` is the branch a
+ /// worktree is cut FROM, `branch` is the branch that gets CREATED. Merging
+ /// them here would silently turn `--base main -b main` into a request to
+ /// create the branch the worktree is already on.
+ pub fn acquire_options(&self) -> anyhow::Result {
+ // `--branch` must name something: an empty value would otherwise read
+ // as "no branch requested" and the flag would be a silent no-op.
+ if let Some(b) = self.branch.as_deref()
+ && b.is_empty()
+ {
+ anyhow::bail!("--branch requires a non-empty branch name");
+ }
+
+ let unique_leaf =
+ self.unique_leaf || env_flag(treehouse_core::pool::TREEHOUSE_UNIQUE_LEAF_VAR);
+ let worktree_path = self
+ .worktree_path
+ .clone()
+ .or_else(|| non_empty_env(treehouse_core::pool::TREEHOUSE_WORKTREE_PATH_VAR));
+
+ Ok(treehouse_core::pool::AcquireOptions {
+ branch: self.base.clone(),
+ skip_fetch: self.no_fetch,
+ new_branch: self.branch.clone(),
+ unique_leaf,
+ worktree_path,
+ ..Default::default()
+ })
+ }
+}
+
+/// Reads an env var that only reads as a boolean when it parses (Go
+/// `strconv.ParseBool`, config.go:135). An unparseable value is treated as
+/// UNSET rather than as an accidental opt-in or opt-out.
+fn env_flag(name: &str) -> bool {
+ match std::env::var(name) {
+ Ok(v) => matches!(
+ v.trim().to_ascii_lowercase().as_str(),
+ "1" | "true" | "t" | "yes" | "y" | "on"
+ ),
+ Err(_) => false,
+ }
+}
+
+/// Reads an env var, treating an empty value as unset (Go
+/// `os.Getenv(name) != ""`, config.go:150).
+fn non_empty_env(name: &str) -> Option {
+ std::env::var(name).ok().filter(|v| !v.is_empty())
+}
+
/// The repo root + pool dir resolved for the current invocation.
pub struct RepoCtx {
pub repo_root: PathBuf,
@@ -229,10 +346,19 @@ pub fn resolve_repo_ctx() -> anyhow::Result {
})
}
-/// Opens a pool for the current repo context.
-pub fn open_pool(ctx: &RepoCtx) -> anyhow::Result {
+/// Opens a pool for the current repo context, with `--root` applied.
+///
+/// `--root` travels as [`OpenOptions::root_override`] rather than as an
+/// injected `TreehouseEnv` pool root: the injected tier is the LAST fallback
+/// in the resolver, below `TREEHOUSE_ROOT`, and Go ranks an explicit flag above
+/// both (config.go:110-117).
+pub fn open_pool_with_root(
+ ctx: &RepoCtx,
+ root: Option<&str>,
+) -> anyhow::Result {
let opts = treehouse_core::pool::OpenOptions {
config: ctx.config.clone(),
+ root_override: root.map(|r| r.to_string()),
..Default::default()
};
Ok(treehouse_core::pool::Pool::open(
@@ -242,74 +368,97 @@ pub fn open_pool(ctx: &RepoCtx) -> anyhow::Result {
)?)
}
-/// Opens a pool with a custom env path.
-pub fn open_pool_with_env_path(
- ctx: &RepoCtx,
- env_path: &std::path::Path,
-) -> anyhow::Result {
- use std::sync::Arc;
- use treehouse_core::env::DefaultEnv;
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use clap::Parser;
- struct CliEnv {
- pool_root: std::path::PathBuf,
+ /// Parses argv the way the binary does, so a test that passes here proves
+ /// the flag is really REGISTERED — not merely that a struct field exists.
+ fn parse_get(args: &[&str]) -> GetArgs {
+ match Cli::try_parse_from(args).expect("argv must parse").command {
+ Some(Command::Get(a)) => a,
+ _ => panic!("expected the get subcommand"),
+ }
}
- impl treehouse_core::env::TreehouseEnv for CliEnv {
- fn pool_root(&self) -> Option {
- Some(self.pool_root.clone())
- }
- fn update_cache_path(&self) -> Option {
- DefaultEnv.update_cache_path()
- }
- fn user_config_path(&self) -> Option {
- DefaultEnv.user_config_path()
- }
- fn read_file(&self, path: &std::path::Path) -> std::io::Result {
- DefaultEnv.read_file(path)
- }
- fn read_bytes(&self, path: &std::path::Path) -> std::io::Result> {
- DefaultEnv.read_bytes(path)
- }
- fn write_file(&self, path: &std::path::Path, data: &[u8]) -> std::io::Result<()> {
- DefaultEnv.write_file(path, data)
- }
- fn ensure_dir(&self, path: &std::path::Path) -> std::io::Result<()> {
- DefaultEnv.ensure_dir(path)
- }
- fn path_exists(&self, path: &std::path::Path) -> bool {
- DefaultEnv.path_exists(path)
- }
- fn list_dir(&self, path: &std::path::Path) -> std::io::Result> {
- DefaultEnv.list_dir(path)
- }
- fn file_meta(
- &self,
- path: &std::path::Path,
- ) -> std::io::Result {
- DefaultEnv.file_meta(path)
- }
- fn env_var(&self, name: &str) -> Option {
- DefaultEnv.env_var(name)
- }
- fn env_var_os(&self, name: &str) -> Option {
- DefaultEnv.env_var_os(name)
- }
- fn cwd(&self) -> Option {
- DefaultEnv.cwd()
- }
+ /// The three restored flags must each REACH `AcquireOptions` with the
+ /// value the user typed. Before the fix these arguments were rejected
+ /// outright with "unexpected argument", so parsing alone is already the
+ /// first half of the proof; the option assertions are the second.
+ #[test]
+ fn the_acquisition_flags_reach_acquire_options() {
+ let a = parse_get(&[
+ "treehouse",
+ "get",
+ "--base",
+ "develop",
+ "-b",
+ "feat/x",
+ "--unique-leaf",
+ ]);
+ let o = a.acquire_options().expect("options must build");
+ assert_eq!(
+ o.branch.as_deref(),
+ Some("develop"),
+ "--base is the cut FROM"
+ );
+ assert_eq!(
+ o.new_branch.as_deref(),
+ Some("feat/x"),
+ "-b is the branch CREATED"
+ );
+ assert!(o.unique_leaf, "--unique-leaf must reach the core");
+
+ let a = parse_get(&[
+ "treehouse",
+ "get",
+ "--worktree-path",
+ "{pool}/{slot}/{repo}-w",
+ ]);
+ let o = a.acquire_options().expect("options must build");
+ assert_eq!(o.worktree_path.as_deref(), Some("{pool}/{slot}/{repo}-w"));
+
+ let a = parse_get(&["treehouse", "get", "--no-fetch"]);
+ assert!(a.acquire_options().unwrap().skip_fetch);
}
- let env = CliEnv {
- pool_root: env_path.to_path_buf(),
- };
- let opts = treehouse_core::pool::OpenOptions {
- config: ctx.config.clone(),
- ..Default::default()
- };
- Ok(treehouse_core::pool::Pool::open_with_env(
- &ctx.repo_root,
- ctx.remote_url.as_deref(),
- &opts,
- Arc::new(env),
- )?)
+ /// `--base` and `-b` are DIFFERENT options and must never collapse into one.
+ /// Conflating them is how a caller ends up resetting every slot to a branch
+ /// it meant to create.
+ #[test]
+ fn base_and_branch_stay_distinct() {
+ let a = parse_get(&["treehouse", "get", "--base", "main", "-b", "feature"]);
+ let o = a.acquire_options().unwrap();
+ assert_eq!(o.branch.as_deref(), Some("main"));
+ assert_eq!(o.new_branch.as_deref(), Some("feature"));
+ assert_ne!(o.branch, o.new_branch);
+ }
+
+ /// An empty `-b` must be an error, not a silent no-op — otherwise the user
+ /// believes a branch was created when none was.
+ #[test]
+ fn an_empty_branch_name_is_refused() {
+ let a = parse_get(&["treehouse", "get", "-b", ""]);
+ let err = a
+ .acquire_options()
+ .expect_err("empty --branch must be refused");
+ assert!(
+ err.to_string().contains("non-empty branch name"),
+ "got: {err}"
+ );
+ }
+
+ /// With no flags, nothing is enabled: the defaults must keep the built-in
+ /// behaviour, so adding the flags cannot change an existing invocation.
+ #[test]
+ fn defaults_are_off_when_no_flag_is_given() {
+ let a = parse_get(&["treehouse", "get"]);
+ let o = a.acquire_options().unwrap();
+ assert!(o.branch.is_none());
+ assert!(o.new_branch.is_none());
+ assert!(!o.unique_leaf);
+ assert!(!o.skip_fetch);
+ assert!(o.worktree_path.is_none());
+ }
}
diff --git a/crates/treehouse/src/format.rs b/crates/treehouse/src/format.rs
index 3948125..f3d9dee 100644
--- a/crates/treehouse/src/format.rs
+++ b/crates/treehouse/src/format.rs
@@ -72,7 +72,7 @@ fn render_human(
}
}
}
- CommandResult::Prune(r) => {
+ CommandResult::Prune(r, _scope) => {
if r.candidates.is_empty() && r.skipped.is_empty() && r.errors.is_empty() {
writeln!(err, "🌳 No stale worktrees to prune.")?;
return Ok(());
@@ -157,10 +157,77 @@ fn render_human(
)?;
}
}
+ CommandResult::Gc(r, _scope) => {
+ if r.candidates.is_empty() && r.skipped.is_empty() && r.errors.is_empty() {
+ writeln!(err, "🌳 No stale worktrees to reclaim.")?;
+ return Ok(());
+ }
+ if r.dry_run {
+ writeln!(
+ out,
+ "🌳 Dry run: would reclaim {} worktree(s) and free {}.",
+ r.candidates.len(),
+ treehouse_core::prune::format_bytes(r.reclaimable_bytes)
+ )?;
+ writeln!(out, "🌳 Re-run with --yes to reclaim these worktrees.")?;
+ for c in &r.candidates {
+ writeln!(
+ out,
+ " [{}] {} {}",
+ c.tag,
+ treehouse_core::prune::format_bytes(c.bytes),
+ c.path
+ )?;
+ }
+ } else {
+ writeln!(
+ out,
+ "🌳 Reclaimed {} worktree(s) and freed {}.",
+ r.reclaimed.len(),
+ treehouse_core::prune::format_bytes(r.freed_bytes)
+ )?;
+ }
+ if !r.skipped.is_empty() {
+ writeln!(err, "🌳 Skipped {} worktree(s):", r.skipped.len())?;
+ for sk in &r.skipped {
+ writeln!(err, " [{}] {} ({})", sk.category, sk.path, sk.reason)?;
+ }
+ }
+ if !r.errors.is_empty() {
+ writeln!(
+ err,
+ "🌳 Cleanup failed for {} worktree(s) (will retry next run):",
+ r.errors.len()
+ )?;
+ for error in &r.errors {
+ writeln!(
+ err,
+ " [{}] {} — {}: {}",
+ error.phase, error.path, error.name, error.detail
+ )?;
+ }
+ }
+ }
}
Ok(())
}
+/// Renders only what belongs on **stdout**, discarding anything a command
+/// would otherwise banner on stderr.
+///
+/// `treehouse lease` needs this: its stderr line names the slot by the name it
+/// was given, which the shared `Get`-lease banner does not carry, so the
+/// caller prints its own banner and asks for the machine half alone. Machine
+/// formats already write stdout only, so this is exactly [`render`] with a
+/// sink in the error position.
+pub fn render_stdout(
+ format: OutputFormat,
+ result: &CommandResult,
+ out: &mut dyn Write,
+) -> std::io::Result<()> {
+ render(format, result, out, &mut std::io::sink())
+}
+
/// JSON format: compact, one document, trailing newline. Machine data only.
fn render_json(
result: &CommandResult,
@@ -207,7 +274,7 @@ fn render_toon(
mod tests {
use super::*;
use treehouse_core::lease::LeaseInfo;
- use treehouse_core::result::{CommandResult, GetResult};
+ use treehouse_core::result::{CommandResult, GetResult, SweepScope};
use treehouse_core::state::ZERO_TIME;
fn ws(name: &str, status: &str) -> treehouse_core::pool::WorktreeStatus {
@@ -306,4 +373,104 @@ mod tests {
let _ = decoded;
let _ = json_val;
}
+
+ // ─── M-018: gc/prune/return now honour --format ───────────────────────
+
+ fn gc(candidates: usize, reclaimed: usize, dry_run: bool) -> CommandResult {
+ use treehouse_core::gc::{GcResult, GcWorktree};
+ CommandResult::Gc(
+ GcResult {
+ dry_run,
+ candidates: (0..candidates)
+ .map(|i| GcWorktree {
+ name: format!("{i}"),
+ path: format!("/pool/{i}/repo"),
+ bytes: 1024,
+ tag: "expired lease".into(),
+ warning: String::new(),
+ })
+ .collect(),
+ reclaimed: (0..reclaimed)
+ .map(|i| GcWorktree {
+ name: format!("{i}"),
+ path: format!("/pool/{i}/repo"),
+ bytes: 1024,
+ tag: "expired lease".into(),
+ warning: String::new(),
+ })
+ .collect(),
+ ..Default::default()
+ },
+ SweepScope::default(),
+ )
+ }
+
+ #[test]
+ fn gc_json_carries_the_documented_keys() {
+ let (out, err) = render_str(OutputFormat::Json, &gc(2, 0, true));
+ assert_eq!(err, "", "machine formats must keep stderr clean");
+ let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
+ for key in [
+ "dry_run",
+ "orphans_included",
+ "global",
+ "pool_count",
+ "candidates",
+ "reclaimed",
+ "skipped",
+ "errors",
+ "reclaimable_bytes",
+ "freed_bytes",
+ ] {
+ assert!(v.get(key).is_some(), "gc JSON is missing {key}: {out}");
+ }
+ assert_eq!(v["candidates"], 2);
+ assert_eq!(v["dry_run"], true);
+ }
+
+ #[test]
+ fn gc_human_matches_the_legacy_render_byte_for_byte() {
+ let (out, err) = render_str(OutputFormat::Human, &gc(1, 0, true));
+ assert_eq!(
+ out,
+ "🌳 Dry run: would reclaim 1 worktree(s) and free 0 B.\n\
+ 🌳 Re-run with --yes to reclaim these worktrees.\n\
+ \x20 [expired lease] 1 KiB /pool/0/repo\n",
+ "the human gc output must be exactly what the pre-formatter println! block produced"
+ );
+ assert_eq!(err, "");
+ }
+
+ #[test]
+ fn gc_human_reports_the_executed_shape() {
+ // A candidate keeps the "nothing to do" guard from short-circuiting,
+ // which is what the legacy `render_gc` did too.
+ let (out, err) = render_str(OutputFormat::Human, &gc(1, 3, false));
+ assert_eq!(out, "🌳 Reclaimed 3 worktree(s) and freed 0 B.\n");
+ assert_eq!(err, "");
+ }
+
+ #[test]
+ fn gc_human_empty_pool_banner_goes_to_stderr_only() {
+ let (out, err) = render_str(OutputFormat::Human, &gc(0, 0, true));
+ assert_eq!(out, "");
+ assert_eq!(err, "🌳 No stale worktrees to reclaim.\n");
+ }
+
+ /// The stdout-only renderer must drop every banner: `lease` prints its own
+ /// stderr line naming the slot, so a second one here would be noise on a
+ /// stream callers capture.
+ #[test]
+ fn render_stdout_drops_the_banner() {
+ let lease = LeaseInfo {
+ path: "/pool/1/repo".into(),
+ lease_id: "a".repeat(32),
+ lease_holder: "agent".into(),
+ leased_at: chrono::Utc::now(),
+ };
+ let r = CommandResult::Get(GetResult::Lease(lease));
+ let mut out = Vec::new();
+ render_stdout(OutputFormat::Human, &r, &mut out).unwrap();
+ assert_eq!(String::from_utf8(out).unwrap(), "/pool/1/repo\n");
+ }
}
diff --git a/crates/treehouse/src/main.rs b/crates/treehouse/src/main.rs
index 2237bf7..d57b5ed 100644
--- a/crates/treehouse/src/main.rs
+++ b/crates/treehouse/src/main.rs
@@ -16,15 +16,57 @@ use cli::{Cli, Command, OutputFormat};
use treehouse_core::config::TreehouseConfig;
use treehouse_core::destroy::{DestroyOptions, DestroyTargetSpec};
use treehouse_core::prune::PruneOptions;
+use treehouse_core::result::SweepScope;
+
+/// Process exit status for "the worktree, and any lease on it, was left exactly
+/// as it was found" (Go `ExitNotReturned`, cmd/exit.go:19).
+///
+/// Distinct from the generic failure because the two demand different
+/// responses: a failure is worth retrying, while a dirty worktree nobody
+/// cleaned stays dirty — `get` skips it and `prune` will not reclaim it — until
+/// someone cleans it or passes `--force`.
+///
+/// **Opt-in.** Go shipped this as a v3.0.0 BREAKING CHANGE, while
+/// `docs/rust-port-plan.md` Appendix B records exit 0 as this port's contract,
+/// so flipping the default would break every existing script. It is gated
+/// behind `TREEHOUSE_EXIT_STRICT=1` until a major release says otherwise.
+const EXIT_NOT_RETURNED: i32 = 3;
+
+/// A worktree left exactly as it was found, with two messages: Go's exit-3
+/// wording (strict mode) and the port's pre-strict wording (default).
+#[derive(Debug)]
+struct NotReturned {
+ strict: String,
+ default: String,
+}
+
+impl std::fmt::Display for NotReturned {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(&self.strict)
+ }
+}
+
+impl std::error::Error for NotReturned {}
+
+/// Whether `TREEHOUSE_EXIT_STRICT=1` is set.
+fn exit_strict() -> bool {
+ std::env::var("TREEHOUSE_EXIT_STRICT").as_deref() == Ok("1")
+}
fn main() {
// Handle --update-check before clap (background child process bypasses the
- // normal command flow). Detached, silent, non-recursive (env guard).
+ // normal command flow). Detached and silent.
+ //
+ // `TREEHOUSE_NO_UPDATE_CHECK=1` is deliberately NOT consulted here: the
+ // spawning parent SETS it on this child precisely so a third generation
+ // would not fork again (Go `backgroundCheckCommand`, updater.go:213-215).
+ // Treating it as a reason to abort made the child kill itself the instant
+ // it was finally wired up, so the cache was never written and the notice
+ // above stayed unreachable. Non-recursion is guaranteed structurally
+ // instead: this handler exits before `Cli::parse` and before
+ // `should_spawn_update_check` ever runs.
let args: Vec = std::env::args().collect();
if args.len() >= 2 && args[1] == "--update-check" {
- if std::env::var("TREEHOUSE_NO_UPDATE_CHECK").as_deref() == Ok("1") {
- std::process::exit(1);
- }
let version = args.get(2).cloned().unwrap_or_default();
if let Some(latest) = treehouse_core::updater::check_latest(
treehouse_core::updater::DEFAULT_GITHUB_API_URL,
@@ -55,20 +97,182 @@ fn main() {
eprintln!();
}
+ // Spawn the background check that actually WRITES that cache. Without it
+ // the notice above is unreachable: only `treehouse update` writes the file,
+ // so an install that never updates never learns a release exists.
+ if should_spawn_update_check(&cli) {
+ spawn_background_check();
+ }
+
if let Err(e) = run(cli) {
+ if let Some(not_returned) = e.downcast_ref::() {
+ // An unreturned worktree is not a failure. Default keeps this
+ // port's documented exit 0; strict mode adopts Go's exit 3 and
+ // its explanation.
+ if exit_strict() {
+ eprintln!("{not_returned}");
+ std::process::exit(EXIT_NOT_RETURNED);
+ }
+ eprintln!("{}", not_returned.default);
+ return;
+ }
eprintln!("{e:#}");
std::process::exit(1);
}
}
-/// Open a pool, respecting --env-path if provided.
+/// Whether this invocation should fork a background update check.
+///
+/// Gated on cache staleness so the check runs at most once per TTL rather than
+/// once per command, and never for `update` (which does the check in the
+/// foreground) or a dev build (whose version is not a release).
+fn should_spawn_update_check(cli: &Cli) -> bool {
+ treehouse_core::VERSION != "dev"
+ && std::env::var("TREEHOUSE_NO_UPDATE_CHECK").as_deref() != Ok("1")
+ && !matches!(cli.command, Some(Command::Update))
+ && treehouse_core::updater::is_cache_stale(treehouse_core::VERSION)
+}
+
+/// Re-execs this binary as a detached `--update-check` child (Go
+/// `updater.SpawnBackgroundCheck`, updater.go:170-201).
+///
+/// Fire-and-forget: a spawn failure is an update check that did not happen, not
+/// a command that failed, so it is swallowed like Go's `_ =`.
+fn spawn_background_check() {
+ let Some(mut cmd) = background_check_command() else {
+ return;
+ };
+ let _ = cmd.spawn();
+}
+
+/// The detached `--update-check` child, or `None` when this executable cannot
+/// be resolved.
+///
+/// Split from the spawn so the wiring is testable: the defect being fixed was
+/// that nothing was ever spawned at all, and "we built the right command" is
+/// the part that can be asserted without waiting on a network.
+fn background_check_command() -> Option {
+ use std::process::{Command, Stdio};
+
+ // Canonicalize, or a symlinked install (homebrew, /usr/local/bin shims)
+ // re-execs through the link every time instead of the real binary.
+ let exe = std::env::current_exe().ok()?;
+ let exe = std::fs::canonicalize(exe).ok()?;
+
+ let mut cmd = Command::new(exe);
+ cmd.arg("--update-check").arg(treehouse_core::VERSION);
+ // The recursion guard the PARENT side reads (`should_spawn_update_check`).
+ cmd.env("TREEHOUSE_NO_UPDATE_CHECK", "1");
+ cmd.stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null());
+
+ // The child OUTLIVES this command, so it must not inherit the caller's
+ // working directory: a cwd inside a pooled worktree makes the child a
+ // process treehouse itself created — reported by `status` and terminated by
+ // `return`. Detaching the cwd also stops it holding the caller's directory
+ // open after it exits (Go `detachedWorkingDir`, updater.go:229-241).
+ if let Some(dir) = detached_working_dir() {
+ cmd.current_dir(dir);
+ }
+ // Nothing usable could be determined above: inheriting the caller's cwd is
+ // the lesser evil, and Go accepts it too — an update check is worth less
+ // than a failed spawn.
+
+ #[cfg(unix)]
+ {
+ use std::os::unix::process::CommandExt;
+ // SAFETY: setsid(2) only. It touches no memory the parent shares and
+ // cannot fail in a way that leaves the child half-built.
+ unsafe {
+ cmd.pre_exec(|| {
+ if libc_setsid() == -1 {
+ return Err(std::io::Error::last_os_error());
+ }
+ Ok(())
+ });
+ }
+ }
+ #[cfg(windows)]
+ {
+ use std::os::windows::process::CommandExt;
+ const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
+ cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
+ }
+
+ Some(cmd)
+}
+
+/// `setsid(2)` without pulling in the `libc` crate for one call.
+#[cfg(unix)]
+unsafe fn libc_setsid() -> i32 {
+ unsafe extern "C" {
+ fn setsid() -> i32;
+ }
+ unsafe { setsid() }
+}
+
+/// A directory safe for a child detached from the caller (Go
+/// `detachedWorkingDir`, updater.go:229-241).
+///
+/// The temp dir is the first choice, but only when it lies OUTSIDE the
+/// worktree the caller is standing in: `TMPDIR=/.tmp` while running from
+/// `/src` would put the child straight back inside the slot it was
+/// detached from, and the boundary that matters is the slot, not the caller's
+/// subdirectory of it. The filesystem root is the fallback, because no worktree
+/// can contain it.
+fn detached_working_dir() -> Option {
+ let cwd = std::env::current_dir().ok()?;
+ let tmp = std::env::temp_dir();
+ if tmp.is_dir() && outside_enclosing_worktree(&tmp, &cwd) {
+ return Some(tmp);
+ }
+ let root = cwd.ancestors().last()?;
+ root.is_dir().then(|| root.to_path_buf())
+}
+
+/// Whether `dir` is safe for a child detached from a caller in `cwd`: true when
+/// `cwd` lies in no worktree at all, or when `dir` is neither the enclosing
+/// worktree root nor a descendant of it.
+///
+/// Fails CLOSED: anything that cannot be resolved or walked reads as "inside",
+/// so an unreadable marker never yields a directory the child could be started
+/// in.
+fn outside_enclosing_worktree(dir: &Path, cwd: &Path) -> bool {
+ let (Ok(resolved_cwd), Ok(resolved_dir)) =
+ (std::fs::canonicalize(cwd), std::fs::canonicalize(dir))
+ else {
+ return false;
+ };
+ let Some(root) = enclosing_worktree_root(&resolved_cwd) else {
+ // cwd is in no worktree: any directory is safe.
+ return true;
+ };
+ !resolved_dir.starts_with(&root)
+}
+
+/// The first directory at or above `dir` carrying a VCS marker (Go
+/// `enclosingWorktreeRoot`), or `None` when none does.
+///
+/// A marker that cannot be read is an error, not a miss — `slot_backend_name`
+/// answers `""` for a damaged marker, which would make this walk PAST a real
+/// worktree and pick an ancestor the child is actually inside.
+fn enclosing_worktree_root(dir: &Path) -> Option {
+ for candidate in dir.ancestors() {
+ match std::fs::symlink_metadata(candidate.join(".git")) {
+ Ok(_) => return Some(candidate.to_path_buf()),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
+ // Unreadable: stop here rather than climb into an ancestor.
+ Err(_) => return None,
+ }
+ }
+ None
+}
+
+/// Open a pool, respecting --root if provided.
fn open_pool_for_cli(cli: &Cli) -> Result {
let ctx = cli::resolve_repo_ctx()?;
- if let Some(ref env_path) = cli.env_path {
- Ok(cli::open_pool_with_env_path(&ctx, env_path)?)
- } else {
- Ok(cli::open_pool(&ctx)?)
- }
+ cli::open_pool_with_root(&ctx, cli.root.as_deref())
}
fn run(cli: Cli) -> Result<()> {
@@ -81,16 +285,22 @@ fn run(cli: Cli) -> Result<()> {
lease_holder: None,
ttl: None,
json: false,
+ base: None,
+ no_fetch: false,
+ branch: None,
+ unique_leaf: false,
+ worktree_path: None,
},
),
Some(Command::Get(args)) => cmd_get(&cli, args),
Some(Command::Enter(args)) => cmd_enter(&cli, args),
Some(Command::Return(args)) => cmd_return(&cli, args),
+ Some(Command::Lease(args)) => cmd_lease(&cli, args),
Some(Command::Status(args)) => cmd_status(&cli, args),
Some(Command::Prune(args)) => cmd_prune(&cli, args),
Some(Command::Destroy(args)) => cmd_destroy(&cli, args),
Some(Command::Gc(args)) => cmd_gc(&cli, args),
- Some(Command::Watch(args)) => cmd_watch(args),
+ Some(Command::Watch(args)) => cmd_watch(&cli, args),
Some(Command::Run(args)) => cmd_run(&cli, args),
Some(Command::Doctor(args)) => cmd_doctor(&cli, args),
Some(Command::Init) => cmd_init(),
@@ -122,7 +332,17 @@ fn cmd_get(cli: &Cli, args: &cli::GetArgs) -> Result<()> {
Some(s) => Some(chrono::Duration::from_std(humantime::parse_duration(&s)?)?),
None => None,
};
- let lease = pool.acquire_lease_with_ttl(&holder, ttl)?;
+ // Carry the acquisition flags (`--base`, `-b`, `--unique-leaf`,
+ // `--worktree-path`, `--no-fetch`) through lease mode too. Go routes
+ // both modes through one `acquireOpts` (cmd/get.go:169-173); funnelling
+ // only the holder/TTL through the older entry point silently dropped
+ // them whenever `--lease` was set.
+ let mut lease_opts = args.acquire_options()?;
+ lease_opts.lease = Some(treehouse_core::pool::LeaseAcquireOptions {
+ holder: holder.clone(),
+ ttl,
+ });
+ let lease = pool.acquire_lease_with_options(&lease_opts, &holder)?;
let result = treehouse_core::result::CommandResult::Get(
treehouse_core::result::GetResult::Lease(lease),
);
@@ -141,9 +361,8 @@ fn cmd_get(cli: &Cli, args: &cli::GetArgs) -> Result<()> {
// Interactive: open a subshell (writes nothing to stdout).
eprintln!("🌳 Setting up worktree...");
- let acquired = pool.get(&treehouse_core::pool::AcquireOptions::default())?;
+ let acquired = pool.get(&args.acquire_options()?)?;
let path = acquired.path.clone();
- let name = acquired.name.clone();
eprintln!(
"🌳 Entered worktree at {}. Type 'exit' to return.",
path.display()
@@ -158,19 +377,56 @@ fn cmd_get(cli: &Cli, args: &cli::GetArgs) -> Result<()> {
}
if status != 0 {
- // Nonzero shell exit; leave the worktree as-is (Go: detach only).
- detach_and_return(&pool, &path, false)?;
+ // Nonzero shell exit. Go does not branch here at all (cmd/get.go:190+):
+ // the slot was acquired either way, so it is detached and handed back
+ // on every exit path. Reporting the status and doing the same work is
+ // the difference; treating it as "leave everything as-is" stranded the
+ // slot: reservation stamp, checked-out branch, dirty tree, all of it.
+ eprintln!("🌳 Subshell exited with status {status}; returning the worktree.");
+ }
+
+ // Clean return path. The release is guarded on our OWN reservation: while
+ // this subshell ran, the slot could have been durably leased over this live
+ // agent home or handed to a later acquisition. Releasing unconditionally
+ // would reset the worktree and clear the NEW owner's reservation underneath
+ // them (Go `RequireOwnedByCaller`, pool.go:1418-1438).
+ let owned = treehouse_core::pool::ReleasePreconditions {
+ require_owned_by_caller: true,
+ ..Default::default()
+ };
+ if let Err(e) = pool.validate_release_preconditions(&path.to_string_lossy(), &owned) {
+ eprintln!(
+ "🌳 Not returning {} to the pool: {e}; leaving it exactly as it is.",
+ path.display()
+ );
return Ok(());
}
- // Clean return path.
+ // Go detaches the HEAD here (cmd/get.go:200-205) BEFORE asking about
+ // dirtiness, and does it under the same state lock that guards the release
+ // — a takeover cannot land between the check and the HEAD move. The port's
+ // equivalent is the release's `before_reset` hook, which runs in exactly
+ // that critical section (pool.rs:814-838).
let dirty = pool.git_is_dirty(&path)?;
- if dirty && !confirm("Clean worktree and return to pool? [Y/n]")? {
- eprintln!("Worktree left dirty. Use 'treehouse return --force' to clean it later.");
- let _ = name;
- return Ok(());
+ if dirty {
+ eprintln!("🌳 Worktree has uncommitted changes.");
+ match confirm("Clean worktree and return to pool? [Y/n]")? {
+ Confirm::No | Confirm::Unanswered => {
+ return Err(NotReturned {
+ strict: format!(
+ "🌳 worktree left dirty and not returned to the pool; prune will not reclaim this slot. Use treehouse return --force {} to clean it later",
+ quote_path(&path)
+ ),
+ default:
+ "Worktree left dirty. Use 'treehouse return --force' to clean it later."
+ .to_string(),
+ }
+ .into());
+ }
+ Confirm::Yes => {}
+ }
}
- pool.release(&path.to_string_lossy())?;
+ release_with_quiescing(&pool, &path.to_string_lossy(), &owned)?;
eprintln!("🌳 Worktree returned to pool.");
Ok(())
}
@@ -207,34 +463,601 @@ fn cmd_enter(cli: &Cli, args: &cli::EnterArgs) -> Result<()> {
/// Release a lease / return a worktree.
fn cmd_return(cli: &Cli, args: &cli::ReturnArgs) -> Result<()> {
- let _ = cli;
+ // Empty --if-lease-id is an error (Go return_cmd.go:76-78).
+ if let Some(id) = &args.if_lease_id
+ && id.is_empty()
+ {
+ return Err(anyhow!("--if-lease-id must not be empty"));
+ }
+ if args.all {
+ return cmd_return_all(cli, args);
+ }
+
let pool = open_pool_for_cli(cli)?;
- let path = args
+ let raw = args
.path
.clone()
.or_else(|| std::env::var("TREEHOUSE_DIR").ok())
.ok_or_else(|| anyhow!("no worktree path specified"))?;
+ let path = resolve_return_target(&pool, &raw)?;
- // Empty --if-lease-id is an error.
- if let Some(id) = &args.if_lease_id
- && id.is_empty()
- {
- return Err(anyhow!("--if-lease-id must not be empty"));
+ let preconditions = treehouse_core::pool::ReleasePreconditions {
+ expected_lease_id: args.if_lease_id.clone(),
+ expected_lease_holder: args.if_lease_holder.clone(),
+ ..Default::default()
+ };
+ let result = release_one(&pool, &path, args, &preconditions)?;
+
+ // Full render, not stdout-only: `🌳 Worktree returned to pool.` is a
+ // byte-exact string scripts match on, and it belongs on stderr. Machine
+ // formats still emit the payload on stdout alone.
+ let result = treehouse_core::result::CommandResult::Return(result);
+ let stdout = std::io::stdout();
+ let stderr = std::io::stderr();
+ let mut out = stdout.lock();
+ let mut err = stderr.lock();
+ format::render(to_output_format(cli.format)?, &result, &mut out, &mut err)?;
+ Ok(())
+}
+
+/// `return --all`: reclaim every HELD worktree in this repository's pool.
+///
+/// A slot nobody holds is never a target — `available` has nothing to return,
+/// `damaged` cannot be judged safe to reset (`destroy` is the verb that removes
+/// one), and a slot the caller's own shell merely stands in is not being held
+/// at all. Everything else is a slot somebody is holding, which is exactly what
+/// a bulk return exists to reclaim. This is deliberately WIDER than prune and
+/// `destroy --all`, which never touch a leased slot: `--all` exists to reclaim a
+/// whole pool.
+///
+/// Each slot is released exactly as naming it would be, one at a time, and a
+/// per-worktree abort, skip, or failure never stops the ones after it — a bulk
+/// return that gave up on the first dirty slot would leave the rest held with
+/// no indication which.
+fn cmd_return_all(cli: &Cli, args: &cli::ReturnArgs) -> Result<()> {
+ cmd_return_all_guards(args)?;
+
+ let pool = open_pool_for_cli(cli)?;
+ let statuses = pool.status()?;
+
+ let mut targets = Vec::new();
+ let mut not_held = 0usize;
+ let mut recovered = 0usize;
+ let mut stood_in: Vec = Vec::new();
+ for wt in &statuses {
+ // A recovered entry is leased by a synthetic holder, so nothing on disk
+ // proves it idle. Counting it as recovered (not as "held") keeps it out
+ // of both totals the summary line reports as returned/skipped targets.
+ if wt.status == treehouse_core::pool::STATUS_LEASED
+ && wt.lease_holder == treehouse_core::state::RECOVERED_LEASE_HOLDER
+ {
+ recovered += 1;
+ eprintln!(
+ "🌳 Leaving {} at {} leased: it was recovered, so nothing proves it idle and it is only returned by name. Check it, then run: treehouse return {}",
+ wt.name,
+ wt.path,
+ quote_path(Path::new(&wt.path))
+ );
+ continue;
+ }
+ // A parked slot the caller is merely standing in reads as held only
+ // because their own shell is in its process list. `treehouse enter`
+ // promises to leave pool state untouched, so returning that slot would
+ // both break the promise and reset the tree under the user running the
+ // sweep. Naming it still returns it — the narrow target is deliberate.
+ if held_only_by_cwd(&pool, wt)? {
+ stood_in.push(wt.name.clone());
+ not_held += 1;
+ continue;
+ }
+ if returnable_status(&wt.status) {
+ targets.push(wt.clone());
+ } else {
+ not_held += 1;
+ }
+ }
+
+ let pool_count = statuses.len();
+ if targets.is_empty() && recovered == 0 {
+ eprintln!("🌳 No held worktrees to return ({pool_count} in the pool).");
+ // Still a RESULT. A machine caller asked for a document and must get
+ // one: "nothing to do" is the answer, silence is a broken contract, and
+ // an empty sweep is the case most likely to be scripted against.
+ return render_return_all(
+ cli,
+ &ReturnAllSummary {
+ pool_count,
+ considered: targets.len() + recovered,
+ returned: Vec::new(),
+ skipped: recovered,
+ aborted: Vec::new(),
+ failed: Vec::new(),
+ not_held,
+ stood_in: stood_in.clone(),
+ },
+ );
+ }
+
+ let mut returned: Vec = Vec::new();
+ let mut skipped = 0usize;
+ let mut aborted: Vec = Vec::new();
+ let mut failed: Vec = Vec::new();
+ for wt in &targets {
+ eprintln!("🌳 Returning {} ({}) at {}", wt.name, wt.status, wt.path);
+ // Pin the lease THIS listing saw. The two instants are separated by
+ // every earlier confirmation in the run, a far wider window than a
+ // named return has.
+ let preconditions = bulk_return_preconditions(wt);
+ match release_one(&pool, &wt.path, args, &preconditions) {
+ Ok(r) if r.aborted => {
+ aborted.push(wt.name.clone());
+ eprintln!(
+ " {} left as found: its uncommitted changes were kept.",
+ wt.name
+ );
+ }
+ Ok(_) => returned.push(wt.name.clone()),
+ // A skip is not a failure: nothing went wrong and nothing was left
+ // half-done, so retrying would report the same thing forever.
+ Err(e) if is_release_skip(&e) => {
+ skipped += 1;
+ if is_recovered_refusal(&e) {
+ eprintln!(
+ " {} skipped: it was recovered since this run listed it, so it is only returned by name. Check it, then run: treehouse return {}",
+ wt.name,
+ quote_path(Path::new(&wt.path))
+ );
+ } else {
+ eprintln!(
+ " {} skipped: it is no longer the acquisition this run listed, so it was left alone ({e}).",
+ wt.name
+ );
+ }
+ }
+ Err(e) => {
+ failed.push(wt.name.clone());
+ eprintln!(" {} failed: {e}", wt.name);
+ }
+ }
+ }
+
+ let returned_count = returned.len();
+ eprintln!(
+ "🌳 Returned {returned_count} of {} held worktree(s); {} skipped; {not_held} not held.",
+ targets.len() + recovered,
+ skipped + recovered
+ );
+
+ let summary = ReturnAllSummary {
+ pool_count,
+ considered: targets.len() + recovered,
+ returned,
+ skipped: skipped + recovered,
+ aborted: aborted.clone(),
+ failed: failed.clone(),
+ not_held,
+ stood_in: stood_in.clone(),
+ };
+ render_return_all(cli, &summary)?;
+
+ // A failure outranks an abort: retrying is the right response to a failure
+ // and the wrong one to a worktree deliberately left dirty, so the more
+ // urgent of the two decides the exit status.
+ if !failed.is_empty() {
+ return Err(anyhow!(
+ "failed to return worktree(s) {}",
+ failed.join(", ")
+ ));
+ }
+ if !aborted.is_empty() {
+ return Err(NotReturned {
+ strict: format!(
+ "🌳 worktree(s) {} not returned: they have uncommitted changes and cleaning was declined or could not be confirmed; prune will not reclaim those slots. Use treehouse return --all --force to clean and return them",
+ aborted.join(", ")
+ ),
+ default: "Aborted.".to_string(),
+ }
+ .into());
+ }
+ Ok(())
+}
+
+/// What a `return --all` sweep did, in the shape a machine caller reads.
+///
+/// `skipped` and `failed` carry NAMES while `returned` carries the names that
+/// came back; the asymmetry is deliberate — a skip and a failure are both
+/// per-slot outcomes an operator has to chase down by name, whereas `returned`
+/// is also the count the exit status is derived from.
+///
+/// The shape is this command's own rather than `CommandResult::Return`: that
+/// variant describes ONE worktree (`path`, `returned`, `aborted`), and a bulk
+/// sweep returning a single-worktree document would tell a script it returned
+/// one tree however many it actually released.
+#[derive(Debug, Clone, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+struct ReturnAllSummary {
+ pool_count: usize,
+ /// Slots the run took as targets (including recovered entries it left
+ /// alone) — the denominator the human summary line reports against.
+ considered: usize,
+ returned: Vec,
+ skipped: usize,
+ aborted: Vec,
+ failed: Vec,
+ not_held: usize,
+ /// Slots skipped because the caller's own shell was the only thing standing
+ /// in them. Reported so the count is explainable rather than mysterious.
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ stood_in: Vec,
+}
+
+impl ReturnAllSummary {
+ fn to_json(&self) -> serde_json::Value {
+ serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
}
+}
- let dirty = pool.git_is_dirty(Path::new(&path))?;
- if dirty && !args.force && !confirm("Clean worktree and return to pool? [Y/n]")? {
- eprintln!("Aborted.");
+/// Renders a `return --all` sweep in the requested format.
+///
+/// This is what the previous `eprintln!`-and-return did not do: under
+/// `--format json|toon` the command emitted ZERO bytes on stdout, so every
+/// consumer of the machine formats hung on an empty read or, worse, treated a
+/// closed stdout as "nothing was held" when the sweep had in fact just returned
+/// eight worktrees. The human lines above stay on stderr, byte for byte.
+fn render_return_all(cli: &Cli, summary: &ReturnAllSummary) -> Result<()> {
+ let fmt = resolve_machine_format(cli, /* json_flag = */ false)?;
+ if fmt == format::OutputFormat::Human {
return Ok(());
}
+ let stdout = std::io::stdout();
+ let stderr = std::io::stderr();
+ let mut out = stdout.lock();
+ let mut err = stderr.lock();
+ render_payload(fmt, &summary.to_json(), &mut out, &mut err)
+}
- let preconditions = treehouse_core::pool::ReleasePreconditions {
- expected_lease_id: args.if_lease_id.clone(),
- expected_lease_holder: args.if_lease_holder.clone(),
+/// Writes one machine-format document to stdout, discarding stderr banners.
+///
+/// `render_payload` mirrors what `format::render` does for a single command
+/// result; it exists here because the bulk payloads this file emits have no
+/// `CommandResult` variant to hand to it. The two must not drift, so the
+/// encoding rules are stated once, here, and the single-result renderer is left
+/// as it is.
+fn render_payload(
+ fmt: format::OutputFormat,
+ payload: &serde_json::Value,
+ out: &mut dyn Write,
+ _err: &mut dyn Write,
+) -> Result<()> {
+ let encoded = match fmt {
+ format::OutputFormat::Human => return Ok(()),
+ // `toon::encode` takes its input by value (its `JsonValue` has no
+ // `From<&serde_json::Value>` impl), so the clone is required rather than
+ // optional. This arm only compiles under the `toon` feature, which is
+ // off by default — hence the `cargo check --features toon` in review.
+ #[cfg(feature = "toon")]
+ format::OutputFormat::Toon => toon::encode(payload.clone(), None),
+ #[cfg(not(feature = "toon"))]
+ format::OutputFormat::Toon => serde_json::to_string(payload)?,
+ format::OutputFormat::Json => serde_json::to_string(payload)?,
};
- pool.release_conditional(&path, &preconditions, None)?;
- eprintln!("🌳 Worktree returned to pool.");
+ writeln!(out, "{encoded}")?;
+ Ok(())
+}
+
+/// Resolves the single worktree a return acts on (Go `resolveReturnTarget`).
+///
+/// The argument is read as a PATH first and only then as a slot NAME, so every
+/// argument that resolves today keeps resolving to exactly the same worktree.
+/// That ordering matters because the two vocabularies collide — standing in a
+/// pool directory, `1` is both a slot name and a real subdirectory — and a
+/// path that already works must never be redirected.
+fn resolve_return_target(pool: &treehouse_core::pool::Pool, arg: &str) -> Result {
+ let abs = absolute(arg);
+ match pool.find_by_path(&abs) {
+ Ok(Some(entry)) => return Ok(entry.path),
+ Ok(None) => {}
+ Err(e) => return Err(anyhow!("{e}")),
+ }
+ if !could_be_worktree_name(arg) {
+ return Err(anyhow!("worktree {abs} is not managed by treehouse"));
+ }
+ match pool.find_by_name(arg) {
+ Ok(Some(entry)) => Ok(entry.path),
+ Ok(None) => Err(anyhow!("{}", unknown_worktree_name_error(&abs, arg, pool)?)),
+ Err(e) => Err(anyhow!("{e}")),
+ }
+}
+
+/// Whether an argument can be read as a slot name (Go `couldBeWorktreeName`).
+///
+/// A pool names its slots itself and never puts a path separator in a name, so
+/// anything holding one is a path and only a path: its failure has to keep
+/// reporting the path diagnosis rather than a misleading "no worktree named".
+/// Backslash is rejected on every platform — it separates paths on Windows, and
+/// no generated slot name contains one anywhere.
+fn could_be_worktree_name(arg: &str) -> bool {
+ if arg.is_empty() || arg == "." || arg == ".." {
+ return false;
+ }
+ if Path::new(arg).is_absolute() {
+ return false;
+ }
+ !arg.contains(['/', '\\'])
+}
+
+/// An argument that resolved as neither vocabulary. It names BOTH readings,
+/// because the user picked one of them and only they know which, and it lists
+/// the names the pool does have. A pool whose names cannot be listed still
+/// produces the refusal: the listing is help, not the verdict.
+fn unknown_worktree_name_error(
+ seen_path: &str,
+ name: &str,
+ pool: &treehouse_core::pool::Pool,
+) -> Result {
+ let names = pool.worktree_names().unwrap_or_default();
+ if names.is_empty() {
+ return Ok(format!(
+ "no worktree named {name:?}: the pool is empty, and it is not a treehouse-managed worktree path either. Run 'treehouse get' to create one"
+ ));
+ }
+ Ok(format!(
+ "no worktree named {name:?} in pool (available: {}), and {seen_path} is not a treehouse-managed worktree path either. Run 'treehouse status' for details",
+ names.join(", ")
+ ))
+}
+
+/// `filepath.Abs`: absolute against the cwd, and cleaned of `.`/`..`.
+fn absolute(arg: &str) -> String {
+ let p = Path::new(arg);
+ let joined = if p.is_absolute() {
+ p.to_path_buf()
+ } else {
+ std::env::current_dir().unwrap_or_default().join(p)
+ };
+ // `canonicalize` would answer about the filesystem and fail on a
+ // worktree that has since been removed; `normalize` is purely lexical.
+ lexical_normalize(&joined).to_string_lossy().into_owned()
+}
+
+/// Lexical `.`/`..` collapse — the Rust stand-in for Go's `filepath.Clean`.
+/// Works on a path that does not exist, which `canonicalize` does not.
+fn lexical_normalize(p: &Path) -> PathBuf {
+ use std::path::Component;
+ let mut out = PathBuf::new();
+ for comp in p.components() {
+ match comp {
+ Component::CurDir => {}
+ Component::ParentDir => match out.components().next_back() {
+ Some(Component::Normal(_)) => {
+ out.pop();
+ }
+ // `..` at the filesystem root IS the root (Go's
+ // `filepath.Clean("/../a") == "/a"`), so it is dropped there
+ // rather than accumulated into a path that escapes upwards.
+ Some(Component::RootDir) => {}
+ // Already leading with `..`, or a bare relative path: keep it.
+ _ => out.push(".."),
+ },
+ other => out.push(other.as_os_str()),
+ }
+ }
+ out
+}
+
+/// The flag combinations `--all` refuses, before any pool is even opened.
+///
+/// `--all` targets every held slot, so naming one of them is a contradiction,
+/// and a `--if-lease-*` condition identifies a single acquisition — which is
+/// the one thing a bulk sweep deliberately cannot pin. Both refusals are
+/// byte-exact Go (return_cmd.go:92-101).
+fn cmd_return_all_guards(args: &cli::ReturnArgs) -> Result<()> {
+ if args.path.is_some() {
+ return Err(anyhow!(
+ "--all takes no path or name; it returns every held worktree in this repository's pool"
+ ));
+ }
+ if args.if_lease_id.is_some() || args.if_lease_holder.is_some() {
+ return Err(anyhow!(
+ "--all cannot be combined with --if-lease-id or --if-lease-holder; a lease condition identifies one acquisition, so name that worktree instead"
+ ));
+ }
+ Ok(())
+}
+
+/// Whether `return --all` acts on a slot in this state (Go `returnableStatus`).
+///
+/// `available` has nothing to return and `damaged`'s contents cannot be judged,
+/// so neither is safe to reset — `destroy` is the verb that removes one.
+fn returnable_status(status: &str) -> bool {
+ !matches!(
+ status,
+ treehouse_core::pool::STATUS_AVAILABLE | treehouse_core::pool::STATUS_DAMAGED
+ )
+}
+
+/// Whether the caller's own shell standing in a slot is the ONLY thing making it
+/// look held (Go `WorktreeStatus.HeldOnlyByCwd`).
+///
+/// **This exclusion is load-bearing, not decoration.** Go's `pool.List` filters
+/// the caller's own process chain out of the process scan before classifying, so
+/// a parked slot the user has merely `cd`'d into reports `available` underneath
+/// and the `you're here` overlay sets `HeldOnlyByCwd`. This port's
+/// `Pool::status` does not filter (`pool.rs:898` calls `find_in_worktree`
+/// unfiltered), so that same slot reports `you're here` with the user's own
+/// shell sitting in its process list — a "held" reading produced entirely by the
+/// caller's presence. `return --all` therefore has to exclude it here or it
+/// resets the tree under the user it is running for.
+///
+/// It is decided from dirtiness, and every other precondition is already
+/// settled by the time a slot reads `you're here`: `leased` and a live owner
+/// reservation both outrank the overlay, and `damaged` is checked after it, so a
+/// `you're here` slot is none of those. That leaves the caller's cwd as the sole
+/// possible claim when the tree is clean — which is exactly Go's predicate.
+///
+/// **Known widening.** Go can tell "only my shell is in here" from "my shell
+/// AND an agent are both in here" because it holds the protected chain. That
+/// chain is not reachable from this crate, so a CLEAN slot holding a genuine
+/// foreign process is excluded too. That errs towards not releasing rather than
+/// towards resetting a live agent's tree, and the slot is still returned by name
+/// (`treehouse return