From 549c6aa7d4d791f3e4ba31ef4828cfd08c2fe8be Mon Sep 17 00:00:00 2001 From: E2E Date: Fri, 2 Oct 2026 00:31:22 +0700 Subject: [PATCH 1/4] feat: port the VCS seam from Go upstream; cut release notes for v0.2.0 Closes the architectural gap that the 27 earlier fixes worked around: the Go original grew an internal/vcs seam with pluggable backends, and this port was still a flat git-only GitBackend. Six of the sixteen drift items were gated on that one rewrite. The seam (crates/treehouse-core/src/vcs/) - BackendRegistry, extensible by register() alone -- no trait edit, no enum. - worktree_backend_name reproduces Go's marker rule exactly: lstat then one stat, so a DANGLING marker is a read error and never an "absent marker" that falls through to the enclosing repository. - destructive_backend_for_worktree REFUSES a markerless path rather than letting git walk up and reset the repository enclosing an in-project pool. - The guarded-reset pair: is_worktree_safe_to_reset resolves the ref once to an immutable SHA, and reset_worktree_to_ref takes git's own HEAD.lock (O_CREAT|O_EXCL), re-reads HEAD under it, refuses if it moved, re-checks dirtiness, and only then read-tree/clean. This is now on the LIVE acquire path (pool.rs reset_for_acquisition), which was the whole point -- a pair nobody calls does not mitigate the race. Backends and capabilities - jj backend (vcs/jj.rs, registered at vcs/mod.rs:301). jj was never installed on the build machine, so there is NO end-to-end coverage; it is unit-tested against a faked command layer and the release notes say so plainly. - APFS clonefile sharing behind --apfs-sharing, degrading to a plain copy on any failure. Documented limitation: it never engages through a symlinked pool root, which includes the macOS default TMPDIR and /tmp. - .worktreeinclude seeding wired into Pool::get; seeded paths recorded in state, and a reset deletes only files the tool itself seeded -- a missing or empty inventory authorises zero deletions. Self-update - `treehouse update` now reaches the download -> verify-sha256 -> extract -> atomic-replace pipeline. An earlier round wrote that pipeline but left it uncalled while cmd_update printed success; that is fixed and now proven by a fixture laid out the way release.yml names assets. - release_workflow_contract: 7 tests parse the real release.yml and assert every published asset has a sidecar the updater accepts. Nothing tied those two files together before, which is why a naming mismatch shipped. - Enabled --features toon in the release build. It was off by default, so released binaries silently emitted JSON for --format toon. Docs corrected against the code, not the other way round: several release claims did not match what the binary does -- jj's real coverage, the apfs/include-file flags that exist, seeding being on the acquire path, and a TOON claim of "~40-60% fewer tokens" that measurement contradicted (TOON is ~5% LARGER on a one-slot pool). Known and disclosed: a doubled "git git" prefix in some git error labels (cosmetic, pre-existing); worktree creation still calls git.worktree_add directly rather than through dispatch, so jj support is asymmetric. Tests 401 -> 493, all passing; clippy clean under -D warnings. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 28 + .github/workflows/release.yml | 16 +- CHANGELOG.md | 216 ++ Cargo.lock | 5 +- Cargo.toml | 9 +- README.md | 158 +- RELEASE_NOTES.md | 345 +++ crates/treehouse-core/Cargo.toml | 6 + crates/treehouse-core/src/config.rs | 603 +++++ crates/treehouse-core/src/fileclone/mod.rs | 2657 ++++++++++++++++++++ crates/treehouse-core/src/git/mod.rs | 168 ++ crates/treehouse-core/src/git/shell.rs | 975 +++++++ crates/treehouse-core/src/lib.rs | 2 + crates/treehouse-core/src/pool.rs | 1166 ++++++++- crates/treehouse-core/src/state.rs | 356 ++- crates/treehouse-core/src/updater.rs | 417 ++- crates/treehouse-core/src/vcs/jj.rs | 2026 +++++++++++++++ crates/treehouse-core/src/vcs/mod.rs | 1745 +++++++++++++ crates/treehouse/Cargo.toml | 14 +- crates/treehouse/src/cli.rs | 487 +++- crates/treehouse/src/main.rs | 173 +- crates/treehouse/tests/update_e2e.rs | 83 +- install.ps1 | 7 +- 23 files changed, 11519 insertions(+), 143 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 RELEASE_NOTES.md create mode 100644 crates/treehouse-core/src/fileclone/mod.rs create mode 100644 crates/treehouse-core/src/vcs/jj.rs create mode 100644 crates/treehouse-core/src/vcs/mod.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 202610d..2a497ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,3 +61,31 @@ jobs: run: cargo test --workspace - name: test with hardening feature run: cargo test -p treehouse-core --features hardening + + # `toon` is feature-gated and OFF by default, so no job above compiles the + # TOON encoder — release.yml's `--features toon` build is the only place it is + # compiled today, and finding out it is broken by pushing a tag is far too + # late. Single runner on purpose: the encoder is portable Rust with no + # platform-specific code, so macOS/Windows buys no signal over one compile, + # and its feature set is disjoint from every other job's (hence no `needs:`). + # + # This job needs network and gets it for the same reason checkout does: + # `tru` is a git dependency (Cargo.lock:2597 pins it to an immutable rev of + # Dicklesworthstone/toon_rust), fetched by cargo from github.com. + toon: + name: build + test with toon encoder + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: build workspace with toon + run: cargo build --workspace --features toon + # The e2e suites spawn target/debug/treehouse (tests/e2e/common.rs + # `treehouse_bin` asserts it exists), and the release binary is the one + # that has to carry the encoder — so build it here, before the tests, + # rather than letting them pick up a default-feature build. + - name: build treehouse binary with toon + run: cargo build --bin treehouse --features toon + - name: test workspace with toon + run: cargo test --workspace --features toon diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b208be..dc37938 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,11 +73,23 @@ jobs: case "${{ matrix.suffix }}" in windows-*) BIN_NAME="treehouse.exe" ;; esac + # `toon` is a FEATURE-gated encoder, and without it `--format toon` + # silently falls back to serde_json — a shipped binary that prints JSON + # for a flag the README documents as TOON. The feature is off by + # default so an offline checkout still compiles, which means every + # build that ships has to ask for it explicitly. + # + # NOTE: `tru` is a git dependency (pinned rev, Cargo.toml + # workspace.dependencies). Under `cross` the fetch happens INSIDE the + # container, so this job needs the container to reach github.com. That + # is also why ci.yml needs its own `--features toon` job — this is the + # only place the encoder is compiled today, and finding out it is + # broken by pushing a tag is far too late. if [[ "${{ matrix.use_cross }}" == "true" ]]; then - cross build --release --target "${{ matrix.target }}" --bin treehouse + cross build --release --target "${{ matrix.target }}" --bin treehouse --features toon BIN="target/${{ matrix.target }}/release/${BIN_NAME}" else - cargo build --release --bin treehouse + cargo build --release --bin treehouse --features toon BIN="target/release/${BIN_NAME}" fi echo "BIN=$BIN" >> "$GITHUB_ENV" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c81fab3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,216 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +Nothing yet. + +## [0.2.0] - 2026-10-01 + +A 0.x minor, so it carries breaking changes. The two that matter are the exit +status and `--env-path`; both are documented in `RELEASE_NOTES.md` and both have +a one-release migration path. + +### Breaking + +#### Exit 3 is now the default when a dirty worktree is left unreturned + +`treehouse return` previously exited 0 even when it declined to clean a dirty +worktree, so a caller reading exit 0 as "the slot was released" was reading a +bug. It now exits 3 — the status Go shipped in v3.0.0 — and keeps the slot held. + +`TREEHOUSE_EXIT_STRICT=0` restores the old exit 0 and its byte-identical output +for one minor release. The variable changed polarity: it used to be opt-in and is +now opt-out, so only a value Go's `strconv.ParseBool` reads as false (`0`, `f`, +`false`, `off`, `n`, `no`) restores exit 0. An unrecognised value is deliberately +not the opt-out — `TREEHOUSE_EXIT_STRICT=1` left over from 0.1.x keeps exit 3, +because handing the escape hatch to a typo is the one direction that can +silently under-report a slot that was never released. + +#### `--env-path` is now an alias of `--root` + +`--env-path ` used to be accepted and ignored, falling back to +`~/.treehouse`. It is now a visible alias of the global `--root` flag, so it +resolves to `/.treehouse` (`/.treehouse` for a relative `DIR`). +Precedence is `--root` > `TREEHOUSE_ROOT` > `treehouse.toml` `root` > +`~/.treehouse`, matching Go. + +### Fixed + +Four paths could destroy work a user had not asked treehouse to destroy. + +- **A non-interactive confirmation no longer reads as consent.** At stdin EOF + `read_line` returns `Ok(0)`, not `Err`, so `treehouse return + < /dev/null` used to take the documented `[Y/n]` bare-Enter default and clean + uncommitted work. The bytes actually read now decide the outcome, and EOF is + a refusal. This is the fix with the widest blast radius — it can turn a script + that used to succeed into one that stops. +- **An unlanded-commit guard now runs before reset.** A worktree holding commits + that exist nowhere else was eligible to be reset away. `is_worktree_safe_to_reset` + refuses unless the worktree is verified clean, and an unverifiable worktree is + refused rather than assumed safe. +- **Pools are scoped by clone identity, not by origin URL alone.** Two clones of + one origin used to resolve to the same pool and contend for the same slots. + The requester's clone identity is resolved once, before the state lock, and a + slot owned by a different clone is classified `OtherClone` and skipped rather + than handed out. An identity that cannot be read is `Unverified` and is also + skipped — never guessed at. +- **Destructive git calls carry a marker precondition.** `require_worktree_marker` + gates the operations that rewrite a checkout, so a path that is not a + treehouse worktree fails closed instead of being operated on. A marker that + cannot be resolved — a dangling symlink, for instance — is a read failure, not + an absent marker. + +### Added + +#### VCS seam + +The `GitBackend` trait every backend must implement, with a registry, per-marker +backend resolution (`.git` → git, `.jj` → jj), and `destructive_backend_for_worktree` +as the single dispatch point for operations that rewrite a checkout. A path +carrying no recognised marker is refused by name rather than answered by git. +Every trait method has a default that refuses with a clear "the +backend does not support ", so adding a backend needs no trait edit +and no dispatch change. The jj backend is registered in the same registry and +implements 21 of the trait's 24 methods; see "Known gaps" for the three it leaves +on their defaults and for why none of that has been run against a real jj yet. + +#### Worktree seeding + +`GitBackend::seed_worktree` copies backend-specific ignored files into a new or +recycled worktree and returns the paths it wrote, so cleanup trusts that +inventory instead of re-reading mutable worktree metadata. The git backend reads +a committed `.worktreeinclude` manifest from the destination worktree's HEAD. +`.worktreeinclude` can never widen what gets copied into a worktree: it can only +authorize paths that are already ignored, and an uncommitted manifest authorises +nothing. + +#### Commands + +- `treehouse lease ` — durably lease an existing worktree by name without + touching it. +- `treehouse doctor [--strict]` — read-only health report; `--strict` treats any + warning as a failure. +- `treehouse gc [--all] [--prune-orphans] [-v] [--yes]` — reclaim stale, + orphaned, and dead-owner worktrees. Dry-run by default. +- `treehouse run [--ttl ] [--lease-holder ] -- ` — acquire, run a + command in the worktree, and guarantee cleanup on every exit. Everything from + the first non-flag token on is the command, so its own flags are never eaten + by treehouse's parser. + +#### Flags + +- `--format human|json|toon`, global, on agent-facing commands. `toon` is a + token-oriented encoding for the list-shaped output `status` and + `lease --json` produce. **It is not smaller than `json` here** — on a measured + `status` payload the `toon` rendering came out *larger* (335 B against 319 B + for one slot, on a pool with long paths; 291 B against 275 B on another). The + absolute counts move with pool path length, but the direction did not across + either fixture: `toon` lost by a consistent ~16 bytes. The usual "40–60% + smaller" folklore does not hold for this payload, so `json` is the format to + benchmark against and `toon` is worth reaching for only after measuring your + own. +- `--root `, global (see the breaking change above). +- `get --lease`, `--lease-holder `, `--ttl `, `--json` — non-interactive + acquire that prints only the path. +- `get --base ` — cut from this branch instead of the repository default. + Distinct from `-b/--branch`, which *creates* a branch; they are kept separate + all the way down, because `--base main -b main` otherwise reads as "create the + branch the worktree is already on". +- `get --no-fetch` — skip the origin fetch, for offline and air-gapped use. +- `get --worktree-path