diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 202610d..b88e55c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -57,7 +57,42 @@ jobs:
# Build the binary the e2e harness spawns BEFORE running tests.
- name: build treehouse binary
run: cargo build --bin treehouse
+ # --no-fail-fast is load-bearing, not a preference. cargo otherwise aborts
+ # at the FIRST failing test target, so a failure in an early target hides
+ # every later one. That is not hypothetical: the 2 failures in the
+ # `treehouse` bin target meant treehouse-core's 17 lib tests never ran on
+ # Windows at all, and the 2 lib failures meant the update_e2e integration
+ # tests never ran on Linux — all of it green-looking for months. Every
+ # target must run before this job can be called green.
- name: test workspace
- run: cargo test --workspace
+ run: cargo test --workspace --no-fail-fast
- name: test with hardening feature
- run: cargo test -p treehouse-core --features hardening
+ run: cargo test -p treehouse-core --features hardening --no-fail-fast
+
+ # `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 --no-fail-fast
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 ` — place a new worktree at a templated path
+ (`{pool}`, `{slot}`, `{repo}`, `{repo_parent}`); overrides `--unique-leaf`.
+- `get --include-file ` — replace the committed `.worktreeinclude` with
+ this file for this acquisition. An empty file seeds nothing and does *not*
+ fall back to the committed manifest.
+- `get --apfs-sharing off|fresh` — share tracked file data with the source
+ checkout on macOS/APFS. Defaults to `$TREEHOUSE_APFS_SHARING`, then `off`.
+ Every failure is a recorded skip, never a fatal error. Note the symlinked
+ pool-root limitation in "Known gaps".
+- `return ` — the path argument now also accepts a slot name as printed by
+ `treehouse status`.
+- `return --all` — return every held worktree in this repository's pool, leaving
+ alone slots nobody holds.
+- `return --if-lease-id ` / `--if-lease-holder ` — return only if the
+ current lease matches, so a second agent cannot return a worktree out from
+ under its holder.
+
+#### Self-update
+
+`treehouse update` resolves this platform's asset from the latest GitHub
+release, verifies it against the published `.sha256` sidecar, and replaces the
+running binary atomically — staged temp file in the target's own directory, then
+rename — so an interrupted update can never leave a half-written executable at
+the target path. macOS quarantine is cleared before the replace. A missing or
+unreadable checksum fails closed: the binary is not replaced. Version checks run
+in the background and are cached, so a machine that is offline is not blocked.
+The `release_workflow_contract` tests in `updater.rs` hold this honest: they
+parse `.github/workflows/release.yml` and assert that every asset it publishes
+has a sidecar name the updater accepts, which is the claim that was false until
+this release.
+
+### Fixed (self-update)
+
+- **The checksum sidecar is now found.** The updater looked for
+ `.sha256` (for example
+ `treehouse-v0.2.0-linux-x86_64.tar.gz.sha256`) while
+ `.github/workflows/release.yml` publishes `.sha256` (for example
+ `treehouse-v0.2.0-linux-x86_64.sha256`), so every platform failed closed with
+ "no checksums file in release assets". The updater now accepts both spellings,
+ preferring the exact one, so no existing release has to be re-uploaded. A
+ `release_workflow_contract` test module in `updater.rs` reads the real
+ workflow and proves every asset it publishes has a sidecar the updater will
+ look for; a fixture could not, because it would stay green after a rename.
+- The update pipeline refuses to proceed without a checksum, rather than
+ installing an unverified binary and reporting success.
+
+### Known gaps in 0.2.0
+
+Named here because each has a doc comment or a module that reads as finished.
+None of them change existing behavior.
+
+- **The jj backend is implemented and registered, and has never been run against
+ a real jj.** `vcs/jj.rs` implements 21 of the trait's 24 methods behind a
+ `JjRunner` seam, and `vcs/mod.rs` registers it unconditionally. Registration
+ is deliberately infallible: a machine with no `jj` binary still gets a
+ registered backend, so a `.jj` marker fails with `jj binary not found on PATH
+ (set JJ_BIN)` rather than a configuration-shaped error. Because no `jj` binary
+ exists on the build machine, all of its coverage is unit tests against a
+ scripted runner — **jj's own output formats are unverified by this
+ repository's suite.**
+- **Worktree creation bypasses the seam.** Reset (`pool.rs:731`,
+ `pool.rs:968`) and dirty checks dispatch on the worktree's marker; creation
+ calls `git.worktree_add` directly (`pool.rs:1230`). The pool also resolves its
+ repository root through `ShellGitBackend` (`pool.rs:472`), so a `.jj`-only
+ directory fails there before creation is reached — as of this build
+ `TREEHOUSE_VCS=jj treehouse get --lease` exits 1 with
+ `git git rev-parse --show-toplevel: fatal: not a git repository (or any of
+ the parent directories): .git`. Fail-closed, but the jj acquisition path is
+ unreachable rather than merely incomplete, and the message names git rather
+ than jj. Root resolution is being moved behind the seam; when it lands the
+ message becomes a refusal that names the jj backend and points at `JJ_BIN`.
+- **Seeding is not implemented for jj.** `seed_worktree` and
+ `reset_worktree_with_seeded_paths` keep the trait's refusing default, so a jj
+ workspace cannot use `.worktreeinclude`. The third inherited default,
+ `resolve_for_worktree`, dispatches back through the seam and costs nothing. jj
+ implements the rest, including the guarded-reset pair.
+- **VCS selection reads `TREEHOUSE_VCS` only.** The `vcs` key in
+ `treehouse.toml` and in `~/.config/treehouse/config.toml` is not consulted,
+ because this port has no `vcs` field on its config type.
+- **APFS sharing never engages through a symlinked pool root, and on macOS that
+ includes `/tmp` and the default `$TMPDIR` (`/var/folders/…`).** The share
+ opens the destination with `O_NOFOLLOW` on every path component, so a symlink
+ between the pool root and the slot is a skip, not a redirection. Reproduced
+ on one repository and volume with two pool roots differing only in symlinks:
+ the `/var/folders/…` root printed `APFS sharing skipped: destination path
+ unavailable or symlinked: Not a directory (os error 20)`; the canonical
+ `/private/var/…` root printed `APFS sharing: cloned=1 logical_bytes=200000
+ private_data_reduced_bytes=200704 below_threshold=1`. The refusal is correct
+ but silent, so a symlinked pool root reads as a broken feature.
+
+[Unreleased]: https://github.com/quangdang46/treehouse_rust/compare/v0.2.0...HEAD
+[0.2.0]: https://github.com/quangdang46/treehouse_rust/compare/v0.1.2...v0.2.0
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
index ff73908..8d9fce8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2550,7 +2550,7 @@ dependencies = [
[[package]]
name = "treehouse"
-version = "0.1.2"
+version = "0.2.0"
dependencies = [
"anyhow",
"chrono",
@@ -2568,12 +2568,13 @@ dependencies = [
[[package]]
name = "treehouse-core"
-version = "0.1.2"
+version = "0.2.0"
dependencies = [
"chrono",
"fd-lock",
"flate2",
"humantime",
+ "libc",
"nix",
"owo-colors",
"rand",
diff --git a/Cargo.toml b/Cargo.toml
index 9f7d868..9bd4ce5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/treehouse", "crates/treehouse-core"]
[workspace.package]
-version = "0.1.2"
+version = "0.2.0"
edition = "2024"
rust-version = "1.88"
license = "MIT"
@@ -38,3 +38,10 @@ zip = { version = "8.6.0", default-features = false, features = ["deflate"] }
# OpenAI/Anthropic Rider (NOT plain MIT — see docs/rust-port-plan.md §3/§5.4).
# Pinned to an immutable rev so a force-push on upstream can't break us.
tru = { git = "https://github.com/Dicklesworthstone/toon_rust", rev = "cb256dcf73ab78c248a14f65840a3fa722ec8682" }
+# Raw Darwin syscalls for APFS clonefile sharing (crates/treehouse-core/src/fileclone/mod.rs):
+# fclonefileat, the getattrlist xattr/ACL readers, and the openat(O_NOFOLLOW) walk
+# that pins directory descriptors. Already in the lockfile as a transitive dependency
+# of tempfile/nix; declared here so treehouse-core can name it directly instead of
+# reimplementing the wrappers. Target-gated to macOS at the member — the module's
+# non-macOS build is a no-op and must not pull a Unix syscall surface into it.
+libc = "0.2.189"
diff --git a/README.md b/README.md
index 759d786..251ac0e 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ irm "https://raw.githubusercontent.com/quangdang46/treehouse_rust/main/install.p
## 🤖 Agent Quickstart
-treehouse is built for coding agents and the orchestrators that spawn them. If you are an agent or a script driving agents, **always use `--format json` (or `--toon`) — never the interactive subshell.**
+treehouse is built for coding agents and the orchestrators that spawn them. If you are an agent or a script driving agents, **always use `--format json` (or `--format toon`) — never the interactive subshell.**
**Output contract**
@@ -43,28 +43,37 @@ treehouse is built for coding agents and the orchestrators that spawn them. If y
|--------|----------|
| `stdout` | Data only: worktree path, JSON, or TOON |
| `stderr` | 🌳 human banners, warnings, prompts |
-| `exit 0` | Success (including declined cleanup prompts) |
+| `exit 0` | Success |
| `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 3` | A dirty worktree was left unreturned |
-### Exit code 3 (opt-in)
+### Exit code 3
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.
+released" was reading a bug. treehouse now matches Go.
-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 return "$PATH" < /dev/null
+# exit 3 + "worktree not returned ... prune will not reclaim this slot"
+```
+
+If you have scripts running under `set -e` that predate the flip, opt out for one
+minor release:
```bash
-TREEHOUSE_EXIT_STRICT=1 treehouse return "$PATH"
-# exit 3 + "worktree not returned ... uncommitted changes were kept"
+TREEHOUSE_EXIT_STRICT=0 treehouse return "$PATH"
+# exit 0 + "Aborted."
```
-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`.
+Only an explicit falsy value opts out (`0`, `false`, `off`, `n`, `no`, `f`).
+Anything else — including a leftover `TREEHOUSE_EXIT_STRICT=1` from the old
+opt-in era, and an unparseable value — keeps the `3`. A typo must never
+silently downgrade to "the slot was released".
+
+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
@@ -72,7 +81,7 @@ PATH=$(treehouse get --lease --ttl 30m --lease-holder agent-42)
# 2) Or the full allocation as JSON
ALLOC=$(treehouse get --lease --ttl 30m --lease-holder agent-42 --json)
-# {"path":"/home/you/.treehouse/acme-3f2a1b/1/acme","lease_id":"9f2c…c6d7","lease_holder":"agent-42","leased_at":"2026-08-14T12:34:56.123456789-07:00"}
+# {"path":"/home/you/.treehouse/acme-3f2a1b/1/acme","lease_id":"9f2c…c6d7","lease_holder":"agent-42","leased_at":"2026-08-14T19:34:56.123456789Z"}
# 3) Run your agent inside it; cleanup is guaranteed on EVERY exit path
treehouse run -- claude -p "implement the pagination fix" "$PATH"
@@ -89,7 +98,9 @@ treehouse status --json
**Why this matters:** if your agent crashes, forgets to return, or gets SIGKILLed, the TTL lease expires and a later `treehouse gc --all --yes` reclaims the worktree *only if it is idle, clean, and merged*. A live agent is never evicted. No more 20 orphaned worktrees on your disk.
-Agent instructions (storage, flags, gotchas) are in [AGENTS.md](AGENTS.md).
+Agent instructions (storage, flags, gotchas) are in the sections below:
+[Commands](#commands), [Output formats](#output-formats), [Agent workflows](#agent-workflows),
+and [Limitations](#limitations).
---
@@ -123,7 +134,7 @@ exit # worktree is reset and returned to the pool
| **TTL leases** | `--ttl 30m` makes the lease self-expiring — a crashed agent can't hold a tree forever |
| **Safe `gc`** | Reclaims stale, orphaned, and dead-owner trees; dry-run by default; a valid lease is never touched |
| **Cleanup-always `run`** | `treehouse run -- ` returns the tree on every exit path, including signals |
-| **Machine-readable** | `--format json` / `--toon` on every agent-facing command |
+| **Machine-readable** | `--format json` / `--format toon` on every agent-facing command |
| **`doctor`** | 12 read-only health checks; answers "why is this worktree still here?" |
| **Safe destruction** | `destroy` and `prune` share one classifier; each risk class is its own opt-in flag |
@@ -137,7 +148,7 @@ exit # worktree is reset and returned to the pool
| In-use detection (conflict-free) | ✅ | ❌ | ❌ |
| Durable lease (survives zero processes) | ✅ | ❌ | ❌ |
| Crash / stale-tree cleanup | ✅ `gc` | `git worktree prune` (low-level) | ❌ |
-| Machine-readable output for agents | ✅ `--json` / `--toon` | ❌ | ❌ |
+| Machine-readable output for agents | ✅ `--format json` / `--format toon` | ❌ | ❌ |
| Automatic `return` on any exit | ✅ `run` | ❌ | ❌ |
| Safe dry-run destruction | ✅ | ❌ | ❌ |
@@ -152,7 +163,8 @@ curl -fsSL "https://raw.githubusercontent.com/quangdang46/treehouse_rust/main/in
# Windows PowerShell
irm "https://raw.githubusercontent.com/quangdang46/treehouse_rust/main/install.ps1" | iex
# or from source / via cargo (any platform)
-cargo install --git https://github.com/quangdang46/treehouse_rust --tag v0.1.2 treehouse
+# `--features toon` is required: without it `--format toon` falls back to JSON.
+cargo install --git https://github.com/quangdang46/treehouse_rust --tag v0.2.0 --features toon treehouse
# 2. From inside a repo, get a worktree and a subshell
cd myproject
@@ -205,9 +217,64 @@ exit
| `--lease` | Durably lease instead of opening a subshell; path-only on stdout |
| `--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` |
+| `--json` | Print `path`, `lease_id`, `lease_holder`, `leased_at` as JSON; requires `--lease`. These four fields are the whole payload — the TTL is not echoed back, so an agent that needs the expiry must compute it from `leased_at` and the `--ttl` it passed |
| `--base ` | Cut from this branch instead of the repository default (does **not** create the branch) |
+| `-b`, `--branch ` | Create and check out this **new** branch at the acquired commit. Distinct from `--base`, which only changes where the worktree is cut *from*. Fails if the branch already exists |
| `--no-fetch` | Skip the `git fetch origin` that normally runs before acquiring (offline / air-gapped) |
+| `--unique-leaf` | Name a newly created worktree directory `-` instead of ``. Defaults to `$TREEHOUSE_UNIQUE_LEAF` |
+| `--worktree-path ` | Template for a newly created worktree's directory, e.g. `{pool}/{slot}/{repo}`. Supported placeholders: `{pool}`, `{slot}`, `{repo}`, `{repo_parent}`. Overrides `--unique-leaf`. Defaults to `$TREEHOUSE_WORKTREE_PATH` |
+| `--include-file ` | Replace the committed `.worktreeinclude` with this file for **this** acquisition. An empty file seeds nothing — it does not fall back to the committed manifest |
+| `--apfs-sharing ` | On macOS/APFS, share tracked file data with the source checkout instead of copying it. Defaults to `$TREEHOUSE_APFS_SHARING`, then `off`. Never fails an acquisition — see [Limitations](#limitations) for the symlinked-pool-root case |
+
+Every flag above applies to **both** acquisition modes — `get` and `get --lease`
+route through one option set, so nothing is silently dropped by `--lease`.
+
+#### Backend selection (`jj`)
+
+The backend is chosen per repository, and **git is the default**. A `.jj`
+directory alone does not select jj; jj must be opted into explicitly, and the
+environment variable is the **only** way to opt in:
+
+```
+TREEHOUSE_VCS=jj > git
+```
+
+There is no `vcs` key in `treehouse.toml` or `~/.config/treehouse/config.toml` —
+the config type has no such field, so writing one is silently ignored rather than
+honoured. (See [Limitations](#limitations).)
+
+`--branch` and `-b` create a *git* branch, so they are **refused** against a jj
+workspace rather than silently ignored — but only once the repository root
+resolves, which today means the checkout has to be readable by git:
+
+```console
+$ TREEHOUSE_VCS=jj treehouse get -b feat/x # in a checkout git can resolve
+--branch is only supported by the git backend (cannot create "feat/x" in a jj workspace)
+```
+
+In a `.jj`-**only** directory the root never resolves, so the git error below
+happens first and you never reach the branch check. See the status note.
+
+> **Status of the jj backend, plainly.** It is implemented and registered —
+> `vcs/jj.rs` implements 21 of the `GitBackend` trait's 24 methods — but it has
+> **never been run against a real jj repository.** All of its test coverage is
+> unit tests against a faked command layer, so jj's own output formats are
+> unverified here. Three concrete gaps:
+>
+> - Only the `TREEHOUSE_VCS` tier is honoured. `vcs = "jj"` in either config
+> file needs a `vcs` field on the config layer, which does not exist.
+> - Worktree **creation** does not go through the seam — it calls
+> `git.worktree_add` directly. The pool also resolves its repository root
+> through git, so in a `.jj`-only directory `TREEHOUSE_VCS=jj treehouse get
+> --lease` currently exits 1 with
+> `git git rev-parse --show-toplevel: fatal: not a git repository` before
+> creation is ever reached. It fails closed; the exact string is changing as
+> root resolution moves behind the seam, so don't script against it.
+> - `.worktreeinclude` seeding is not implemented for jj; those two trait
+> methods keep the refusing default.
+>
+> A missing `jj` binary is reported as itself: `jj binary not found on PATH
+> (set JJ_BIN)`, not as a configuration error.
### `status`
@@ -267,6 +334,8 @@ treehouse lease 3 --lease-holder agent-42 --json
|------|-------------|
| `--root ` | Custom pool root; available on every command. Aliased as `--env-path`. |
| `TREEHOUSE_ROOT` | Same thing, from the environment |
+| `TREEHOUSE_EXIT_STRICT=0` | Opt **out** of the default exit `3` on an unreturned dirty worktree; restores exit `0`. See [Exit code 3](#exit-code-3) |
+| `TREEHOUSE_VCS=jj` | Select the jj backend for this repository. Git is the default |
Precedence, highest first:
@@ -342,21 +411,26 @@ treehouse status --format human # default
[{"name":"1","path":"/home/you/.treehouse/acme-3f2a1b/1/acme","status":"available","lease_id":"","lease_holder":"","leased_at":null,"processes":[]}]
```
-**TOON** — the same data, compacted for token-conscious LLM context:
+**TOON** — the same data, in a list-shaped encoding for LLM context:
```
[1]:
- - name: 1
+ - name: "1"
path: /home/you/.treehouse/acme-3f2a1b/1/acme
- status: available
- lease_id: ""
+ status: leased
+ lease_id: f0b318a86692af4e4cddd732ebca0941
lease_holder: ""
- leased_at: null
- processes: []
+ leased_at: "2026-10-01T16:33:44.985070+00:00"
+ processes[0]:
```
+Quoting is the encoder's, not yours — a value is quoted when unquoted would be
+ambiguous, so `processes[0]:` is an empty list rendered by key, not a slice.
+
`human` keeps the 🌳 banners on `stderr` and the table on `stdout`, so scripts that capture `stdout` are safe in every mode.
+The TOON encoder is behind the opt-in `toon` feature and is enabled in the release build, so every binary you download emits the output above. A binary you build yourself needs `cargo build --features toon` (see [Development](#development)); without it `--format toon` still succeeds but prints JSON.
+
---
## Agent workflows
@@ -558,6 +632,19 @@ A lease survives with zero running processes; it is never cleared by the self-he
- **Windows termination is abrupt.** There is no graceful SIGTERM on Windows; processes are terminated via `TerminateProcess`.
- **TTL + a live agent:** a long-running agent past its TTL is never evicted (the in-use check protects it); only *idle* stale trees are reclaimed.
- **Recovered-from-corrupt trees hold disk until verified** — deliberate conservatism.
+- **APFS sharing needs a non-symlinked pool root, and on macOS the obvious ones are symlinked.** `--apfs-sharing fresh` opens the destination with `O_NOFOLLOW` on every path component, so a symlink between the pool root and the slot is a skip. `/tmp` and `/var` are symlinks to `/private`, which means `--root "$TMPDIR/..."` skips on the default macOS temp directory. Reproduced on one repo and volume:
+
+ ```console
+ --root "$TMPDIR/pool" # APFS sharing skipped: destination path unavailable
+ # or symlinked: Not a directory (os error 20)
+ --root "$(cd "$TMPDIR" && pwd -P)/pool"
+ # APFS sharing: cloned=1 logical_bytes=200000
+ # private_data_reduced_bytes=200704 below_threshold=1
+ ```
+
+ The refusal is deliberate — a slot must not be redirectable — but it is silent, so it reads as a broken feature. Pass a canonical path if you want sharing.
+- **jj is implemented but unproven.** The backend is registered and implements 21 of 24 trait methods, but no test in this repository has run it against a real jj binary, and worktree creation plus the pool's repository-root resolution still go through git. A `.jj`-only checkout therefore fails closed rather than operating. See [Backend selection (`jj`)](#backend-selection-jj).
+- **`toon` is not smaller than `json` here.** Measured on a real `status` payload, `toon` came out larger. `--format json` is the default recommendation.
---
@@ -582,7 +669,12 @@ Yes. Trees are returned to the pool, not deleted, so `node_modules`, `target/`,
**Stale** = treehouse manages it, it's idle, and its HEAD is merged — safe to reclaim. **Orphan** = the backing repository's git metadata is gone; treehouse can't verify its contents, so it's only removed with an explicit `--prune-orphans --yes`.
### Which output format should my orchestrator use?
-`--format json` for stable, machine-parseable output. `--format toon` when feeding structured data into an LLM context and you want ~40–60% fewer tokens.
+`--format json`. It is the format to benchmark against: on real `status` payloads
+`toon` rendered *larger* than JSON — 291 B vs 275 B for one slot on one pool,
+335 B vs 319 B on another with longer paths. The absolute counts scale with pool
+path length, but the direction held on every fixture measured (~16 bytes the
+worse), so the usual "TOON is 40–60% smaller" folklore does not hold here. Reach
+for `--format toon` only after measuring your own payload and seeing it win.
---
@@ -595,7 +687,21 @@ cargo clippy --all-targets # lint
cargo fmt --check # formatting
```
-This is a Rust port of [kunchenguid/treehouse](https://github.com/kunchenguid/treehouse) (Go v2.1.1 is the behavioral reference). The workspace is two crates: `crates/treehouse` (CLI) and `crates/treehouse-core` (the pool/state/git/process library). The port plan is in [docs/rust-port-plan.md](docs/rust-port-plan.md); implementation is tracked as dependency-linked beads (see `.beads/`).
+The TOON encoder (`--format toon`) lives behind an opt-in `toon` feature, because it
+pulls a pinned git dependency and a default-features build should still work offline.
+It is **enabled in the release build**, so every binary downloaded from a GitHub
+release emits real TOON; anything you build yourself has to ask for it:
+
+```sh
+cargo build --bin treehouse --features toon
+cargo test --bin treehouse --features toon
+```
+
+Without the feature the CLI still runs `--format toon`, but prints JSON. See
+[docs/toon-dependency-verification.md](docs/toon-dependency-verification.md) for what
+was pinned and why.
+
+This is a Rust port of [kunchenguid/treehouse](https://github.com/kunchenguid/treehouse). The gap audit in [CHANGELOG.md](CHANGELOG.md) is against upstream **v3.1.0**; the behavioral reference for the pool/state/lease core is earlier Go (v2.x). The workspace is two crates: `crates/treehouse` (CLI) and `crates/treehouse-core` (the pool/state/git/process library). The port plan is in [docs/rust-port-plan.md](docs/rust-port-plan.md); implementation is tracked as dependency-linked beads (see `.beads/`).
---
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
new file mode 100644
index 0000000..e699a5a
--- /dev/null
+++ b/RELEASE_NOTES.md
@@ -0,0 +1,388 @@
+# treehouse 0.2.0
+
+Worktrees for parallel coding agents, ported from Go. This release closes 27
+audited gaps against upstream v3.1.0, four of which could destroy uncommitted
+work, and adds the VCS seam, four commands, and structured output for agents.
+
+This is a `0.x` minor, so it carries breaking changes. Two of them will bite
+scripts.
+
+---
+
+## Read this first: three changes that affect how you run treehouse
+
+### 1. Exit code 3 is now the default
+
+When `treehouse return` leaves a dirty worktree unreturned — because you
+declined the clean, or because the confirmation could not be answered — it now
+**exits 3** instead of 0. The slot stays held either way; only the status
+changes.
+
+0.1.2 exited 0 here, which meant a script doing this:
+
+```bash
+treehouse return "$p" && deploy
+```
+
+would run `deploy` against a worktree that still has your uncommitted changes in
+it. Exit 3 is what Go shipped in v3.0.0 for exactly that reason, and this port
+had been holding exit 0 behind `TREEHOUSE_EXIT_STRICT=1` while the contract was
+still settling. That wait is over.
+
+**If you have `set -e`, or any `&&` chain keyed on treehouse's exit status,
+this changes your control flow today.** A script that used to continue will now
+stop. That is the intended fix, but it is still a stop.
+
+To get the old behavior back for one minor release:
+
+```bash
+export TREEHOUSE_EXIT_STRICT=0
+```
+
+Note the **polarity flipped**. It used to be opt-in (`=1` enabled the new
+behavior); it is now opt-out (only a false value restores exit 0). If you set
+`TREEHOUSE_EXIT_STRICT=1` back in 0.1.x, leave it — you get exit 3 either way.
+Only `0`, `f`, `false`, `off`, `n`, or `no` restore exit 0. Anything else,
+including a typo, keeps the strict default. We would rather a fat-fingered
+variable name leave a slot reported as held than silently unreleased.
+
+### 2. `--env-path` changed meaning
+
+`--env-path ` used to be **accepted and silently ignored** — the pool went
+to `~/.treehouse` regardless of what you passed. It is now an alias of the
+global `--root` flag, so it resolves to `/.treehouse`:
+
+```bash
+treehouse --env-path ~/scratch get # pool at ~/scratch/.treehouse
+treehouse --root . get # pool at /.treehouse
+```
+
+Resolution order is `--root` > `TREEHOUSE_ROOT` > `treehouse.toml` `root` >
+`~/.treehouse`, matching Go. A relative path resolves from the repository root,
+so `--root .` gives you an in-project pool.
+
+If you were passing `--env-path` and it had no effect, it now has one — which is
+the point, but it does mean a script that passed it harmlessly will suddenly use
+a different pool than before.
+
+### 3. Four data-loss fixes
+
+These were the reason the release exists. In every case the old behavior was
+"assume the user meant yes, or assume the path was safe."
+
+**A non-interactive confirmation no longer counts as consent.** `treehouse
+return < /dev/null` used to take the documented `[Y/n]` bare-Enter
+default and **clean away your uncommitted work**. The prompt reads as though
+Enter means yes because that is the safe convention, but with no one there to
+answer, "yes" was not an answer. EOF is now a refusal:
+
+```bash
+treehouse return "$p" < /dev/null # refuses, exits 3, keeps your changes
+treehouse return "$p" --force # what you actually wanted in a script
+```
+
+**Unlanded commits block a reset.** A worktree holding commits that exist
+nowhere else — not pushed, not on any other branch — was eligible to be reset
+away. A worktree is now verified safe before anything rewrites it, and one that
+cannot be verified is refused rather than assumed.
+
+**Two clones of one origin no longer share a pool slot.** Slots were keyed by
+origin URL, so cloning a repository twice gave you two working trees competing
+for the same slot directory. The requesting clone's identity is now resolved
+before the pool lock is taken, and a slot belonging to a different clone is
+skipped. If identity cannot be read, the slot is skipped too — never guessed.
+
+**Destructive git calls now require proof the path is a worktree.** Before
+resetting or rewriting a checkout, treehouse verifies it is actually one of its
+own worktrees. An unrelated directory is refused instead of operated on, and a
+marker that cannot be read — a dangling symlink, a damaged checkout — is treated
+as a failure to verify, not as an absent marker.
+
+### Not data loss, but the same class of "the flag did nothing"
+
+**`destroy --include-in-use` now terminates, on Linux and on Windows.** After
+killing the processes in a worktree, the survivor re-scan still found the pid it
+had just killed, so destroy skipped with *"worktree processes still running
+after termination"* and `--include-in-use` was a no-op.
+
+The cause was in the process table, not the kill: sysinfo refreshes a process's
+`cwd` only when that field is currently unset (`ProcessRefreshKind::everything()`
+uses `UpdateKind::OnlyIfNotSet`), so it never re-reads a cwd it already holds.
+On Linux a killed child survives as a zombie, its `/proc//cwd` disappears,
+and the cached path — the worktree it died in — is what destroy saw. A zombie
+has already terminated: it runs nothing and blocks no removal. Windows has the
+same symptom from the other direction — it keeps a terminated process in the
+table until the last handle to it closes, and sysinfo's Windows backend assigns
+`status` exactly once (`ProcessStatus::Run`) and never updates it, so a zombie
+check alone can never fire. Both are now excluded from the scan; on Windows that
+asks the OS directly via `GetExitCodeProcess`, failing closed if the process
+cannot be opened.
+
+**A seed inventory could name a path outside the worktree on Windows.** The
+inventory is git's path language — every separator a `/`, on every platform — so
+"absolute" there means "starts with `/`", which is what Go checks with
+`path.IsAbs`. The port used `Path::is_absolute()` instead, and on Windows that
+is `has_root() && prefix().is_some()`: a rooted-but-prefixless name like
+`/etc/passwd` is not absolute, so it passed validation, and
+`worktree.join("/etc/passwd")` then dropped everything after the drive prefix
+and resolved to `C:\etc\passwd`. That turned a gate on deleting ignored files
+*inside* a worktree into one that could name files outside it. Now rejected on
+every platform.
+
+### Why some of this is only being fixed now
+
+`ci.yml` ran `cargo test --workspace` **without `--no-fail-fast`**, so cargo
+stopped at the first failing test target and every later target reported nothing
+at all. On Windows the `treehouse` bin target failed first, which meant
+`treehouse-core`'s lib tests — 17 of them, spanning pool, state, updater and the
+VCS seam — **never ran on Windows**. On Linux the lib target failed first, which
+meant the `update_e2e` integration tests never ran there either. And
+`--features hardening` did not compile, behind a step that only ran once the
+step before it passed, so the hardening tests had never been built. All of it
+looked green. `--no-fail-fast` is now set on every test step.
+
+---
+
+## New capabilities
+
+### A VCS seam
+
+Every git operation now goes through one trait with a backend registry, so
+adding a second VCS means implementing a trait rather than rewriting the pool.
+Paths are resolved to a backend by marker: `.git` means git, `.jj` means jj, and
+a path carrying neither is **refused by name** rather than quietly answered by
+git — a misroute is a binary built for the wrong thing.
+
+Every trait method carries a default that refuses with a clear message, so a new
+backend needs no dispatch-site changes.
+
+### APFS file-cloning sharing
+
+On macOS, seeding a worktree can share the file's data blocks with the source
+instead of copying them, via the kernel's `clonefile`. Files below 64 KB are
+skipped — the per-file setup costs more than the duplicate bytes save, and small
+files are most of a checkout. Every failure is a recorded skip, never a fatal
+error, so an acquisition never fails because sharing could not happen.
+
+```bash
+treehouse get --lease --apfs-sharing fresh # off (default) or fresh
+```
+
+> **It does not engage through a symlinked pool root, and on macOS that
+> includes the default `TMPDIR`.** The share opens the destination with
+> `O_NOFOLLOW` on every path component, so any symlink between the pool root and
+> the slot is a refusal, not a redirection. macOS makes `/tmp` and `/var` (and
+> therefore `$TMPDIR`, `/var/folders/…`) symlinks to `/private`, so a pool under
+> a temp directory skips. Verified: the same repository, same volume, two pool
+> roots differing only in symlinks —
+>
+> ```
+> --root "$TMPDIR/pool" # APFS sharing skipped: destination path unavailable
+> # or symlinked: Not a directory (os error 20)
+> --root /private/var/…/pool
+> # APFS sharing: cloned=1 logical_bytes=200000
+> # private_data_reduced_bytes=200704 below_threshold=1
+> ```
+>
+> A symlinked pool root is the safe answer anyway — the slot must not be
+> redirectable — but the skip is silent and looks like the feature is broken. If
+> you want sharing, give `--root` a canonical path (`cd "$dir" && pwd -P`).
+> Off macOS it is a correct no-op with the same "skipped" line.
+
+### `.worktreeinclude` seeding
+
+Commit a `.worktreeinclude` manifest and treehouse copies those paths into every
+new or recycled worktree — the `.env` files and local config that gitignore
+keeps out of the checkout but that every agent needs. `get --include-file `
+replaces the committed manifest for one acquisition.
+
+It cannot be used to smuggle tracked content in: the manifest can only
+authorize paths that are **already ignored**, and an uncommitted `.worktreeinclude`
+authorizes nothing. Both halves are observable:
+
+```console
+$ treehouse get --lease # .worktreeinclude written but uncommitted
+$ ls "$SLOT"/local.yml # no such file — nothing was seeded
+$ git add .worktreeinclude && git commit
+$ treehouse get --lease && ls "$SLOT"/local.yml
+local.yml
+```
+
+An empty `--include-file` selects nothing; it does **not** fall back to the
+committed manifest.
+
+### Four new commands
+
+```bash
+treehouse lease # durably lease a worktree, don't touch it
+treehouse doctor # read-only health report
+treehouse doctor --strict # treat any warning as a failure
+treehouse gc # reclaim stale / orphaned / dead-owner slots (dry-run)
+treehouse gc --yes --all # actually do it, everywhere
+treehouse run -- claude # acquire, run, guarantee cleanup on every exit
+```
+
+`run` is the one for agents: everything from the first non-flag token on is your
+command, so its own flags are never eaten by treehouse's parser, and the worktree
+is returned whether the command exits 0, exits non-zero, or is killed.
+
+`lease` is the one for orchestration: it hands you a worktree without entering it,
+so a scheduler can queue work without holding a subshell open.
+
+### Structured output for agents
+
+`--format json|toon` is global and applies to agent-facing commands.
+
+```bash
+treehouse status --format json
+treehouse status --format toon # same data, cheaper to tokenize
+```
+
+`toon` is a token-oriented encoding — list-shaped payloads stay flat and
+delimiting punctuation mostly disappears, which matters when an agent is reading
+the output. We have not published a token-size figure, so measure it against
+your own payloads before committing to the switch. `human` remains the default,
+so nothing changes unless you ask.
+
+> **Measured, not claimed:** on a real `treehouse status` payload the `toon`
+> rendering came out *larger* than the JSON one — 291 B against 275 B for one
+> slot, 2358 B against 2265 B for eight; re-measured on a second pool with
+> longer paths, 335 B against 319 B. The absolute counts move with pool path
+> length, but the direction did not across either fixture: `toon` lost by a
+> consistent ~16 bytes. That is the opposite of the usual "40–60% smaller"
+> folklore, and it is why we are not repeating it. `json` is the format to
+> benchmark against; reach for `toon` when you have measured your own payloads
+> and it wins.
+
+### Assorted flags
+
+- `get --base ` — cut from a branch other than 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 quietly reads as
+ "create the branch we are already on".
+- `get --no-fetch` — skip the origin fetch for offline or air-gapped machines.
+- `get --worktree-path ` — place a new worktree at a templated path
+ (`{pool}`, `{slot}`, `{repo}`, `{repo_parent}`).
+- `get --lease --ttl 30m` — acquire non-interactively and print only the path.
+- `return ` — the argument now also accepts a slot name as printed by
+ `treehouse status`, not just a path.
+- `return --all` — return everything you hold, leaving alone slots nobody holds.
+- `return --if-lease-id ` — return only if the lease is still yours, so a
+ second agent cannot pull a worktree out from under its holder.
+
+### Self-update
+
+```bash
+treehouse update
+```
+
+Resolves your platform's asset, verifies it against the published SHA-256, and
+replaces the binary atomically. Background version checks are cached, so being
+offline costs you a notice, not a hang.
+
+> **Scope of what was verified for this release.** The asset-selection, sidecar
+> resolution, checksum-mismatch refusal, and atomic-replace paths are covered by
+> the `updater.rs` unit tests, including `release_workflow_contract`, which
+> parses the real `release.yml` — those pass. The **end-to-end** path
+> (real download → verify → replace) was not exercised against a live release:
+> the release base URL is compiled in (`updater.rs:35`) with no environment
+> override, so there is no local-fixture seam to drive it from. Treat the
+> download-and-replace path as tested at the unit level, not demonstrated
+> end-to-end in this release.
+
+The checksum sidecar bug is fixed. The updater had looked for
+`.sha256` while the workflow publishes `.sha256`, so
+every platform failed closed with "no checksums file in release assets". The
+updater now accepts both spellings, preferring the exact one, and a
+`release_workflow_contract` test module in `updater.rs` parses the real
+`.github/workflows/release.yml` to prove every published asset has a sidecar the
+updater will look for — so the two cannot drift apart again. Nothing is
+installed and nothing is corrupted if a checksum is missing or unreadable; the
+update simply refuses.
+
+---
+
+## Still open
+
+Naming these because a reader skimming the source would otherwise assume more
+than has been proven.
+
+- **The jj backend is implemented, registered, and not exercised end to end.**
+ `vcs/jj.rs` implements 21 of the `GitBackend` trait's 24 methods behind a
+ `JjRunner` seam, and it is registered unconditionally
+ (`registry.register(BACKEND_JJ, Arc::new(jj::JjBackend::discover()))`), so a
+ `.jj` marker always resolves. Three things are true and worth separating:
+
+ - **No jj binary, no jj run.** `which jj` finds nothing on the machine this
+ release was built on, so every jj test runs against a scripted fake runner.
+ Argument construction, marker and flavour resolution, the fail-closed
+ refusals, and the clean/dirty reset protocol are exercised; **jj's own
+ output formats are not verified by anything in this repository.** Treat jj
+ as built and unproven, not as shipped-and-working.
+ - **Creation does not go through the seam.** Reset, dirty checks, and the
+ guarded reset dispatch per marker (`pool.rs:731`, `pool.rs:968`), but
+ worktree *creation* still calls `git.worktree_add` directly
+ (`pool.rs:1230`). A jj workspace that reached creation would get a git
+ worktree. In practice it never gets that far: the pool opens by resolving
+ the repository root through `ShellGitBackend` (`pool.rs:472`), which shells
+ out to `git rev-parse --show-toplevel`, so as of this build
+ `TREEHOUSE_VCS=jj treehouse get --lease` in a `.jj`-only directory exits 1
+ with
+
+ ```
+ git git rev-parse --show-toplevel: fatal: not a git repository (or any of the parent directories): .git
+ ```
+
+ — a git error naming git, which is the wrong thing to tell someone who
+ correctly selected jj. Moving root resolution behind the seam is in
+ progress; when it lands this string is replaced by a refusal that names the
+ `jj` backend, says it will not fall back to git, and points at `JJ_BIN`. Do
+ not script against either string.
+ - **Seeding is not implemented for jj.** Of the three trait methods jj leaves
+ on their defaults, `resolve_for_worktree` simply dispatches back through the
+ seam, so it costs nothing — but `seed_worktree` and
+ `reset_worktree_with_seeded_paths` refuse, so a jj workspace cannot use
+ `.worktreeinclude`. Everything else jj implements, including
+ `is_worktree_safe_to_reset` and `reset_worktree_to_ref`.
+
+ What is genuinely solid today: `--branch` is refused in a jj workspace with
+ `--branch is only supported by the git backend (cannot create "feat/x" in a jj
+ workspace)` rather than silently writing a git ref that means nothing. One
+ precondition the paragraph above implies and this one did not: that refusal is
+ reached only *after* the repository root resolves, so it is what you see in a
+ checkout git can still open. In a `.jj`-only directory the root never resolves
+ and the git error above is what you get instead.
+- **VCS selection reads `TREEHOUSE_VCS` only.** The `vcs` key in
+ `treehouse.toml` and `~/.config/treehouse/config.toml` is not consulted —
+ `TreehouseConfig` has no `vcs` field. Opt in through the environment variable.
+- **A missing `jj` binary is not reported as a configuration problem.** The
+ backend is registered even when discovery finds no binary, so a `.jj` marker
+ fails with `jj binary not found on PATH (set JJ_BIN)` rather than `no backend
+ is registered for "jj"`, which would send you looking for a setting that is
+ already correct.
+- **Windows ARM64 has no prebuilt asset.** The release matrix builds
+ `windows-x86_64` only. On Windows ARM64, `install.ps1` falls back to building
+ from source, which works but is slower.
+
+---
+
+## Upgrading
+
+```bash
+treehouse --root . update
+# or
+curl -fsSL https://raw.githubusercontent.com/quangdang46/treehouse_rust/main/install.sh | bash
+```
+
+Before you upgrade, check your scripts for two things:
+
+1. Anything that keys on `treehouse return`'s exit status — add
+ `TREEHOUSE_EXIT_STRICT=0` if you cannot fix them today.
+2. Anything that passes `--env-path` — it was being ignored before and is not
+ now.
+
+Existing pools keep working. No data format changed, and nothing in 0.2.0
+requires you to re-run `treehouse init`.
+
+Full technical detail, including every gap closed against upstream v3.1.0, is in
+[CHANGELOG.md](CHANGELOG.md).
\ No newline at end of file
diff --git a/crates/treehouse-core/Cargo.toml b/crates/treehouse-core/Cargo.toml
index 6cb64c8..03ed9cb 100644
--- a/crates/treehouse-core/Cargo.toml
+++ b/crates/treehouse-core/Cargo.toml
@@ -32,6 +32,12 @@ tru = { workspace = true, optional = true }
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31.3", features = ["signal", "process"] }
+# APFS clonefile sharing, which is macOS-only and opt-in (src/fileclone/mod.rs).
+# Gated here rather than at the workspace so a Linux/Windows build does not
+# compile a Darwin syscall surface it can never call.
+[target.'cfg(target_os = "macos")'.dependencies]
+libc = { workspace = true }
+
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61.2", features = ["Win32_Storage_FileSystem", "Win32_System_Threading"] }
diff --git a/crates/treehouse-core/src/config.rs b/crates/treehouse-core/src/config.rs
index ab0bb86..977944b 100644
--- a/crates/treehouse-core/src/config.rs
+++ b/crates/treehouse-core/src/config.rs
@@ -376,6 +376,324 @@ pub enum ConfigError {
Toml(String, String),
}
+// ─── pool exclusion (Go internal/config/gitignore.go) ─────────────────────────
+
+/// Arranges for a pool directory to be ignored by the enclosing repository
+/// (Go `EnsureExcluded`).
+///
+/// Two layers, because one is not enough:
+///
+/// 1. A `.gitignore` containing `*` inside the pool root. Both git and jj read
+/// nested `.gitignore` files, so this alone keeps an in-project pool out of
+/// snapshots — including under jj, which never reads `.git/info/exclude`.
+/// 2. The pool root recorded in the repo-local, UNTRACKED `.git/info/exclude`,
+/// so an in-project pool stays out of `git status` and out of any commit
+/// without dirtying the user's tracked `.gitignore`.
+///
+/// Layer 2 is skipped when the pool is not inside a git repository (the
+/// default global root under `$HOME`), which matches the previous behavior for
+/// the global store, and degrades to layer 1 alone when no usable git dir
+/// exists.
+///
+/// For backward compatibility, a pre-existing entry in the tracked `.gitignore`
+/// is left alone and treated as sufficient — an upgrading user is not
+/// surprised by a moved ignore rule.
+pub fn ensure_excluded(
+ treehouse_dir: &Path,
+ git: &dyn crate::git::GitBackend,
+) -> Result<(), ConfigError> {
+ let self_ignored = write_self_ignore(treehouse_dir).is_ok();
+
+ // The directory itself may not exist yet, so walk up to an existing
+ // ancestor for the git check below.
+ let mut check_dir = treehouse_dir.to_path_buf();
+ let existing = loop {
+ match std::fs::metadata(&check_dir) {
+ Ok(meta) if meta.is_dir() => break true,
+ _ => match check_dir.parent() {
+ Some(parent) if parent != check_dir => check_dir = parent.to_path_buf(),
+ // Reached the filesystem root without finding a directory.
+ _ => break false,
+ },
+ }
+ };
+ if !existing {
+ return Ok(());
+ }
+
+ // Not inside a git repo — nothing to do (e.g. the global ~/.treehouse root).
+ let Ok(repo_root) = git.repo_root(&check_dir) else {
+ return Ok(());
+ };
+
+ // Outside the repository working tree (a sibling pool root): nothing to do.
+ let Some(rel) = relative_to(&repo_root, treehouse_dir) else {
+ return Ok(());
+ };
+ if rel.starts_with("..") {
+ return Ok(());
+ }
+
+ // Forward slashes and a leading slash anchor the entry at the repo root.
+ let entry = format!("/{}", rel.replace('\\', "/"));
+
+ // Backward compatibility: an entry an older version already recorded in the
+ // tracked .gitignore is left in place rather than duplicated into
+ // .git/info/exclude.
+ if has_ignore_entry(&repo_root.join(".gitignore"), &entry) {
+ return Ok(());
+ }
+
+ let Ok(common_dir) = git.common_git_dir(&repo_root) else {
+ if self_ignored {
+ return Ok(());
+ }
+ // Go returns the underlying error only when the self-ignore also
+ // failed; with no git dir and no self-ignore there is nothing left to
+ // fall back on, so the failure is surfaced rather than swallowed.
+ return Err(ConfigError::Invalid(
+ format!("cannot determine git dir for {}", repo_root.display()),
+ String::new(),
+ ));
+ };
+ let exclude_path = common_dir.join("info").join("exclude");
+
+ if has_ignore_entry(&exclude_path, &entry) {
+ return Ok(());
+ }
+
+ std::fs::create_dir_all(
+ exclude_path.parent().ok_or_else(|| {
+ ConfigError::Invalid(exclude_path.display().to_string(), String::new())
+ })?,
+ )
+ .map_err(|e| ConfigError::Io(exclude_path.display().to_string(), e))?;
+
+ let existing = match std::fs::read(&exclude_path) {
+ Ok(bytes) => bytes,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
+ Err(e) => return Err(ConfigError::Io(exclude_path.display().to_string(), e)),
+ };
+
+ // Append, preserving whatever the user already put in this file. A file
+ // with no trailing newline would otherwise have the entry concatenated onto
+ // its last line, silently merging two ignore rules into one.
+ let mut contents = existing;
+ if !contents.is_empty() && !contents.ends_with(b"\n") {
+ contents.push(b'\n');
+ }
+ contents.extend_from_slice(entry.as_bytes());
+ contents.push(b'\n');
+
+ std::fs::write(&exclude_path, &contents)
+ .map_err(|e| ConfigError::Io(exclude_path.display().to_string(), e))
+}
+
+/// `treehouse_dir` relative to `repo_root`, in slash-separated form.
+///
+/// Returns `None` when the two cannot be related at all (different roots on
+/// Windows), which callers treat as "not in this repository".
+///
+/// The comparison tries the paths as given first, then their canonical forms.
+/// That second attempt is not cosmetic: git reports its own roots through
+/// `/private/var/...` on macOS, while a pool root configured as `/var/...`
+/// reaches us spelled the short way. Compared literally the two share no
+/// prefix, and the exclude entry would be silently never written — the pool
+/// would then be neither self-ignored nor excluded, and the repository would
+/// read dirty with a directory full of worktrees in it.
+///
+/// Canonicalization is attempted per-path and failures fall back to the input,
+/// because the pool root may not exist yet.
+fn relative_to(repo_root: &Path, treehouse_dir: &Path) -> Option {
+ let root = normalize_for_compare(repo_root);
+ let target = normalize_for_compare(treehouse_dir);
+ if let Some(rel) = relative_within(&root, &target) {
+ return Some(rel);
+ }
+ let root_canonical = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
+ let target_canonical = std::fs::canonicalize(&target).unwrap_or_else(|_| target.clone());
+ if root_canonical == root && target_canonical == target {
+ // Nothing resolved, so retrying gained nothing.
+ return None;
+ }
+ relative_within(&root_canonical, &target_canonical)
+}
+
+/// The tail of `target` after `root`, or `None` unless `root` is a proper
+/// ancestor of `target`.
+fn relative_within(root: &Path, target: &Path) -> Option {
+ let root_components: Vec<_> = root.components().collect();
+ let target_components: Vec<_> = target.components().collect();
+
+ if target_components.len() < root_components.len() {
+ return None;
+ }
+ if !root_components
+ .iter()
+ .zip(target_components.iter())
+ .all(|(a, b)| a == b)
+ {
+ return None;
+ }
+ let tail: Vec = target_components[root_components.len()..]
+ .iter()
+ .map(|c| c.as_os_str().to_string_lossy().into_owned())
+ .collect();
+ Some(tail.join("/"))
+}
+
+/// Resolves `.` and `..` lexically so a pool root spelled with a relative
+/// segment still compares equal to the same directory spelled without one.
+///
+/// Lexical, not `canonicalize`: the pool root may not exist yet, which is
+/// exactly the case `ensure_excluded` has to handle.
+fn normalize_for_compare(path: &Path) -> std::path::PathBuf {
+ use std::path::Component;
+ let mut out = std::path::PathBuf::new();
+ for component in path.components() {
+ match component {
+ Component::CurDir => {}
+ Component::ParentDir => {
+ out.pop();
+ }
+ other => out.push(other.as_os_str()),
+ }
+ }
+ out
+}
+
+/// Creates the pool root and writes a `.gitignore` containing `*` into it,
+/// unless one already exists (Go `writeSelfIgnore`).
+///
+/// The pool root then ignores its own entire contents, which is what makes an
+/// in-project root safe for backends that never read `.git/info/exclude`. Ignore
+/// rules never cross a worktree boundary, so the file has no effect on the
+/// pooled worktrees below it.
+fn write_self_ignore(treehouse_dir: &Path) -> std::io::Result<()> {
+ std::fs::create_dir_all(treehouse_dir)?;
+ let path = treehouse_dir.join(".gitignore");
+ if std::fs::symlink_metadata(&path).is_ok() {
+ // Present already. A symlink here is left exactly as it is: the pool
+ // root is the user's to configure, and following it would write
+ // outside the pool.
+ return Ok(());
+ }
+ std::fs::write(path, "*\n")
+}
+
+/// Whether the ignore file at `path` already contains `entry` as a standalone
+/// line. A missing file reads as absent (Go `hasIgnoreEntry`).
+fn has_ignore_entry(path: &Path, entry: &str) -> bool {
+ let Ok(data) = std::fs::read_to_string(path) else {
+ return false;
+ };
+ data.lines().any(|line| line.trim() == entry)
+}
+
+// ─── ignored repo hooks warning (Go internal/config/hooks.go) ─────────────────
+
+/// The lifecycle hook keys a repo-level `treehouse.toml` may declare and that
+/// [`TreehouseConfig::load`] discards. Order fixes the order they are named in
+/// the warning.
+const HOOK_KEYS: [&str; 2] = ["post_create", "pre_destroy"];
+
+/// Dedupes the ignored-hooks warning per config file: a single command loads
+/// config repeatedly, and the warning is for a human, once.
+fn warned_repo_hooks() -> &'static std::sync::Mutex> {
+ static LOCKED: std::sync::OnceLock>> =
+ std::sync::OnceLock::new();
+ LOCKED.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
+}
+
+/// Warns on stderr, once per repo config file, when the repo-level
+/// `treehouse.toml` declares lifecycle hooks (Go `WarnIfRepoHooksIgnored`).
+///
+/// [`TreehouseConfig::load`] deliberately discards repo hooks so that
+/// `treehouse get` on an untrusted clone cannot execute checked-in shell; this
+/// only makes that discard audible, and changes no behavior.
+///
+/// It is a standalone function rather than a step inside `load` because
+/// `treehouse destroy ` reads hooks through [`TreehouseConfig::load_global`]
+/// and never opens the repo config at all, so a warning wired only into `load`
+/// would stay silent for exactly the command that surfaced the defect.
+///
+/// Best-effort and never fails: a missing, unreadable, or malformed repo config
+/// produces no warning and no error, because destroy has to keep working
+/// against a pool whose repository config is broken or whose repository is gone.
+pub fn warn_if_repo_hooks_ignored(repo_root: &Path) {
+ let path = repo_root.join("treehouse.toml");
+ let Ok(text) = std::fs::read_to_string(&path) else {
+ return;
+ };
+ let declared = declared_hook_keys(&text);
+ if declared.is_empty() {
+ return;
+ }
+ {
+ let Ok(mut seen) = warned_repo_hooks().lock() else {
+ return;
+ };
+ if !seen.insert(path.clone()) {
+ return;
+ }
+ }
+ eprintln!(
+ "🌳 Warning: ignoring [hooks] in {}: {}",
+ pretty_path(&path),
+ declared.join(", ")
+ );
+ eprintln!(
+ " Lifecycle hooks run only from {}; hooks in repo-level config are ignored for safety.",
+ user_config_path_for_display()
+ );
+}
+
+/// The hook keys the document actually DECLARES.
+///
+/// An empty `[hooks]` table declares no hook and so silently discards nothing
+/// worth warning about. This deliberately inspects the raw TOML rather than
+/// the decoded [`Hooks`]: decoding a malformed file must produce silence (the
+/// caller has to survive a broken repo config), and it cannot be reached
+/// through the strict decoder anyway.
+fn declared_hook_keys(text: &str) -> Vec {
+ let Ok(value) = text.parse::() else {
+ return Vec::new();
+ };
+ let Some(toml::Value::Table(hooks)) = value.get("hooks") else {
+ return Vec::new();
+ };
+ HOOK_KEYS
+ .iter()
+ .filter(|key| hooks.contains_key(**key))
+ .map(|key| key.to_string())
+ .collect()
+}
+
+/// Names the user-level config file for humans, falling back to the
+/// conventional `~`-rooted spelling when `$HOME` cannot be resolved: the
+/// warning must still point somewhere useful.
+fn user_config_path_for_display() -> String {
+ user_config_path().map_or_else(
+ || "~/.config/treehouse/config.toml".to_string(),
+ |path| pretty_path(&path),
+ )
+}
+
+/// A path under `$HOME` shown as `~/...`.
+///
+/// Only affects display. Resolving the home prefix textually — rather than
+/// canonicalizing — keeps the check from failing on paths that do not exist,
+/// which is the normal case for a config file that is only being named.
+fn pretty_path(path: &Path) -> String {
+ if let Some(home) = home_dir()
+ && let Ok(rel) = path.strip_prefix(&home)
+ && !rel.as_os_str().is_empty()
+ {
+ return format!("~/{}", rel.display());
+ }
+ path.display().to_string()
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -763,4 +1081,291 @@ mod tests {
assert_eq!(cfg.max_trees, 16); // default
assert!(cfg.hooks.is_empty());
}
+
+ // ─── pool exclusion (Go internal/config/gitignore.go) ───────────────────
+
+ /// A temp repo with one commit on `main`.
+ fn git_repo() -> (tempfile::TempDir, PathBuf) {
+ let dir = tempfile::tempdir().unwrap();
+ let repo = dir.path().join("repo");
+ let must = |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.join(" "),
+ String::from_utf8_lossy(&out.stderr)
+ );
+ };
+ std::process::Command::new("git")
+ .args([
+ "init",
+ "-q",
+ "--initial-branch=main",
+ repo.to_str().unwrap(),
+ ])
+ .output()
+ .expect("git must be installed");
+ must(&["config", "user.email", "test@test.com"]);
+ must(&["config", "user.name", "Test"]);
+ std::fs::write(repo.join("README.md"), b"hello\n").unwrap();
+ must(&["add", "."]);
+ must(&["commit", "-m", "initial"]);
+ (dir, repo)
+ }
+
+ fn backend() -> crate::git::ShellGitBackend {
+ crate::git::ShellGitBackend::discover().expect("git must be installed")
+ }
+
+ fn read_exclude(repo: &Path) -> String {
+ std::fs::read_to_string(repo.join(".git").join("info").join("exclude")).unwrap()
+ }
+
+ /// An in-project pool must be recorded in the repo-local, UNTRACKED
+ /// `.git/info/exclude` — and must leave the tracked `.gitignore` alone, so
+ /// the user's repository does not read dirty because treehouse used it.
+ #[test]
+ fn ensure_excluded_writes_to_git_info_exclude() {
+ let (_d, repo) = git_repo();
+ let git = backend();
+ let pool = repo.join(".treehouse");
+
+ ensure_excluded(&pool, &git).unwrap();
+
+ assert!(
+ read_exclude(&repo).contains("/.treehouse"),
+ "the pool root must be recorded in .git/info/exclude"
+ );
+ assert!(
+ !repo.join(".gitignore").exists(),
+ "the tracked .gitignore must not be created"
+ );
+
+ // The real proof it stays out of the user's way.
+ let status = std::process::Command::new("git")
+ .args(["status", "--porcelain"])
+ .current_dir(&repo)
+ .output()
+ .unwrap();
+ assert!(
+ String::from_utf8_lossy(&status.stdout).trim().is_empty(),
+ "the repository must read clean, got:\n{}",
+ String::from_utf8_lossy(&status.stdout)
+ );
+ }
+
+ /// Both layers are needed: git and jj read a nested `.gitignore`, so the
+ /// pool root ignoring its own contents is what protects a backend that
+ /// never consults `.git/info/exclude`.
+ #[test]
+ fn ensure_excluded_makes_the_pool_root_self_ignoring() {
+ let (_d, repo) = git_repo();
+ let git = backend();
+ let pool = repo.join(".treehouse");
+
+ ensure_excluded(&pool, &git).unwrap();
+
+ assert_eq!(
+ std::fs::read_to_string(pool.join(".gitignore")).unwrap(),
+ "*\n",
+ "the pool root must ignore its own entire contents"
+ );
+
+ // Idempotent: a second call must not append a duplicate.
+ ensure_excluded(&pool, &git).unwrap();
+ assert_eq!(
+ read_exclude(&repo).matches("/.treehouse").count(),
+ 1,
+ "the entry must not be duplicated"
+ );
+ }
+
+ /// Backward compatibility: a pool an older version already recorded in the
+ /// tracked `.gitignore` keeps that rule. Moving it to `.git/info/exclude`
+ /// would surprise an upgrading user by un-ignoring the tracked rule.
+ #[test]
+ fn ensure_excluded_leaves_a_preexisting_gitignore_entry_alone() {
+ let (_d, repo) = git_repo();
+ let git = backend();
+ std::fs::write(repo.join(".gitignore"), b"/.treehouse\n").unwrap();
+ let pool = repo.join(".treehouse");
+
+ ensure_excluded(&pool, &git).unwrap();
+
+ assert_eq!(
+ std::fs::read_to_string(repo.join(".gitignore")).unwrap(),
+ "/.treehouse\n",
+ "an existing tracked rule must be left untouched"
+ );
+ let exclude = repo.join(".git").join("info").join("exclude");
+ assert!(
+ !exclude.exists() || !read_exclude(&repo).contains("/.treehouse"),
+ "a pre-existing tracked entry must not be duplicated into info/exclude"
+ );
+ }
+
+ /// An exclude file the user has other content in must be APPENDED to, with
+ /// a newline inserted when the file lacks a trailing one — otherwise the
+ /// entry is concatenated onto the last rule and silently merges two ignore
+ /// patterns into one.
+ #[test]
+ fn ensure_excluded_appends_without_merging_into_the_last_rule() {
+ let (_d, repo) = git_repo();
+ let git = backend();
+ let exclude = repo.join(".git").join("info").join("exclude");
+ std::fs::create_dir_all(exclude.parent().unwrap()).unwrap();
+ // No trailing newline: the merge hazard.
+ std::fs::write(&exclude, b"*.log").unwrap();
+
+ ensure_excluded(&repo.join(".treehouse"), &git).unwrap();
+
+ let contents = read_exclude(&repo);
+ assert!(
+ contents.starts_with("*.log\n"),
+ "the existing rule must be preserved on its own line, got:\n{contents}"
+ );
+ assert!(
+ contents.contains("/.treehouse"),
+ "the pool entry must be appended, got:\n{contents}"
+ );
+ }
+
+ /// A pool outside the repository working tree gets nothing written: an
+ /// exclude entry anchored at the repo root cannot mean "this other
+ /// directory", and writing one anyway would be a rule that silently does
+ /// nothing.
+ #[test]
+ fn ensure_excluded_does_nothing_outside_the_repo() {
+ let dir = tempfile::tempdir().unwrap();
+ // No git repo anywhere above a tempdir on most systems; assert the
+ // call succeeds and does not fabricate an exclude file.
+ let pool = dir.path().join("elsewhere").join(".treehouse");
+ ensure_excluded(&pool, &backend()).unwrap();
+ assert!(
+ pool.join(".gitignore").exists(),
+ "the self-ignore must still be written: it is valid with no repo"
+ );
+ }
+
+ /// A nested pool root is recorded with its full relative path.
+ #[test]
+ fn ensure_excluded_records_a_nested_pool_root() {
+ let (_d, repo) = git_repo();
+ let git = backend();
+ let pool = repo.join("worktrees").join(".treehouse");
+
+ ensure_excluded(&pool, &git).unwrap();
+
+ assert!(
+ read_exclude(&repo).contains("/worktrees/.treehouse"),
+ "the nested pool must be anchored at the repo root, got:\n{}",
+ read_exclude(&repo)
+ );
+ }
+
+ /// `ensure_excluded` must be callable for a pool root that does not exist
+ /// yet — it is what creates it.
+ #[test]
+ fn ensure_excluded_creates_a_missing_pool_root() {
+ let (_d, repo) = git_repo();
+ let git = backend();
+ let pool = repo.join("brand").join("new").join(".treehouse");
+ assert!(!pool.exists());
+
+ ensure_excluded(&pool, &git).unwrap();
+
+ assert!(pool.is_dir(), "the pool root must have been created");
+ assert!(read_exclude(&repo).contains("/brand/new/.treehouse"));
+ }
+
+ // ─── ignored repo hooks warning (Go internal/config/hooks.go) ───────────
+
+ /// Whether the repo config declares each of the two hook keys.
+ fn warning_for(repo: &Path) -> Vec {
+ // The warning goes to stderr, so it is captured by re-parsing what the
+ // decision function would emit. `declared_hook_keys` is the part that
+ // decides WHICH keys are named.
+ declared_hook_keys(&std::fs::read_to_string(repo.join("treehouse.toml")).unwrap())
+ }
+
+ /// The warning names the file and every declared key, so a user who wrote
+ /// the hooks can see which config was ignored and why.
+ #[test]
+ fn the_ignored_hooks_warning_names_the_file_and_every_declared_key() {
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::write(
+ dir.path().join("treehouse.toml"),
+ "[hooks]\npost_create = [\"./setup.sh\"]\npre_destroy = [\"./teardown.sh\"]\n",
+ )
+ .unwrap();
+
+ let keys = warning_for(dir.path());
+ assert_eq!(keys, ["post_create", "pre_destroy"]);
+ assert!(
+ user_config_path_for_display().ends_with("config.toml"),
+ "the warning must point at the user-level config, got {}",
+ user_config_path_for_display()
+ );
+ }
+
+ /// A config that declares only one key must not name the other. The user
+ /// wrote one hook; telling them two were ignored sends them looking for a
+ /// config they never wrote.
+ #[test]
+ fn the_ignored_hooks_warning_names_only_declared_keys() {
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::write(
+ dir.path().join("treehouse.toml"),
+ "[hooks]\npre_destroy = [\"./teardown.sh\"]\n",
+ )
+ .unwrap();
+
+ let keys = warning_for(dir.path());
+ assert_eq!(keys, ["pre_destroy"]);
+ assert!(!keys.contains(&"post_create".to_string()));
+ }
+
+ /// Silence is required, not merely nice: a config with no hooks, an empty
+ /// `[hooks]` table, and a MALFORMED file must all warn about nothing. A
+ /// broken repo config is normal in `destroy`, and warning about every pool
+ /// whose config happens to be invalid would bury the real message.
+ #[test]
+ fn the_ignored_hooks_warning_is_silent_when_there_is_nothing_to_ignore() {
+ for contents in ["max_trees = 4\n", "[hooks]\n", "invalid toml <<<\n", ""] {
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::write(dir.path().join("treehouse.toml"), contents).unwrap();
+ assert!(
+ declared_hook_keys(contents).is_empty(),
+ "{contents:?} must declare no hook"
+ );
+ }
+ // A repo with no config at all.
+ assert!(declared_hook_keys("").is_empty());
+ }
+
+ /// The warning fires ONCE per config file. A single command loads config
+ /// repeatedly, and a per-load warning would print the same line dozens of
+ /// times.
+ #[test]
+ fn the_ignored_hooks_warning_is_deduped_per_file() {
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::write(
+ dir.path().join("treehouse.toml"),
+ "[hooks]\npost_create = [\"./setup.sh\"]\n",
+ )
+ .unwrap();
+ let path = dir.path().join("treehouse.toml");
+
+ let mut seen = warned_repo_hooks().lock().unwrap();
+ assert!(seen.insert(path.clone()), "first sighting is recorded");
+ assert!(
+ !seen.insert(path.clone()),
+ "a second sighting of the same file must be deduped"
+ );
+ }
}
diff --git a/crates/treehouse-core/src/destroy.rs b/crates/treehouse-core/src/destroy.rs
index 6e93b04..bc150d6 100644
--- a/crates/treehouse-core/src/destroy.rs
+++ b/crates/treehouse-core/src/destroy.rs
@@ -169,7 +169,8 @@ impl Pool {
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 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) {
@@ -185,8 +186,7 @@ impl Pool {
}
// Execute: two-phase.
- let (destroyed, exec_skips) =
- self.execute_destroy(&removable, allow_leased, opts)?;
+ let (destroyed, exec_skips) = 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);
@@ -313,11 +313,13 @@ impl Pool {
if removable.is_empty() {
return Ok((Vec::new(), Vec::new()));
}
- let planned_by_path: std::collections::HashMap =
- removable
- .iter()
- .map(|(t, ctx)| (t.path.clone(), (t.clone(), ctx.clone())))
- .collect();
+ let planned_by_path: std::collections::HashMap<
+ String,
+ (DestroyTarget, crate::prune::PruneContext),
+ > = removable
+ .iter()
+ .map(|(t, ctx)| (t.path.clone(), (t.clone(), ctx.clone())))
+ .collect();
// Phase 1: reserve Destroying + fresh owner under the lock.
//
diff --git a/crates/treehouse-core/src/fileclone/mod.rs b/crates/treehouse-core/src/fileclone/mod.rs
new file mode 100644
index 0000000..cef418f
--- /dev/null
+++ b/crates/treehouse-core/src/fileclone/mod.rs
@@ -0,0 +1,2657 @@
+//! Filesystem-level fast paths (Go `internal/fileclone`, upstream #153).
+//!
+//! On APFS a freshly-created git worktree can share file *data* with the source
+//! checkout instead of duplicating it: the bytes are stored once and the new
+//! directory entry is copy-on-write. For a repo with a large tracked binary the
+//! difference is instant versus minutes, and the space is not duplicated either.
+//!
+//! ## The sharp edge, and why it is safe here
+//!
+//! Sharing data is NOT sharing an inode. A write through the destination breaks
+//! the copy-on-write link and leaves the source untouched, which is what makes
+//! the fast path usable for a worktree a developer is about to edit. Go proves
+//! that property end to end in `sharing_darwin_test.go` ("target edit changed
+//! source" / "source edit/deletion affected clone"); [`share`] keeps the same
+//! guarantee and this module's tests re-assert it on every run.
+//!
+//! The hard part is not the syscall (`fclonefileat`) but everything around it.
+//! Publishing a clone replaces a file that already exists, so a mistake can
+//! leak metadata (an xattr the source had and the destination must not), corrupt
+//! content, or publish bytes that are not the ones we hashed. This module
+//! therefore **refuses far more than it accepts**, and every refusal is ported
+//! from Go's `attributes_darwin.go` / `metadata_darwin.go`:
+//!
+//! * no symlink in any path component, including either root — traversal and
+//! publication run on pinned directory descriptors, never on a path that can
+//! be re-pointed underneath us ([`darwin::open_directory`]);
+//! * no ACL on the file, the source, or the *parent directory* (a directory ACL
+//! can make a clone inherit differently than a plain copy would);
+//! * no `com.apple.decmpfs` or `com.apple.ResourceFork` on either side — their
+//! content hashes can match while the *storage representation* differs;
+//! * no sparse or compressed files, same argument at the block level;
+//! * no hardlinks, no `st_flags`, no setuid/setgid/sticky bits, no differing
+//! owner, no differing size;
+//! * the clone is staged in a private directory and re-hashed **after** its
+//! metadata is restored, so a metadata step that rewrites content is caught;
+//! * immediately before `renameat`, the destination file, its parent, and the
+//! destination root are all re-identified through descriptors opened earlier.
+//!
+//! ## What this cannot do
+//!
+//! Staging and the final identity checks narrow the window in which a hostile
+//! or merely concurrent writer can interfere. They do not close it: no
+//! filesystem check can cover the instant between the last check and
+//! `renameat`. **The caller must exclusively own the destination directory for
+//! the whole call** — see [`share`]. Go demands the same in its package
+//! comment, which is why the lifecycle only ever passes a fresh, reserved slot.
+//!
+//! ## Opt-in
+//!
+//! Nothing here runs unless a caller asks for it. There is no config read and no
+//! environment read *inside this module*: [`share`] is an ordinary function, and
+//! the only caller is the pool's acquisition path, which calls it only when the
+//! acquisition asked for it (`--apfs-sharing fresh` on the CLI,
+//! `$TREEHOUSE_APFS_SHARING`, or `AcquireOptions::apfs_sharing`). Someone who
+//! passes no such flag gets byte-identical behavior whether this module is
+//! empty or full.
+//!
+//! Failure is never fatal to the caller. Almost every problem is a *skip*
+//! recorded in [`Report::skipped`], and even the aborts in [`ShareError`] are
+//! reported so the caller can fall back to a plain copy rather than fail the
+//! acquisition. The pool honors that: the tracked files were already written by
+//! `git worktree add` before this pass runs, so declining to share leaves a
+//! correct worktree that merely duplicates its bytes.
+//!
+//! ## Where it plugs in
+//!
+//! Sharing is NOT part of seeding ([`crate::git::GitBackend::seed_worktree`]),
+//! which copies the pool's ignored files. Sharing applies to the TRACKED
+//! checkout git itself just wrote, and only on a slot the acquisition created
+//! moments earlier — the exclusivity this module's contract requires is
+//! something a recycled slot cannot promise.
+//!
+//! The path enumeration and the Git-freshness preflight that upstream keeps in
+//! `internal/vcs` are NOT here: this module takes the relative paths and does the
+//! sharing. The pool owns that preflight (it needs to check the source
+//! repository and the destination worktree, and to notice hooks or an fsmonitor
+//! that may have left a writer behind) and then calls [`share`].
+
+use std::collections::BTreeMap;
+use std::fmt;
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
+
+/// Smallest file worth sharing, in bytes (Go `fileclone.MinimumSize`).
+///
+/// Below this the per-file setup — four hashes, a `getattrlist`, a staged
+/// directory, a `renameat` — costs more than the duplicated bytes save, and
+/// small files are the overwhelming majority of a checkout.
+pub const MINIMUM_SIZE: u64 = 64 * 1024;
+
+/// Whether this platform has an APFS sharing implementation.
+///
+/// `false` everywhere except macOS. On those platforms [`share`] is a no-op
+/// that reports a reason rather than an error, and does not even look at the
+/// paths it is handed (Go `sharing_other.go`).
+pub const SUPPORTED: bool = cfg!(target_os = "macos");
+
+/// Keeps logical payload separate from measured APFS private data. Private
+/// data is not volume free space: snapshots and delayed reclamation still
+/// apply.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct Report {
+ /// Files whose data is now shared rather than duplicated.
+ pub cloned: u64,
+ /// Bytes of payload moved to shared storage. This is the *logical* size of
+ /// the clones, not the space reclaimed.
+ pub logical_bytes: u64,
+ /// Measured reduction in the destination's APFS private data. Reported
+ /// separately from [`Self::logical_bytes`] and never a substitute for it.
+ pub private_bytes_reduced: u64,
+ /// Why each file was left alone, counted by reason (Go `Report.Skipped`).
+ pub skipped: BTreeMap<&'static str, u64>,
+ /// Set when the whole pass was skipped before looking at any file.
+ pub reason: String,
+ /// Up to three per-file errors, for the operator. Every error is also
+ /// counted as a `kept_after_error` skip.
+ pub errors: Vec,
+}
+
+impl Report {
+ /// Record one refusal. Reasons are static strings so a caller can match on
+ /// them (`report.skipped.get("acl")`) instead of parsing prose.
+ pub fn skip(&mut self, reason: &'static str) {
+ *self.skipped.entry(reason).or_insert(0) += 1;
+ }
+
+ /// Whether this pass shared nothing: an unsupported platform, a preflight
+ /// refusal, or every file skipped.
+ pub fn is_noop(&self) -> bool {
+ self.cloned == 0
+ }
+}
+
+impl fmt::Display for Report {
+ /// The one-line summary Go's `Report.String` produces. `BTreeMap` ordering
+ /// makes it stable, so it diffs cleanly in logs.
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ if !self.reason.is_empty() {
+ return write!(f, "APFS sharing skipped: {}", self.reason);
+ }
+ write!(
+ f,
+ "APFS sharing: cloned={} logical_bytes={} private_data_reduced_bytes={}",
+ self.cloned, self.logical_bytes, self.private_bytes_reduced
+ )?;
+ for (reason, count) in &self.skipped {
+ write!(f, " {reason}={count}")?;
+ }
+ for error in &self.errors {
+ write!(f, " error={error:?}")?;
+ }
+ Ok(())
+ }
+}
+
+/// Why a sharing pass gave up instead of finishing.
+///
+/// Everything *refusable* is a [`Report`] skip, not an error. These variants are
+/// the cases where continuing would be unsafe or meaningless, and they are
+/// reported so the caller can fall back to a plain copy.
+#[derive(Debug, thiserror::Error)]
+pub enum ShareError {
+ /// The destination file, its parent, or the destination root changed
+ /// identity between our first look and the publication step. Something
+ /// outside this call is writing to a directory we were told we own.
+ #[error("destination changed during APFS sharing: {0}")]
+ DestinationChanged(String),
+
+ /// A staging directory could not be fully removed, or removal found data we
+ /// did not put there. We refuse to delete anything we did not create, so
+ /// the caller is told rather than left guessing what is on disk.
+ #[error("APFS sharing staging cleanup incomplete: {0}")]
+ Cleanup(String),
+
+ /// The pass was cancelled. Staging has already been torn down.
+ #[error("APFS sharing cancelled")]
+ Cancelled,
+
+ /// A syscall failed. The affected file is left exactly as it was.
+ #[error(transparent)]
+ Io(#[from] std::io::Error),
+}
+
+impl ShareError {
+ /// Whether the pass must abort, as opposed to one file being kept as-is and
+ /// recorded in [`Report::errors`].
+ ///
+ /// A cancelled or changed destination means the state we validated is gone;
+ /// a cleanup failure means we cannot promise what is left on disk. Both need
+ /// the caller to stop and think. An `Io` error is scoped to one file.
+ pub fn is_fatal(&self) -> bool {
+ matches!(
+ self,
+ ShareError::DestinationChanged(_) | ShareError::Cleanup(_) | ShareError::Cancelled
+ )
+ }
+}
+
+/// Cooperative cancellation for a sharing pass.
+///
+/// Go installs a `signal.NotifyContext` inside `Share`. A library must not take
+/// over a process's signal disposition, so the token lives here instead: a
+/// caller that wants SIGINT/SIGTERM to abort a pass wires its own handler to
+/// [`Cancel::cancel`]. [`share`] uses a token that is never cancelled.
+///
+/// Cancellation is checked between files and between hash chunks, and always
+/// leaves staging cleaned up — the same guarantee Go's SIGTERM test asserts.
+#[derive(Debug, Default)]
+pub struct Cancel {
+ flag: AtomicBool,
+}
+
+impl Cancel {
+ /// A handle that can be cancelled from another thread or a signal handler.
+ pub fn new() -> Arc {
+ Arc::new(Self::default())
+ }
+
+ /// A token that is never cancelled.
+ pub fn never() -> Cancel {
+ Cancel::default()
+ }
+
+ /// Request cancellation. The current file finishes its current step; no
+ /// further file is started and no staged clone is published.
+ pub fn cancel(&self) {
+ self.flag.store(true, Ordering::SeqCst);
+ }
+
+ pub fn is_cancelled(&self) -> bool {
+ self.flag.load(Ordering::SeqCst)
+ }
+}
+
+#[cfg(target_os = "macos")]
+pub use darwin::{filesystem_reason, share, share_cancellable};
+
+#[cfg(not(target_os = "macos"))]
+pub use other::{filesystem_reason, share, share_cancellable};
+
+/// Whether `path` is a relative path that stays inside the destination and is
+/// already in its shortest form — the Rust form of Go's
+/// `filepath.IsLocal(path) && filepath.Clean(path) == path` on a unix host.
+///
+/// Both halves reject the same shapes, so they fold into one walk: every
+/// `/`-separated component must be non-empty and must not be `.` or `..`.
+///
+/// * a leading `/` is absolute, so the path could name anything;
+/// * an empty component (`a//b`, `a/b/`) means `Clean` would rewrite it;
+/// * a `.` component means the same;
+/// * a `..` component is what `IsLocal` exists to reject, and even without it
+/// `Clean` would rewrite the path, so the two checks cannot disagree.
+///
+/// A leading `:` is a legal unix filename, so unlike the Windows branch of
+/// `IsLocal` it is not special-cased here.
+#[cfg(any(target_os = "macos", test))]
+fn is_safe_relative_path(path: &str) -> bool {
+ if path.is_empty() || path.starts_with('/') {
+ return false;
+ }
+ path.split('/')
+ .all(|part| !part.is_empty() && part != "." && part != "..")
+}
+
+#[cfg(test)]
+mod path_tests {
+ use super::is_safe_relative_path;
+
+ #[test]
+ fn plain_relative_paths_are_accepted() {
+ for path in [
+ "asset",
+ "sub/asset",
+ "a/b/c.bin",
+ "with space",
+ "with:colon",
+ ] {
+ assert!(is_safe_relative_path(path), "{path} should be accepted");
+ }
+ }
+
+ #[test]
+ fn traversal_and_non_normalized_paths_are_refused() {
+ for path in [
+ "",
+ "/asset",
+ "../target/asset",
+ "sub/../../escape",
+ "a//b",
+ "a/./b",
+ "./a",
+ "a/b/",
+ ".",
+ "..",
+ ] {
+ assert!(!is_safe_relative_path(path), "{path} should be refused");
+ }
+ }
+}
+
+#[cfg(test)]
+mod report_tests {
+ use super::*;
+
+ #[test]
+ fn skip_counts_repeat_reasons() {
+ let mut report = Report::default();
+ report.skip("acl");
+ report.skip("acl");
+ report.skip("below_threshold");
+ assert_eq!(report.skipped["acl"], 2);
+ assert_eq!(report.skipped["below_threshold"], 1);
+ assert!(report.is_noop());
+ }
+
+ #[test]
+ fn reason_short_circuits_the_summary() {
+ let report = Report {
+ reason: "requires APFS on both paths".to_string(),
+ ..Report::default()
+ };
+ assert_eq!(
+ report.to_string(),
+ "APFS sharing skipped: requires APFS on both paths"
+ );
+ assert!(report.is_noop());
+ }
+
+ /// The rendered form is a log line operators grep; the ordering and the
+ /// field names are part of the contract.
+ #[test]
+ fn summary_is_ordered_and_includes_errors() {
+ let mut report = Report {
+ cloned: 2,
+ logical_bytes: 4096,
+ private_bytes_reduced: 1024,
+ ..Report::default()
+ };
+ report.skip("sparse_or_compressed");
+ report.skip("acl");
+ report.errors.push("asset: boom".to_string());
+ assert_eq!(
+ report.to_string(),
+ "APFS sharing: cloned=2 logical_bytes=4096 private_data_reduced_bytes=1024 \
+ acl=1 sparse_or_compressed=1 error=\"asset: boom\""
+ );
+ assert!(!report.is_noop());
+ }
+
+ #[test]
+ fn only_fatal_variants_abort_the_pass() {
+ assert!(ShareError::DestinationChanged("asset".into()).is_fatal());
+ assert!(ShareError::Cleanup("busy".into()).is_fatal());
+ assert!(ShareError::Cancelled.is_fatal());
+ assert!(!ShareError::Io(std::io::Error::other("boom")).is_fatal());
+ }
+
+ #[test]
+ fn cancellation_token_round_trips() {
+ let token = Cancel::never();
+ assert!(!token.is_cancelled());
+ token.cancel();
+ assert!(token.is_cancelled());
+ }
+}
+
+// ─── macOS ───────────────────────────────────────────────────────────────────
+
+#[cfg(target_os = "macos")]
+mod darwin {
+ use super::{Cancel, MINIMUM_SIZE, Report, ShareError, is_safe_relative_path};
+ use libc::{c_int, c_long, mode_t, off_t, time_t};
+ use rand::Rng;
+ use sha2::{Digest, Sha256};
+ use std::cell::Cell;
+ use std::collections::BTreeMap;
+ use std::ffi::{CStr, CString};
+ use std::io;
+ use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
+ use std::os::unix::ffi::OsStrExt;
+ use std::path::{Component, Path};
+
+ // ── Darwin constants ──────────────────────────────────────────────────
+ //
+ // From `sys/attr.h` and `sys/attr_clang.h`, which libc does not re-export.
+ // `getattrlist` packs returned attributes on four-byte boundaries, not at
+ // Rust struct alignment, so responses are read at fixed byte offsets
+ // (RESPONSE_*) rather than through a struct. The layout and every value
+ // here were checked against a live `fgetattrlist` on APFS; the module's
+ // tests re-check them on every run.
+ const ATTR_BIT_MAP_COUNT: u16 = 5;
+ const ATTR_CMN_ACCTIME: u32 = 0x0000_1000;
+ const ATTR_CMN_EXTENDED_SECURITY: u32 = 0x0040_0000;
+ const ATTR_CMN_MODTIME: u32 = 0x0000_0400;
+ const ATTR_CMN_RETURNED_ATTRS: u32 = 0x8000_0000;
+ /// `ATTR_CMN_FNDRINFO` with `ATTRFMT_INFO` — the allocated size, not the
+ /// logical size.
+ const ATTR_CMN_FNDRINFO_PRIVATE_SIZE: u32 = 0x0000_0008;
+ const ATTR_CMN_CLONEID: u32 = 0x0000_0100;
+ const ATTR_CMN_CLONEREFS: u32 = 0x0000_1000;
+ const FSOPT_ATTR_CMN_EXTENDED: u32 = 0x0000_0020;
+ const FSOPT_PACK_INVAL_ATTRS: u32 = 0x0000_0008;
+ const KAUTH_FILESEC_MAGIC: u32 = 0x012c_c16d;
+ /// The `kauth_filesec` entry-count value that means "no ACL". Anything
+ /// else — including a count of zero for an *explicitly empty* ACL — is an
+ /// ACL: inheritance can tell those apart, and cloning must not change that
+ /// policy. An absent ACL never reaches this value; it arrives as a
+ /// zero-length reference, which is handled separately.
+ const KAUTH_FILESEC_NOACL: u32 = u32::MAX;
+
+ // Byte offsets inside an `fgetattrlist` response: a u32 length, the
+ // returned attrlist (bitmapcount and reserved are NOT repeated), then
+ // packed attribute data.
+ const RESPONSE_LENGTH: usize = 0;
+ const RESPONSE_COMMONATTR: usize = 4;
+ const RESPONSE_FORKATTR: usize = 20;
+ /// An `attrreference_t` — { i32 offset, u32 length } — whose offset is
+ /// relative to *itself*, not to the start of the buffer.
+ const RESPONSE_SECURITY_OFFSET: usize = 24;
+ const RESPONSE_SECURITY_LENGTH: usize = 28;
+ const RESPONSE_PRIVATE_SIZE: usize = 32;
+ const RESPONSE_CLONE_ID: usize = 40;
+ /// Smallest legal response for a regular file: header, security reference,
+ /// private size, clone id, and the empty clone-reference count.
+ const FIXED_FILE_SIZE: usize = 52;
+ /// Directories request no forkattr, so only the header plus the reference.
+ const FIXED_DIRECTORY_SIZE: usize = 32;
+ /// A `kauth_filesec` must be at least this long for its entry count to sit
+ /// inside the buffer.
+ const MINIMUM_SECURITY_ATTRIBUTE: usize = 44;
+ const SECURITY_ENTRY_COUNT: usize = 36;
+ /// Large enough for the maximum Darwin ACL, not just the count we read.
+ const ATTRIBUTE_BUFFER: usize = 8192;
+ /// Bound memory and skip metadata we could not safely reproduce anyway.
+ const MAXIMUM_XATTR_BYTES: usize = 1024 * 1024;
+ const MAXIMUM_REPORTED_ERRORS: usize = 3;
+ const HASH_CHUNK: usize = 128 * 1024;
+ const STAGING_PREFIX: &str = ".treehouse-sharing-";
+ const DIRECTORY_FLAGS: c_int =
+ libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC;
+
+ /// A cheap preflight so unsupported volumes do not pay for Git enumeration
+ /// and status. [`share`] repeats these checks through no-follow handles; a
+ /// pass here is a hint, not a promise.
+ pub fn filesystem_reason(source: &Path, destination: &Path) -> String {
+ let (Ok(source_fs), Ok(destination_fs)) = (statfs_path(source), statfs_path(destination))
+ else {
+ return "filesystem cannot be verified".to_string();
+ };
+ if !is_apfs(&source_fs) || !is_apfs(&destination_fs) {
+ return "requires APFS on both paths".to_string();
+ }
+ // Same volume means same `st_dev` on macOS. libc keeps `fsid_t`'s two
+ // halves private, and the authoritative check inside `share` compares
+ // `st_dev` on the opened handles — so the preflight and the real check
+ // share one predicate instead of two that could drift.
+ match (metadata_path(source), metadata_path(destination)) {
+ (Ok(source_meta), Ok(destination_meta))
+ if source_meta.st_dev == destination_meta.st_dev =>
+ {
+ String::new()
+ }
+ _ => "source and destination are on different volumes".to_string(),
+ }
+ }
+
+ /// Share identical file data between `source` and `destination` without
+ /// sharing writable inodes.
+ ///
+ /// `paths` are relative, `Clean`-form paths interpreted under
+ /// `destination`; anything else is counted as an `unsafe_path` skip. The
+ /// source file is `source` joined with the same relative path.
+ ///
+ /// # Safety contract with the caller
+ ///
+ /// The caller must **exclusively own `destination` for the duration of this
+ /// call**: no hooks, no smudge filters, no fsmonitor, no other process.
+ /// Neither the identity checks nor the final atomic `renameat` can protect
+ /// against a concurrent writer — they only narrow the window. Go states the
+ /// same requirement in the package comment, and the lifecycle satisfies it
+ /// by only ever passing a fresh, reserved slot.
+ ///
+ /// Failure is not fatal. A [`ShareError`] that
+ /// [`ShareError::is_fatal`] rejects is one file left untouched and recorded
+ /// in [`Report::errors`]; a fatal one means the caller must fall back to a
+ /// plain copy rather than continue.
+ ///
+ /// A fatal error carries no [`Report`]. Go returns the partial one
+ /// alongside, but after a changed destination, a failed cleanup, or a
+ /// cancellation the destination is in a state the caller must not reason
+ /// about — a partial count would read as a partial success.
+ pub fn share(
+ source: &Path,
+ destination: &Path,
+ paths: &[impl AsRef],
+ ) -> Result {
+ share_cancellable(source, destination, paths, &Cancel::never())
+ }
+
+ /// [`share`], aborting promptly when `cancel` is tripped. See [`Cancel`] for
+ /// why the signal handling lives with the caller.
+ pub fn share_cancellable(
+ source: &Path,
+ destination: &Path,
+ paths: &[impl AsRef],
+ cancel: &Cancel,
+ ) -> Result {
+ share_with(cancel, source, destination, paths, &production_ops())
+ }
+
+ // ── the pass ──────────────────────────────────────────────────────────
+
+ /// The two operations the port keeps injectable. Behavioral failure tests
+ /// override these rather than reaching into production state, so they can
+ /// never change behavior for another pass or for a real caller.
+ type CloneFn<'a> = &'a dyn Fn(RawFd, RawFd, &CStr) -> io::Result<()>;
+ type MetadataFn<'a> = &'a dyn Fn(RawFd, &Metadata<'_>) -> io::Result<()>;
+
+ struct Ops<'a> {
+ clone: CloneFn<'a>,
+ metadata: MetadataFn<'a>,
+ }
+
+ /// `fn` pointers in statics so the production operations can be handed out
+ /// as `&'static dyn Fn` — the same shape a behavioral test overrides with a
+ /// capturing closure.
+ static PRODUCTION_CLONE: fn(RawFd, RawFd, &CStr) -> io::Result<()> = clone_file;
+ static PRODUCTION_METADATA: fn(RawFd, &Metadata<'_>) -> io::Result<()> = restore_metadata;
+
+ fn production_ops() -> Ops<'static> {
+ Ops {
+ clone: &PRODUCTION_CLONE,
+ metadata: &PRODUCTION_METADATA,
+ }
+ }
+
+ fn share_with(
+ cancel: &Cancel,
+ source: &Path,
+ destination: &Path,
+ paths: &[impl AsRef],
+ ops: &Ops<'_>,
+ ) -> Result {
+ let mut report = Report::default();
+ let source_dir = match open_directory(source) {
+ Ok(dir) => dir,
+ Err(error) => {
+ report.reason = format!("source path unavailable or symlinked: {error}");
+ return Ok(report);
+ }
+ };
+ let destination_dir = match open_directory(destination) {
+ Ok(dir) => dir,
+ Err(error) => {
+ report.reason = format!("destination path unavailable or symlinked: {error}");
+ return Ok(report);
+ }
+ };
+
+ // Everything below re-verifies the preflight through the pinned
+ // handles: a path can be swapped between the check and the open.
+ let source_fs = match fstatfs(source_dir.as_raw_fd()) {
+ Ok(fs) => fs,
+ Err(_) => return Ok(refuse(report, "source filesystem cannot be verified")),
+ };
+ let destination_fs = match fstatfs(destination_dir.as_raw_fd()) {
+ Ok(fs) => fs,
+ Err(_) => return Ok(refuse(report, "destination filesystem cannot be verified")),
+ };
+ if !is_apfs(&source_fs) || !is_apfs(&destination_fs) {
+ return Ok(refuse(report, "requires APFS on both paths"));
+ }
+ let source_stat = match fstat(source_dir.as_raw_fd()) {
+ Ok(stat) => stat,
+ Err(_) => return Ok(refuse(report, "source identity cannot be verified")),
+ };
+ let destination_stat = match fstat(destination_dir.as_raw_fd()) {
+ Ok(stat) => stat,
+ Err(_) => return Ok(refuse(report, "destination identity cannot be verified")),
+ };
+ if source_stat.st_dev != destination_stat.st_dev {
+ return Ok(refuse(
+ report,
+ "source and destination are on different volumes",
+ ));
+ }
+ if source_stat.st_ino == destination_stat.st_ino {
+ return Ok(refuse(
+ report,
+ "source and destination are the same directory",
+ ));
+ }
+
+ for path in paths {
+ let path = AsRef::::as_ref(path);
+ if cancel.is_cancelled() {
+ return Err(ShareError::Cancelled);
+ }
+ if !is_safe_relative_path(path) {
+ report.skip("unsafe_path");
+ continue;
+ }
+ // The staging guard records teardown failures here; Rust `Drop`
+ // cannot return one, and a cleanup failure must abort the pass.
+ let cleanup = Cell::new(None);
+ let outcome = share_file(
+ cancel,
+ &source_dir,
+ &destination_dir,
+ destination,
+ path,
+ &destination_stat,
+ ops,
+ &cleanup,
+ );
+ if let Some(message) = cleanup.take() {
+ return Err(ShareError::Cleanup(message));
+ }
+ match outcome {
+ Err(error) if error.is_fatal() => return Err(error),
+ Err(error) => {
+ report.skip("kept_after_error");
+ if report.errors.len() < MAXIMUM_REPORTED_ERRORS {
+ report.errors.push(format!("{path}: {error}"));
+ }
+ }
+ Ok(Outcome {
+ reason: Some(reason),
+ ..
+ }) => report.skip(reason),
+ Ok(Outcome {
+ reason: None,
+ logical,
+ saved,
+ }) => {
+ report.cloned += 1;
+ report.logical_bytes += logical;
+ report.private_bytes_reduced += saved;
+ }
+ }
+ }
+ Ok(report)
+ }
+
+ fn refuse(mut report: Report, reason: &str) -> Report {
+ report.reason = reason.to_string();
+ report
+ }
+
+ struct Outcome {
+ reason: Option<&'static str>,
+ logical: u64,
+ saved: u64,
+ }
+
+ impl Outcome {
+ fn skip(reason: &'static str) -> Outcome {
+ Outcome {
+ reason: Some(reason),
+ logical: 0,
+ saved: 0,
+ }
+ }
+
+ fn cloned(logical: u64, saved: u64) -> Outcome {
+ Outcome {
+ reason: None,
+ logical,
+ saved,
+ }
+ }
+ }
+
+ /// The metadata a published clone must carry: the *destination's* original
+ /// stat, ACL verdict, and xattrs — never the source's.
+ struct Metadata<'a> {
+ stat: &'a libc::stat,
+ attributes: Attributes,
+ xattrs: BTreeMap, Vec>,
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn share_file(
+ cancel: &Cancel,
+ source: &OwnedFd,
+ destination: &OwnedFd,
+ destination_path: &Path,
+ path: &str,
+ root_stat: &libc::stat,
+ ops: &Ops<'_>,
+ cleanup: &Cell>,
+ ) -> Result {
+ let (parent_path, leaf) = split_leaf(path);
+ let leaf = cstr(leaf.as_bytes())?;
+
+ // A parent we cannot open no-follow is a *skip*, not a failure: the
+ // destination is simply not shaped like the source here.
+ let Ok(parent) = open_relative_directory(destination.as_raw_fd(), parent_path) else {
+ return Ok(Outcome::skip("destination_missing_or_symlink"));
+ };
+ let Ok((destination_file, old)) = open_regular(parent.as_raw_fd(), &leaf) else {
+ return Ok(Outcome::skip("destination_not_regular_or_symlink"));
+ };
+ if old.st_size < MINIMUM_SIZE as off_t {
+ return Ok(Outcome::skip("below_threshold"));
+ }
+ let Ok(source_parent) = open_relative_directory(source.as_raw_fd(), parent_path) else {
+ return Ok(Outcome::skip("source_missing_or_symlink"));
+ };
+ let Ok((source_file, donor)) = open_regular(source_parent.as_raw_fd(), &leaf) else {
+ return Ok(Outcome::skip("source_not_regular_or_symlink"));
+ };
+
+ if old.st_dev != donor.st_dev {
+ return Ok(Outcome::skip("different_volume"));
+ }
+ if old.st_ino == donor.st_ino {
+ return Ok(Outcome::skip("same_file"));
+ }
+ // A second link means someone else's name points at these blocks;
+ // replacing the destination would change what they see.
+ if old.st_nlink != 1 || donor.st_nlink != 1 {
+ return Ok(Outcome::skip("hardlink"));
+ }
+ if old.st_uid != donor.st_uid || old.st_gid != donor.st_gid {
+ return Ok(Outcome::skip("different_owner"));
+ }
+ if old.st_flags != 0
+ || donor.st_flags != 0
+ || old.st_mode & 0o7000 != 0
+ || donor.st_mode & 0o7000 != 0
+ {
+ return Ok(Outcome::skip("unsupported_flags_or_mode"));
+ }
+ if old.st_size != donor.st_size {
+ return Ok(Outcome::skip("different_content"));
+ }
+ // Conservatively exclude sparse and compressed representations. Their
+ // hashes may match while the metadata changes how the bytes are
+ // interpreted — and a clone would silently drop the sparseness.
+ if allocated_bytes(&old) < old.st_size as u64
+ || allocated_bytes(&donor) < donor.st_size as u64
+ {
+ return Ok(Outcome::skip("sparse_or_compressed"));
+ }
+
+ // Settle this newly checked-out file's allocation before measuring
+ // private bytes; logical payload is reported separately and is never a
+ // substitute.
+ fsync(destination_file.as_raw_fd())
+ .map_err(|error| context(error, "measuring allocation"))?;
+ let destination_attributes = file_attributes(destination_file.as_raw_fd())
+ .map_err(|error| context(error, "destination attributes"))?;
+ let source_attributes = file_attributes(source_file.as_raw_fd())
+ .map_err(|error| context(error, "source attributes"))?;
+ let parent_attributes = file_attributes(parent.as_raw_fd())
+ .map_err(|error| context(error, "parent attributes"))?;
+ if destination_attributes.acl || source_attributes.acl || parent_attributes.acl {
+ // A directory ACL can make the clone inherit differently than a
+ // plain copy would, and we cannot reproduce the inheritance.
+ return Ok(Outcome::skip("acl"));
+ }
+ let xattrs = read_xattrs(destination_file.as_raw_fd())
+ .map_err(|error| context(error, "destination metadata"))?;
+ let donor_xattrs = read_xattrs(source_file.as_raw_fd())
+ .map_err(|error| context(error, "source metadata"))?;
+ for set in [&xattrs, &donor_xattrs] {
+ if set.contains_key(b"com.apple.decmpfs".as_slice())
+ || set.contains_key(b"com.apple.ResourceFork".as_slice())
+ {
+ return Ok(Outcome::skip("representation_xattr"));
+ }
+ }
+ let expected = hash(destination_file.as_raw_fd(), cancel)?;
+ if hash(source_file.as_raw_fd(), cancel)? != expected {
+ return Ok(Outcome::skip("different_content"));
+ }
+ if source_attributes.clone_id != 0
+ && source_attributes.clone_id == destination_attributes.clone_id
+ {
+ // Already the same physical blocks. Cloning again would only churn
+ // the directory entry, and would lose whatever made the first
+ // clone worth keeping.
+ return Ok(Outcome::skip("already_shared"));
+ }
+
+ // Sixteen unpredictable bytes: the staging name must not be guessable,
+ // or a hostile process could pre-create the directory and have us
+ // publish into its tree.
+ let mut random = [0u8; 16];
+ rand::rng().fill(&mut random);
+ let name = CString::new(format!("{STAGING_PREFIX}{}", hex(&random)))
+ .expect("a hex rendering cannot contain NUL");
+ if let Err(error) = mkdirat(parent.as_raw_fd(), &name, 0o700) {
+ return Err(error.into());
+ }
+ let staging_dir = match openat_owned(parent.as_raw_fd(), &name, DIRECTORY_FLAGS) {
+ Ok(dir) => dir,
+ Err(error) => {
+ return Err(
+ if let Err(cleanup_error) = unlinkat_removedir(parent.as_raw_fd(), &name) {
+ ShareError::Cleanup(format!("{error}; staging directory: {cleanup_error}"))
+ } else {
+ error.into()
+ },
+ );
+ }
+ };
+ let staging = Staging {
+ dir: staging_dir,
+ parent: parent.as_raw_fd(),
+ name,
+ failure: cleanup,
+ };
+
+ if cancel.is_cancelled() {
+ return Err(ShareError::Cancelled);
+ }
+ (ops.clone)(source_file.as_raw_fd(), staging.dir.as_raw_fd(), c"clone")
+ .map_err(|error| context(error, "clonefile"))?;
+ let (staged, _) = open_regular(staging.dir.as_raw_fd(), c"clone")?;
+ if hash(staged.as_raw_fd(), cancel)? != expected {
+ // The source changed between our hash and the clone. The staged
+ // copy is discarded with the staging directory.
+ return Ok(Outcome::skip("source_changed"));
+ }
+ let wanted = Metadata {
+ stat: &old,
+ attributes: destination_attributes,
+ xattrs,
+ };
+ (ops.metadata)(staged.as_raw_fd(), &wanted)
+ .map_err(|error| context(error, "preserving metadata"))?;
+ if hash(staged.as_raw_fd(), cancel)? != expected {
+ // Restoring metadata rewrote content. Publishing would be
+ // publishing something we never verified.
+ return Ok(Outcome::skip("metadata_changed_content"));
+ }
+ // Hashing can update atime; restore both timestamps after the last read.
+ restore_times(staged.as_raw_fd(), &old)?;
+ verify_metadata(staged.as_raw_fd(), &wanted)?;
+ let final_attributes = file_attributes(staged.as_raw_fd())?;
+ fsync(staged.as_raw_fd())?;
+ if cancel.is_cancelled() {
+ return Err(ShareError::Cancelled);
+ }
+
+ // Re-check the open file, its name, the destination root and the
+ // parent. This still requires exclusive ownership: no filesystem check
+ // can close the interval between here and the rename.
+ let now = fstat(destination_file.as_raw_fd())?;
+ let named = fstatat(parent.as_raw_fd(), &leaf, libc::AT_SYMLINK_NOFOLLOW)?;
+ if !same_identity(&old, &now)
+ || !same_identity(&old, &named)
+ || !directory_unchanged(destination_path, root_stat)
+ || !parent_unchanged(destination.as_raw_fd(), parent_path, &parent)
+ {
+ return Err(ShareError::DestinationChanged(path.to_string()));
+ }
+ renameat(staging.dir.as_raw_fd(), c"clone", parent.as_raw_fd(), &leaf)?;
+
+ let saved = destination_attributes
+ .private_bytes
+ .saturating_sub(final_attributes.private_bytes);
+ Ok(Outcome::cloned(old.st_size as u64, saved))
+ }
+
+ /// Removes the staged clone and the staging directory when a file is
+ /// finished with — published, refused, or errored.
+ ///
+ /// Go joins a cleanup failure onto the in-flight error. `Drop` cannot return
+ /// one, so a failure is stashed for the caller to promote: the distinction
+ /// matters, because an incomplete teardown must abort the pass and must
+ /// never be resolved by deleting data we did not create.
+ struct Staging<'a> {
+ dir: OwnedFd,
+ parent: RawFd,
+ name: CString,
+ failure: &'a Cell>,
+ }
+
+ impl Staging<'_> {
+ /// Keeps the first failure: later ones are consequences of it, and the
+ /// first is the one that names the actual cause.
+ fn record(&self, message: String) {
+ if self.failure.take().is_none() {
+ self.failure.set(Some(message));
+ }
+ }
+ }
+
+ impl Drop for Staging<'_> {
+ fn drop(&mut self) {
+ // Only the clone WE named. Anything else in the directory is not
+ // ours to remove, and the rmdir below will report that. An already
+ // absent clone means the rename published it, which is success.
+ if let Err(error) = unlinkat_nodir(self.dir.as_raw_fd(), c"clone")
+ && error.raw_os_error() != Some(libc::ENOENT)
+ {
+ self.record(format!("unlinking staged clone: {error}"));
+ }
+ // Re-identify the directory through BOTH the descriptor we hold and
+ // its name in the parent, so a staging directory that was moved or
+ // replaced is never removed by name alone.
+ let identity = match (
+ fstat(self.dir.as_raw_fd()),
+ fstatat(self.parent, &self.name, libc::AT_SYMLINK_NOFOLLOW),
+ ) {
+ (Ok(opened), Ok(named)) => {
+ (opened.st_dev, opened.st_ino) == (named.st_dev, named.st_ino)
+ }
+ _ => false,
+ };
+ if !identity {
+ // The name no longer resolves to the directory we created.
+ // Something replaced or moved it; refuse to remove anything.
+ self.record(format!(
+ "staging directory {} was replaced before removal",
+ self.name.to_string_lossy()
+ ));
+ return;
+ }
+ if let Err(error) = unlinkat_removedir(self.parent, &self.name) {
+ self.record(format!("removing staging directory: {error}"));
+ }
+ }
+ }
+
+ // ── attributes ────────────────────────────────────────────────────────
+
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
+ struct Attributes {
+ /// APFS allocated bytes. Zero when the file's data is entirely shared
+ /// with another clone.
+ private_bytes: u64,
+ /// The data-stream identity. Two files with the same non-zero clone id
+ /// read the same physical blocks.
+ clone_id: u64,
+ /// Whether the file carries an explicit ACL. An *empty* explicit ACL
+ /// counts: inheritance can distinguish it from "no ACL", and cloning
+ /// must not change that policy.
+ acl: bool,
+ }
+
+ fn file_attributes(fd: RawFd) -> io::Result {
+ let mut request = attrlist(ATTR_CMN_RETURNED_ATTRS | ATTR_CMN_EXTENDED_SECURITY, 0);
+ let stat = fstat(fd)?;
+ let mut fixed_size = FIXED_FILE_SIZE;
+ if stat.st_mode & libc::S_IFMT == libc::S_IFDIR {
+ // Parents need the ACL check but have no data streams to report.
+ fixed_size = FIXED_DIRECTORY_SIZE;
+ } else {
+ request.forkattr =
+ ATTR_CMN_FNDRINFO_PRIVATE_SIZE | ATTR_CMN_CLONEID | ATTR_CMN_CLONEREFS;
+ }
+ let mut buffer = [0u8; ATTRIBUTE_BUFFER];
+ // SAFETY: `request` and `buffer` are live locals; fgetattrlist writes at
+ // most `buffer.len()` bytes and does not retain either pointer.
+ let code = unsafe {
+ libc::fgetattrlist(
+ fd,
+ (&raw mut request).cast(),
+ buffer.as_mut_ptr().cast(),
+ buffer.len(),
+ FSOPT_ATTR_CMN_EXTENDED | FSOPT_PACK_INVAL_ATTRS,
+ )
+ };
+ if code != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ let length = read_u32(&buffer, RESPONSE_LENGTH)? as usize;
+ let common = read_u32(&buffer, RESPONSE_COMMONATTR)?;
+ let fork = read_u32(&buffer, RESPONSE_FORKATTR)?;
+ if length < fixed_size
+ || length > buffer.len()
+ || common & ATTR_CMN_RETURNED_ATTRS == 0
+ || fork & request.forkattr != request.forkattr
+ {
+ return Err(invalid_data(format!(
+ "APFS allocation/security attributes unavailable: \
+ length={length} common={common:#x} extended={fork:#x}"
+ )));
+ }
+ let reference = read_i32(&buffer, RESPONSE_SECURITY_OFFSET)?;
+ let security_size = read_u32(&buffer, RESPONSE_SECURITY_LENGTH)?;
+ let acl = if security_size == 0 {
+ false
+ } else {
+ if common & ATTR_CMN_EXTENDED_SECURITY == 0 {
+ return Err(invalid_data("security attribute was not returned"));
+ }
+ let start = RESPONSE_SECURITY_OFFSET as i64 + i64::from(reference);
+ if start < fixed_size as i64
+ || security_size < MINIMUM_SECURITY_ATTRIBUTE as u32
+ || start + i64::from(security_size) > length as i64
+ {
+ return Err(invalid_data("invalid Darwin security attribute"));
+ }
+ let start = start as usize;
+ if read_u32(&buffer, start)? != KAUTH_FILESEC_MAGIC {
+ return Err(invalid_data("unknown Darwin security attribute"));
+ }
+ read_u32(&buffer, start + SECURITY_ENTRY_COUNT)? != KAUTH_FILESEC_NOACL
+ };
+ Ok(Attributes {
+ private_bytes: if request.forkattr == 0 {
+ 0
+ } else {
+ read_u64(&buffer, RESPONSE_PRIVATE_SIZE)?
+ },
+ clone_id: if request.forkattr == 0 {
+ 0
+ } else {
+ read_u64(&buffer, RESPONSE_CLONE_ID)?
+ },
+ acl,
+ })
+ }
+
+ /// `fsetattrlist` preserves nanoseconds; `futimes` would truncate them to
+ /// microseconds and silently change a file a caller is about to trust.
+ fn restore_times(fd: RawFd, wanted: &libc::stat) -> io::Result<()> {
+ let mut request = attrlist(ATTR_CMN_MODTIME | ATTR_CMN_ACCTIME, 0);
+ // The attribute buffer is `struct timespec[2]` in
+ // {modification, access} order.
+ let times = [
+ libc::timespec {
+ tv_sec: wanted.st_mtime,
+ tv_nsec: wanted.st_mtime_nsec,
+ },
+ libc::timespec {
+ tv_sec: wanted.st_atime,
+ tv_nsec: wanted.st_atime_nsec,
+ },
+ ];
+ let size = size_of::() * times.len();
+ // SAFETY: both structs are live locals and `size` is their combined
+ // length; fsetattrlist copies the data and retains neither pointer.
+ let code = unsafe {
+ libc::fsetattrlist(
+ fd,
+ (&raw mut request).cast(),
+ times.as_ptr().cast_mut().cast(),
+ size,
+ 0,
+ )
+ };
+ if code != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+ }
+
+ fn attrlist(commonattr: u32, forkattr: u32) -> libc::attrlist {
+ libc::attrlist {
+ bitmapcount: ATTR_BIT_MAP_COUNT,
+ reserved: 0,
+ commonattr,
+ volattr: 0,
+ dirattr: 0,
+ fileattr: 0,
+ forkattr,
+ }
+ }
+
+ // ── xattrs ────────────────────────────────────────────────────────────
+
+ /// Reads the destination's own xattrs so they can be reproduced exactly.
+ /// No source-only xattr — quarantine, provenance, anything — may leak onto
+ /// the destination, which means the whole set has to be readable, not just
+ /// the names we care about.
+ fn read_xattrs(fd: RawFd) -> io::Result, Vec>> {
+ let mut result = BTreeMap::new();
+ let size = list_xattr_size(fd)?;
+ if size > MAXIMUM_XATTR_BYTES {
+ return Err(invalid_data("extended attribute names exceed safety limit"));
+ }
+ if size == 0 {
+ return Ok(result);
+ }
+ let mut names = vec![0u8; size];
+ let written = list_xattrs(fd, &mut names)?;
+ if written > names.len() {
+ return Err(invalid_data("extended attribute list changed"));
+ }
+ let mut total = 0usize;
+ for name in names[..written].split(|byte| *byte == 0) {
+ if name.is_empty() {
+ continue;
+ }
+ let size = xattr_size(fd, name)?;
+ total += size;
+ if total > MAXIMUM_XATTR_BYTES {
+ return Err(invalid_data("extended attributes exceed safety limit"));
+ }
+ let mut value = vec![0u8; size];
+ let read = get_xattr(fd, name, &mut value)?;
+ if read > value.len() {
+ return Err(invalid_data("extended attribute changed"));
+ }
+ value.truncate(read);
+ result.insert(name.to_vec(), value);
+ }
+ Ok(result)
+ }
+
+ fn restore_metadata(fd: RawFd, wanted: &Metadata<'_>) -> io::Result<()> {
+ let current = read_xattrs(fd)?;
+ for name in current.keys() {
+ if !wanted.xattrs.contains_key(name) {
+ remove_xattr(fd, name)?;
+ }
+ }
+ for (name, value) in &wanted.xattrs {
+ if current.get(name) == Some(value) {
+ continue;
+ }
+ set_xattr(fd, name, value)?;
+ }
+ // Mode and times last: a failure here must leave a clone that does not
+ // look publishable, not one that does.
+ fchmod(fd, wanted.stat.st_mode)?;
+ restore_times(fd, wanted.stat)
+ }
+
+ fn verify_metadata(fd: RawFd, wanted: &Metadata<'_>) -> io::Result<()> {
+ let stat = fstat(fd)?;
+ if stat.st_mode != wanted.stat.st_mode
+ || stat.st_uid != wanted.stat.st_uid
+ || stat.st_gid != wanted.stat.st_gid
+ || stat.st_size != wanted.stat.st_size
+ || !same_time(
+ stat.st_mtime,
+ stat.st_mtime_nsec,
+ wanted.stat.st_mtime,
+ wanted.stat.st_mtime_nsec,
+ )
+ || !same_time(
+ stat.st_atime,
+ stat.st_atime_nsec,
+ wanted.stat.st_atime,
+ wanted.stat.st_atime_nsec,
+ )
+ || stat.st_flags != wanted.stat.st_flags
+ || stat.st_nlink != 1
+ {
+ return Err(invalid_data(
+ "cloning did not preserve destination metadata",
+ ));
+ }
+ if file_attributes(fd)?.acl != wanted.attributes.acl {
+ return Err(invalid_data("cloning changed ACL"));
+ }
+ if read_xattrs(fd)? != wanted.xattrs {
+ return Err(invalid_data("cloning changed extended attributes"));
+ }
+ Ok(())
+ }
+
+ // ── paths and identities ──────────────────────────────────────────────
+
+ /// The parent directory and the leaf of a relative path, both relative to a
+ /// destination root. An empty parent means the root itself, which is what
+ /// Go's `filepath.Dir` expresses as `"."`.
+ fn split_leaf(path: &str) -> (&Path, &str) {
+ match path.rsplit_once('/') {
+ Some((parent, leaf)) => (Path::new(parent), leaf),
+ None => (Path::new(""), path),
+ }
+ }
+
+ /// Rejects a symlink in every component, including either root. Once
+ /// returned, traversal and publication use the pinned descriptor, so a
+ /// rename of an ancestor afterwards cannot redirect us.
+ fn open_directory(path: &Path) -> io::Result {
+ let absolute = std::path::absolute(path)?;
+ let root = openat_owned(libc::AT_FDCWD, c"/", DIRECTORY_FLAGS)?;
+ open_relative_directory(root.as_raw_fd(), &absolute)
+ }
+
+ /// `O_NOFOLLOW` on every step is what turns a symlinked component into an
+ /// error instead of a redirection. `..` is opened like any other component,
+ /// because a caller-supplied root may legitimately name one.
+ fn open_relative_directory(root: RawFd, path: &Path) -> io::Result {
+ let mut fd = openat_owned(root, c".", DIRECTORY_FLAGS)?;
+ for component in path.components() {
+ // The root is already pinned by the descriptor we were handed, and
+ // "." is this directory by definition. Every other component —
+ // including ".." — is opened, so the walk ends on exactly the
+ // directory the caller named.
+ let part = match component {
+ Component::RootDir | Component::CurDir => continue,
+ Component::ParentDir => Path::new("..").as_os_str(),
+ Component::Normal(part) => part,
+ Component::Prefix(_) => return Err(invalid_data("unsupported path prefix")),
+ };
+ let part = cstr(part.as_bytes())?;
+ let next = openat_owned(fd.as_raw_fd(), &part, DIRECTORY_FLAGS);
+ // Replaced by `next` either way, so close the old descriptor.
+ drop(fd);
+ fd = next?;
+ }
+ Ok(fd)
+ }
+
+ /// Opens a regular file, refusing symlinks. `O_NONBLOCK` keeps a FIFO from
+ /// blocking the pass forever; the `S_IFREG` check rejects it anyway.
+ fn open_regular(parent: RawFd, name: &CStr) -> io::Result<(OwnedFd, libc::stat)> {
+ let file = openat_owned(
+ parent,
+ name,
+ libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC,
+ )?;
+ let stat = fstat(file.as_raw_fd())?;
+ if stat.st_mode & libc::S_IFMT != libc::S_IFREG {
+ return Err(invalid_data("not a readable regular file"));
+ }
+ Ok((file, stat))
+ }
+
+ fn same_identity(a: &libc::stat, b: &libc::stat) -> bool {
+ a.st_dev == b.st_dev
+ && a.st_ino == b.st_ino
+ && a.st_size == b.st_size
+ && same_time(a.st_mtime, a.st_mtime_nsec, b.st_mtime, b.st_mtime_nsec)
+ && same_time(a.st_ctime, a.st_ctime_nsec, b.st_ctime, b.st_ctime_nsec)
+ && a.st_nlink == b.st_nlink
+ }
+
+ fn same_time(a_sec: time_t, a_nsec: c_long, b_sec: time_t, b_nsec: c_long) -> bool {
+ a_sec == b_sec && a_nsec == b_nsec
+ }
+
+ /// Re-resolves the destination root through its original path. A root that
+ /// was renamed away and replaced is no longer the directory we were given.
+ fn directory_unchanged(path: &Path, before: &libc::stat) -> bool {
+ open_directory(path)
+ .and_then(|dir| fstat(dir.as_raw_fd()))
+ .is_ok_and(|now| now.st_dev == before.st_dev && now.st_ino == before.st_ino)
+ }
+
+ fn parent_unchanged(root: RawFd, path: &Path, parent: &OwnedFd) -> bool {
+ let Ok(fresh) = open_relative_directory(root, path) else {
+ return false;
+ };
+ match (fstat(fresh.as_raw_fd()), fstat(parent.as_raw_fd())) {
+ (Ok(a), Ok(b)) => a.st_dev == b.st_dev && a.st_ino == b.st_ino,
+ _ => false,
+ }
+ }
+
+ fn allocated_bytes(stat: &libc::stat) -> u64 {
+ // A negative block count is not a thing; treating it as zero makes the
+ // sparseness check refuse rather than proceed.
+ u64::try_from(stat.st_blocks)
+ .unwrap_or(0)
+ .saturating_mul(512)
+ }
+
+ // ── content ───────────────────────────────────────────────────────────
+
+ /// `pread` rather than seek-then-read: hashing must not disturb the
+ /// descriptor's offset, and the file is only ever read through this handle.
+ /// It does update atime, which is why [`restore_times`] runs after the last
+ /// hash.
+ fn hash(fd: RawFd, cancel: &Cancel) -> Result<[u8; 32], ShareError> {
+ let mut hasher = Sha256::new();
+ let mut buffer = vec![0u8; HASH_CHUNK];
+ let mut offset: off_t = 0;
+ loop {
+ if cancel.is_cancelled() {
+ return Err(ShareError::Cancelled);
+ }
+ let read = pread(fd, &mut buffer, offset)?;
+ if read == 0 {
+ return Ok(hasher.finalize().into());
+ }
+ hasher.update(&buffer[..read]);
+ offset += read as off_t;
+ }
+ }
+
+ fn clone_file(source: RawFd, destination_dir: RawFd, name: &CStr) -> io::Result<()> {
+ // SAFETY: `name` is a valid C string and both descriptors are live for
+ // the call. fclonefileat takes ownership of neither.
+ let code = unsafe { libc::fclonefileat(source, destination_dir, name.as_ptr(), 0) };
+ if code != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+ }
+
+ // ── syscall and buffer helpers ────────────────────────────────────────
+
+ /// Attach Go's `%w`-style context to a syscall error without dropping the
+ /// errno from the message.
+ fn context(error: io::Error, message: &str) -> ShareError {
+ ShareError::Io(io::Error::new(error.kind(), format!("{message}: {error}")))
+ }
+
+ fn invalid_data(message: impl Into) -> io::Error {
+ io::Error::new(io::ErrorKind::InvalidData, message.into())
+ }
+
+ fn cstr(value: &[u8]) -> io::Result {
+ CString::new(value).map_err(|_| invalid_data("path contains a NUL byte"))
+ }
+
+ /// Lowercase hex. `LowerHex` is not implemented for arrays, and the
+ /// staging name has to be exactly as unguessable as the bytes in it.
+ fn hex(bytes: &[u8]) -> String {
+ let mut out = String::with_capacity(bytes.len() * 2);
+ for byte in bytes {
+ out.push(char::from_digit(u32::from(byte >> 4), 16).expect("nibble is a hex digit"));
+ out.push(char::from_digit(u32::from(byte & 0xf), 16).expect("nibble is a hex digit"));
+ }
+ out
+ }
+
+ fn openat_owned(dirfd: RawFd, path: &CStr, flags: c_int) -> io::Result {
+ // SAFETY: `path` is a valid C string for the duration of the call, and a
+ // non-negative return is a fresh descriptor we now own.
+ let fd = unsafe { libc::openat(dirfd, path.as_ptr(), flags) };
+ if fd < 0 {
+ return Err(io::Error::last_os_error());
+ }
+ // SAFETY: `fd` is fresh and owned; nothing else will close it.
+ Ok(unsafe { OwnedFd::from_raw_fd(fd) })
+ }
+
+ fn fstat(fd: RawFd) -> io::Result {
+ // SAFETY: `fd` is live and `stat` is a plain C struct we own.
+ let mut stat = unsafe { std::mem::zeroed::() };
+ if unsafe { libc::fstat(fd, &raw mut stat) } != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(stat)
+ }
+
+ fn fstatat(dirfd: RawFd, path: &CStr, flags: c_int) -> io::Result {
+ // SAFETY: as `fstat`, plus a valid `path`.
+ let mut stat = unsafe { std::mem::zeroed::() };
+ if unsafe { libc::fstatat(dirfd, path.as_ptr(), &raw mut stat, flags) } != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(stat)
+ }
+
+ fn fstatfs(fd: RawFd) -> io::Result {
+ // SAFETY: as `fstat`, plus a correctly sized `statfs` buffer.
+ let mut statfs = unsafe { std::mem::zeroed::() };
+ if unsafe { libc::fstatfs(fd, &raw mut statfs) } != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(statfs)
+ }
+
+ fn statfs_path(path: &Path) -> io::Result {
+ let name = cstr(path.as_os_str().as_bytes())?;
+ // SAFETY: `name` is a valid C string and `statfs` is a plain C struct
+ // we own.
+ let mut statfs = unsafe { std::mem::zeroed::() };
+ if unsafe { libc::statfs(name.as_ptr(), &raw mut statfs) } != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(statfs)
+ }
+
+ fn metadata_path(path: &Path) -> io::Result {
+ let name = cstr(path.as_os_str().as_bytes())?;
+ // SAFETY: as `statfs_path`, for `stat`. Following a symlink is fine in
+ // a preflight; the authoritative check runs on no-follow handles.
+ let mut stat = unsafe { std::mem::zeroed::() };
+ if unsafe { libc::stat(name.as_ptr(), &raw mut stat) } != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(stat)
+ }
+
+ /// The filesystem name from a `statfs`, NUL-padded in a fixed 16-byte field.
+ /// Only APFS supports the clone data streams this module reads, and only
+ /// APFS gives a meaningful answer to the allocation and clone-id queries.
+ fn is_apfs(statfs: &libc::statfs) -> bool {
+ let name: Vec = statfs
+ .f_fstypename
+ .iter()
+ .map(|byte| *byte as u8)
+ .take_while(|byte| *byte != 0)
+ .collect();
+ name == b"apfs"
+ }
+
+ fn fsync(fd: RawFd) -> io::Result<()> {
+ // SAFETY: `fd` is live for the call; fsync only takes a descriptor.
+ if unsafe { libc::fsync(fd) } != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+ }
+
+ fn fchmod(fd: RawFd, mode: mode_t) -> io::Result<()> {
+ // SAFETY: `fd` is live for the call.
+ if unsafe { libc::fchmod(fd, mode) } != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+ }
+
+ fn mkdirat(dirfd: RawFd, name: &CStr, mode: mode_t) -> io::Result<()> {
+ // SAFETY: `dirfd` and `name` are valid for the call.
+ if unsafe { libc::mkdirat(dirfd, name.as_ptr(), mode) } != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+ }
+
+ fn unlinkat_nodir(dirfd: RawFd, name: &CStr) -> io::Result<()> {
+ // SAFETY: `dirfd` and `name` are valid for the call.
+ if unsafe { libc::unlinkat(dirfd, name.as_ptr(), 0) } != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+ }
+
+ fn unlinkat_removedir(dirfd: RawFd, name: &CStr) -> io::Result<()> {
+ // SAFETY: `dirfd` and `name` are valid for the call.
+ if unsafe { libc::unlinkat(dirfd, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+ }
+
+ fn renameat(old_dirfd: RawFd, old: &CStr, new_dirfd: RawFd, new: &CStr) -> io::Result<()> {
+ // SAFETY: both descriptors and both names are valid for the call.
+ if unsafe { libc::renameat(old_dirfd, old.as_ptr(), new_dirfd, new.as_ptr()) } != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+ }
+
+ fn pread(fd: RawFd, buffer: &mut [u8], offset: off_t) -> io::Result {
+ // SAFETY: `buffer` is writable for `len` bytes and `fd` is live; pread
+ // writes at most `len` bytes and does not move the file offset.
+ let read = unsafe { libc::pread(fd, buffer.as_mut_ptr().cast(), buffer.len(), offset) };
+ if read < 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(read as usize)
+ }
+
+ fn list_xattr_size(fd: RawFd) -> io::Result {
+ // SAFETY: a null buffer with size 0 is the documented way to ask for the
+ // required length; `fd` is live.
+ let size = unsafe { libc::flistxattr(fd, std::ptr::null_mut(), 0, 0) };
+ if size < 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(size as usize)
+ }
+
+ fn list_xattrs(fd: RawFd, buffer: &mut [u8]) -> io::Result {
+ // SAFETY: `buffer` is writable for `buffer.len()` bytes, which is the
+ // length we pass, and `fd` is live.
+ let read = unsafe { libc::flistxattr(fd, buffer.as_mut_ptr().cast(), buffer.len(), 0) };
+ if read < 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(read as usize)
+ }
+
+ fn xattr_size(fd: RawFd, name: &[u8]) -> io::Result {
+ let name = cstr(name)?;
+ // SAFETY: as `list_xattr_size`, for a single named attribute.
+ let size = unsafe { libc::fgetxattr(fd, name.as_ptr(), std::ptr::null_mut(), 0, 0, 0) };
+ if size < 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(size as usize)
+ }
+
+ fn get_xattr(fd: RawFd, name: &[u8], value: &mut [u8]) -> io::Result {
+ let name = cstr(name)?;
+ // SAFETY: `value` is writable for `value.len()` bytes, which is the
+ // length we pass, and `fd` is live.
+ let read = unsafe {
+ libc::fgetxattr(
+ fd,
+ name.as_ptr(),
+ value.as_mut_ptr().cast(),
+ value.len(),
+ 0,
+ 0,
+ )
+ };
+ if read < 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(read as usize)
+ }
+
+ fn set_xattr(fd: RawFd, name: &[u8], value: &[u8]) -> io::Result<()> {
+ let name = cstr(name)?;
+ // SAFETY: both buffers are valid for the lengths we pass.
+ let code =
+ unsafe { libc::fsetxattr(fd, name.as_ptr(), value.as_ptr().cast(), value.len(), 0, 0) };
+ if code != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+ }
+
+ fn remove_xattr(fd: RawFd, name: &[u8]) -> io::Result<()> {
+ let name = cstr(name)?;
+ // SAFETY: `name` is a valid C string and `fd` is live.
+ if unsafe { libc::fremovexattr(fd, name.as_ptr(), 0) } != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+ }
+
+ fn read_u32(buffer: &[u8], offset: usize) -> io::Result {
+ let bytes: [u8; 4] = buffer
+ .get(offset..offset + 4)
+ .and_then(|slice| slice.try_into().ok())
+ .ok_or_else(|| invalid_data(format!("response truncated at offset {offset}")))?;
+ Ok(u32::from_le_bytes(bytes))
+ }
+
+ fn read_i32(buffer: &[u8], offset: usize) -> io::Result {
+ let bytes: [u8; 4] = buffer
+ .get(offset..offset + 4)
+ .and_then(|slice| slice.try_into().ok())
+ .ok_or_else(|| invalid_data(format!("response truncated at offset {offset}")))?;
+ Ok(i32::from_le_bytes(bytes))
+ }
+
+ fn read_u64(buffer: &[u8], offset: usize) -> io::Result {
+ let bytes: [u8; 8] = buffer
+ .get(offset..offset + 8)
+ .and_then(|slice| slice.try_into().ok())
+ .ok_or_else(|| invalid_data(format!("response truncated at offset {offset}")))?;
+ Ok(u64::from_le_bytes(bytes))
+ }
+
+ // ─── tests ────────────────────────────────────────────────────────────
+ //
+ // Ported from Go's `sharing_darwin_test.go`, `safety_darwin_test.go`,
+ // `metadata_safety_darwin_test.go` and `attributes_darwin_test.go`, plus
+ // one end-to-end pass over a real git worktree. Go's `volumes_darwin_test.go`
+ // is deliberately NOT ported: it mounts disposable disk images, which is not
+ // available in every macOS sandbox, and Go already gates it behind
+ // `TREEHOUSE_TEST_VOLUMES=1`. The volume refusals it covers are still
+ // reachable here through `filesystem_reason` and the same-directory check.
+
+ #[cfg(test)]
+ mod tests {
+ use super::*;
+ use std::os::unix::fs::{MetadataExt, symlink};
+ use std::path::PathBuf;
+
+ /// A temporary directory on APFS, in its symlink-free form.
+ ///
+ /// The raw `TempDir` path can sit behind `/var` → `/private/var`, and
+ /// the no-follow walk refuses a symlinked component by design. Go's
+ /// `cloneFixture` calls `filepath.EvalSymlinks` for the same reason;
+ /// every directory handed to `share` in these tests must come from
+ /// [`Self::path`], never from the `TempDir` directly.
+ struct ApfsDir {
+ _root: tempfile::TempDir,
+ path: PathBuf,
+ }
+
+ impl ApfsDir {
+ /// `None` when the temporary directory is not on APFS; the caller
+ /// skips, exactly as Go's `cloneFixture` does.
+ fn new() -> Option {
+ let root = tempfile::tempdir().ok()?;
+ let path = root.path().canonicalize().ok()?;
+ let on_apfs = statfs_path(&path).ok().is_some_and(|fs| is_apfs(&fs));
+ on_apfs.then_some(ApfsDir { _root: root, path })
+ }
+
+ fn path(&self) -> &Path {
+ &self.path
+ }
+ }
+
+ /// A source/destination pair holding one identical file on APFS. The
+ /// Rust form of Go's `cloneFixture`.
+ struct Fixture {
+ /// Kept alive so the directories outlive every path handed to
+ /// `share`.
+ _root: ApfsDir,
+ source: PathBuf,
+ destination: PathBuf,
+ data: Vec,
+ }
+
+ impl Fixture {
+ fn new() -> Option {
+ let root = ApfsDir::new()?;
+ let source = root.path().join("source");
+ let destination = root.path().join("target");
+ let data = b"independent file data\n".repeat(8192);
+ for dir in [&source, &destination] {
+ std::fs::create_dir(dir).ok()?;
+ write_test_file(&dir.join("asset"), &data);
+ }
+ Some(Fixture {
+ _root: root,
+ source,
+ destination,
+ data,
+ })
+ }
+ }
+
+ /// Skips the calling test, loudly, when there is no APFS volume to work
+ /// on. A silent pass would be indistinguishable from a real one.
+ macro_rules! fixture {
+ () => {
+ match Fixture::new() {
+ Some(fixture) => fixture,
+ None => {
+ eprintln!("skipping: the temporary directory is not on APFS");
+ eprintln!(" (set TMPDIR to an APFS volume to exercise this path)");
+ return;
+ }
+ }
+ };
+ }
+
+ /// Writes and settles the file, so its APFS allocation is real before
+ /// anything measures it.
+ fn write_test_file(path: &Path, data: &[u8]) {
+ use std::io::Write;
+ let mut file = std::fs::File::create(path).expect("create test file");
+ file.write_all(data).expect("write test file");
+ file.sync_all().expect("settle test file allocation");
+ }
+
+ fn edit_in_place(path: &Path, data: &[u8]) {
+ use std::io::{Seek, SeekFrom, Write};
+ let mut file = std::fs::OpenOptions::new()
+ .write(true)
+ .open(path)
+ .expect("open for edit");
+ file.seek(SeekFrom::Start(0)).expect("seek");
+ file.write_all(data).expect("edit");
+ }
+
+ fn attributes_of(path: &Path) -> (Attributes, libc::stat) {
+ let file = std::fs::File::open(path).expect("open for attributes");
+ let fd = file.as_raw_fd();
+ let attributes = file_attributes(fd).expect("read attributes");
+ (attributes, fstat(fd).expect("fstat"))
+ }
+
+ fn xattrs_of(path: &Path) -> BTreeMap, Vec> {
+ let file = std::fs::File::open(path).expect("open for xattrs");
+ read_xattrs(file.as_raw_fd()).expect("read xattrs")
+ }
+
+ fn set_path_xattr(path: &Path, name: &str, value: &[u8]) {
+ let file = std::fs::OpenOptions::new()
+ .write(true)
+ .open(path)
+ .expect("open for xattr");
+ set_xattr(file.as_raw_fd(), name.as_bytes(), value).expect("set xattr");
+ }
+
+ fn set_mode(path: &Path, mode: mode_t) {
+ let file = std::fs::File::open(path).expect("open for chmod");
+ fchmod(file.as_raw_fd(), mode).expect("chmod");
+ }
+
+ /// `utimensat` takes {access, modification}, the reverse of the
+ /// `fsetattrlist` order used to restore them.
+ fn set_times(path: &Path, atime: libc::timespec, mtime: libc::timespec) {
+ let name = cstr(path.as_os_str().as_bytes()).expect("path");
+ let times = [atime, mtime];
+ // SAFETY: `name` and `times` are valid for the lengths we pass, and
+ // neither is retained.
+ let code = unsafe { libc::utimensat(libc::AT_FDCWD, name.as_ptr(), times.as_ptr(), 0) };
+ assert_eq!(code, 0, "utimensat: {}", io::Error::last_os_error());
+ }
+
+ fn add_acl(path: &Path) {
+ let output = std::process::Command::new("chmod")
+ .args(["+a", "everyone allow read"])
+ .arg(path)
+ .output()
+ .expect("chmod must be available");
+ assert!(
+ output.status.success(),
+ "chmod +a {}: {}",
+ path.display(),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ }
+
+ /// Any staging directory left anywhere under `root` is a bug: the guard
+ /// tears it down on every path out of a file.
+ fn assert_no_staging(root: &Path) {
+ let Ok(entries) = std::fs::read_dir(root) else {
+ return;
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ let name = entry.file_name();
+ assert!(
+ !name.to_string_lossy().starts_with(STAGING_PREFIX),
+ "staging survived: {}",
+ path.display()
+ );
+ if path.is_dir() {
+ assert_no_staging(&path);
+ }
+ }
+ }
+
+ /// The staging directories currently present directly under `root`.
+ fn staging_paths(root: &Path) -> Vec {
+ let Ok(entries) = std::fs::read_dir(root) else {
+ return Vec::new();
+ };
+ entries
+ .flatten()
+ .map(|entry| entry.path())
+ .filter(|path| {
+ path.file_name()
+ .is_some_and(|name| name.to_string_lossy().starts_with(STAGING_PREFIX))
+ })
+ .collect()
+ }
+
+ fn skipped(report: &Report, reason: &str) -> u64 {
+ report.skipped.get(reason).copied().unwrap_or(0)
+ }
+
+ // ── the guarantee that makes the whole thing worth doing ──────────
+
+ /// Go `TestSharePreservesMetadataAndIndependentFiles`. The clone must
+ /// be copy-on-write (not a shared writable inode) and must carry the
+ /// DESTINATION's metadata, with no source-only xattr leaking across.
+ #[test]
+ fn share_preserves_metadata_and_keeps_the_files_independent() {
+ let fixture = fixture!();
+ let source_file = fixture.source.join("asset");
+ let destination_file = fixture.destination.join("asset");
+
+ // Make the destination's own metadata distinctive in every
+ // dimension the port promises to preserve.
+ set_mode(&destination_file, 0o751);
+ let stamp = libc::timespec {
+ tv_sec: 1_234_567_890,
+ tv_nsec: 123_456_789,
+ };
+ set_times(&destination_file, stamp, stamp);
+ set_path_xattr(&source_file, "com.treehouse.source-only", b"donor");
+ set_path_xattr(&destination_file, "com.treehouse.destination", b"original");
+
+ let (before, old) = attributes_of(&destination_file);
+ let report = share(&fixture.source, &fixture.destination, &["asset"]).expect("pass");
+ assert_eq!(report.cloned, 1, "{report}");
+ assert_eq!(report.logical_bytes, fixture.data.len() as u64, "{report}");
+ assert_eq!(
+ report.private_bytes_reduced, before.private_bytes,
+ "{report}"
+ );
+
+ let (source_attributes, source_stat) = attributes_of(&source_file);
+ let (after, stat) = attributes_of(&destination_file);
+ assert_ne!(
+ source_attributes.clone_id, 0,
+ "a shared file has a data stream"
+ );
+ assert_eq!(
+ source_attributes.clone_id, after.clone_id,
+ "the data is not shared"
+ );
+ assert_ne!(source_stat.st_ino, stat.st_ino, "the clone shares an inode");
+ assert_ne!(stat.st_ino, old.st_ino, "the destination inode was reused");
+ assert_eq!(
+ after.private_bytes, 0,
+ "the clone still allocates its own data"
+ );
+ assert_eq!(stat.st_mode, old.st_mode, "mode changed");
+ assert_eq!(
+ (stat.st_mtime, stat.st_mtime_nsec),
+ (old.st_mtime, old.st_mtime_nsec),
+ "mtime changed"
+ );
+ assert_eq!(
+ (stat.st_atime, stat.st_atime_nsec),
+ (old.st_atime, old.st_atime_nsec),
+ "atime changed"
+ );
+
+ let xattrs = xattrs_of(&destination_file);
+ assert_eq!(
+ xattrs
+ .get(b"com.treehouse.destination".as_slice())
+ .map(Vec::as_slice),
+ Some(b"original".as_slice()),
+ "the destination's own xattr was not preserved"
+ );
+ assert!(
+ !xattrs.contains_key(b"com.treehouse.source-only".as_slice()),
+ "a source-only xattr leaked onto the destination"
+ );
+
+ // A second pass has nothing left to do.
+ let report = share(&fixture.source, &fixture.destination, &["asset"]).expect("pass");
+ assert_eq!(skipped(&report, "already_shared"), 1, "{report}");
+
+ // The whole point: copy-on-write, not a shared writable inode.
+ edit_in_place(&destination_file, b"target edit");
+ assert_eq!(
+ std::fs::read(&source_file).expect("read source"),
+ fixture.data,
+ "a target edit changed the source"
+ );
+ write_test_file(&source_file, b"source edit");
+ std::fs::remove_file(&source_file).expect("remove source");
+ let published = std::fs::read(&destination_file).expect("read target");
+ assert!(published.starts_with(b"target edit"));
+ assert_eq!(
+ &published[11..],
+ &fixture.data[11..],
+ "a source edit or deletion affected the clone"
+ );
+ assert_no_staging(&fixture.destination);
+ }
+
+ // ── refusals ─────────────────────────────────────────────────────
+
+ /// Go `TestShareSameSizeDifferentBytes`.
+ #[test]
+ fn share_refuses_same_size_different_bytes() {
+ let fixture = fixture!();
+ write_test_file(
+ &fixture.destination.join("asset"),
+ &vec![b'x'; fixture.data.len()],
+ );
+ let report = share(&fixture.source, &fixture.destination, &["asset"]).expect("pass");
+ assert_eq!(skipped(&report, "different_content"), 1, "{report}");
+ assert_eq!(report.cloned, 0, "{report}");
+ }
+
+ /// Go `TestShareSymlinkParentsAndUnsafePaths`.
+ #[test]
+ fn share_refuses_unsafe_and_symlinked_paths() {
+ let fixture = fixture!();
+ let destination_file = fixture.destination.join("asset");
+ symlink(&fixture.destination, fixture.destination.join("link")).expect("symlink");
+
+ let report = share(
+ &fixture.source,
+ &fixture.destination,
+ &["link/asset", "../target/asset"],
+ )
+ .expect("pass");
+ assert_eq!(
+ skipped(&report, "destination_missing_or_symlink"),
+ 1,
+ "{report}"
+ );
+ assert_eq!(skipped(&report, "unsafe_path"), 1, "{report}");
+
+ // A symlinked ROOT is refused outright, and its target is untouched.
+ let alias = fixture
+ .destination
+ .parent()
+ .expect("a fixture root")
+ .join("alias");
+ symlink(&fixture.destination, &alias).expect("symlink");
+ let report = share(&fixture.source, &alias, &["asset"]).expect("pass");
+ assert!(report.reason.contains("symlink"), "{report}");
+ assert_eq!(
+ std::fs::read(&destination_file).expect("read"),
+ fixture.data,
+ "the symlink target changed"
+ );
+ }
+
+ /// Go `TestShareMinimumSizeBoundary`. One byte either side of the
+ /// threshold decides whether the setup cost is worth paying.
+ #[test]
+ fn share_honors_the_minimum_size_boundary() {
+ for size in [MINIMUM_SIZE - 1, MINIMUM_SIZE] {
+ let fixture = fixture!();
+ let data = vec![b'a'; size as usize];
+ write_test_file(&fixture.source.join("asset"), &data);
+ write_test_file(&fixture.destination.join("asset"), &data);
+ let report =
+ share(&fixture.source, &fixture.destination, &["asset"]).expect("pass");
+ assert_eq!(
+ report.cloned,
+ u64::from(size == MINIMUM_SIZE),
+ "size {size}: {report}"
+ );
+ }
+ }
+
+ /// Go `TestShareSafeSkips`. Each case must leave the destination
+ /// exactly as it found it.
+ #[test]
+ fn share_refuses_cases_it_cannot_reproduce() {
+ struct Case {
+ name: &'static str,
+ reason: &'static str,
+ change: fn(&Fixture),
+ }
+ let cases = [
+ Case {
+ name: "different content",
+ reason: "different_content",
+ change: |fixture| {
+ write_test_file(&fixture.destination.join("asset"), &vec![b'x'; 172_032])
+ },
+ },
+ Case {
+ name: "below threshold",
+ reason: "below_threshold",
+ change: |fixture| write_test_file(&fixture.destination.join("asset"), b"small"),
+ },
+ Case {
+ name: "hardlink",
+ reason: "hardlink",
+ change: |fixture| {
+ std::fs::hard_link(
+ fixture.destination.join("asset"),
+ fixture.destination.join("alias"),
+ )
+ .expect("hard link");
+ },
+ },
+ Case {
+ name: "symlink",
+ reason: "destination_not_regular_or_symlink",
+ change: |fixture| {
+ std::fs::remove_file(fixture.destination.join("asset")).expect("remove");
+ symlink(
+ fixture.source.join("asset"),
+ fixture.destination.join("asset"),
+ )
+ .expect("symlink");
+ },
+ },
+ Case {
+ name: "file ACL",
+ reason: "acl",
+ change: |fixture| add_acl(&fixture.destination.join("asset")),
+ },
+ Case {
+ name: "parent ACL",
+ reason: "acl",
+ change: |fixture| add_acl(&fixture.destination),
+ },
+ ];
+ for case in cases {
+ let Some(fixture) = Fixture::new() else {
+ eprintln!(
+ "skipping {}: the temporary directory is not on APFS",
+ case.name
+ );
+ continue;
+ };
+ (case.change)(&fixture);
+ let before = std::fs::symlink_metadata(fixture.destination.join("asset"))
+ .expect("lstat before");
+ let report =
+ share(&fixture.source, &fixture.destination, &["asset"]).expect("pass");
+ assert_eq!(report.cloned, 0, "{}: {report}", case.name);
+ assert_eq!(skipped(&report, case.reason), 1, "{}: {report}", case.name);
+ let after = std::fs::symlink_metadata(fixture.destination.join("asset"))
+ .expect("lstat after");
+ assert_eq!(
+ (before.dev(), before.ino(), before.mtime()),
+ (after.dev(), after.ino(), after.mtime()),
+ "{}: the skip replaced the destination",
+ case.name
+ );
+ assert_no_staging(&fixture.destination);
+ }
+ }
+
+ /// Go `TestSharingUnsupportedRepresentations`. These are the cases
+ /// where a matching hash does NOT mean the same file.
+ #[test]
+ fn share_refuses_unsupported_representations() {
+ struct Case {
+ name: &'static str,
+ reason: &'static str,
+ change: fn(&Fixture),
+ }
+ let cases = [
+ Case {
+ name: "flags",
+ reason: "unsupported_flags_or_mode",
+ change: |fixture| {
+ let target = cstr(fixture.destination.join("asset").as_os_str().as_bytes())
+ .expect("path");
+ // SAFETY: `target` is a valid C string and the flag is a
+ // documented `UF_*` value.
+ let code = unsafe { libc::chflags(target.as_ptr(), libc::UF_HIDDEN) };
+ assert_eq!(code, 0, "chflags: {}", io::Error::last_os_error());
+ },
+ },
+ Case {
+ name: "resource fork",
+ reason: "representation_xattr",
+ change: |fixture| {
+ set_path_xattr(
+ &fixture.destination.join("asset"),
+ "com.apple.ResourceFork",
+ b"representation metadata",
+ )
+ },
+ },
+ Case {
+ name: "sparse",
+ reason: "sparse_or_compressed",
+ change: |fixture| {
+ for root in [&fixture.source, &fixture.destination] {
+ let file = std::fs::OpenOptions::new()
+ .write(true)
+ .open(root.join("asset"))
+ .expect("open for truncate");
+ file.set_len(8 * 1024 * 1024).expect("truncate");
+ }
+ },
+ },
+ ];
+ for case in cases {
+ let Some(fixture) = Fixture::new() else {
+ eprintln!(
+ "skipping {}: the temporary directory is not on APFS",
+ case.name
+ );
+ continue;
+ };
+ (case.change)(&fixture);
+ let target = fixture.destination.join("asset");
+ let before = std::fs::read(&target).expect("read before");
+ let report =
+ share(&fixture.source, &fixture.destination, &["asset"]).expect("pass");
+ assert_eq!(report.cloned, 0, "{}: {report}", case.name);
+ assert_eq!(skipped(&report, case.reason), 1, "{}: {report}", case.name);
+ let after = std::fs::read(&target).expect("read after");
+ assert_eq!(
+ before, after,
+ "{}: an unsupported representation changed",
+ case.name
+ );
+ assert_no_staging(&fixture.destination);
+ }
+ }
+
+ /// Both sides missing, for different reasons, must not be conflated:
+ /// only the destination's identity is ever at risk.
+ #[test]
+ fn missing_side_reasons_are_distinguished() {
+ let fixture = fixture!();
+ std::fs::remove_file(fixture.source.join("asset")).expect("remove source");
+ let report = share(&fixture.source, &fixture.destination, &["asset"]).expect("pass");
+ assert_eq!(
+ skipped(&report, "source_not_regular_or_symlink"),
+ 1,
+ "{report}"
+ );
+
+ let fixture = fixture!();
+ std::fs::remove_file(fixture.destination.join("asset")).expect("remove destination");
+ let report = share(&fixture.source, &fixture.destination, &["asset"]).expect("pass");
+ assert_eq!(
+ skipped(&report, "destination_not_regular_or_symlink"),
+ 1,
+ "{report}"
+ );
+ }
+
+ // ── failure handling ─────────────────────────────────────────────
+
+ /// Go `TestShareFailureKeepsOriginal`. A failed `clonefileat` is a skip,
+ /// never a lost file: the caller falls back to a plain copy.
+ #[test]
+ fn share_keeps_the_original_when_cloning_fails() {
+ for (name, code) in [("ENOSPC", libc::ENOSPC), ("ENOTSUP", libc::ENOTSUP)] {
+ let fixture = fixture!();
+ let ops = Ops {
+ clone: &|_, _, _| Err(io::Error::from_raw_os_error(code)),
+ metadata: &PRODUCTION_METADATA,
+ };
+ let report = share_with(
+ &Cancel::never(),
+ &fixture.source,
+ &fixture.destination,
+ &["asset"],
+ &ops,
+ )
+ .expect("a per-file failure is not fatal");
+ assert_eq!(skipped(&report, "kept_after_error"), 1, "{name}: {report}");
+ assert_eq!(report.errors.len(), 1, "{name}: {report}");
+ assert_eq!(
+ std::fs::read(fixture.destination.join("asset")).expect("read"),
+ fixture.data,
+ "{name}: the failure changed the original"
+ );
+ assert_no_staging(&fixture.destination);
+ }
+ }
+
+ /// Go `TestShareSourceMutationAndMetadataFailure`, metadata branch.
+ #[test]
+ fn share_keeps_the_original_when_metadata_cannot_be_restored() {
+ let fixture = fixture!();
+ let ops = Ops {
+ clone: &PRODUCTION_CLONE,
+ metadata: &|_, _| Err(io::Error::from_raw_os_error(libc::EPERM)),
+ };
+ let report = share_with(
+ &Cancel::never(),
+ &fixture.source,
+ &fixture.destination,
+ &["asset"],
+ &ops,
+ )
+ .expect("a per-file failure is not fatal");
+ assert_eq!(report.cloned, 0, "{report}");
+ assert_eq!(skipped(&report, "kept_after_error"), 1, "{report}");
+ assert_eq!(
+ std::fs::read(fixture.destination.join("asset")).expect("read"),
+ fixture.data,
+ "the failure published something"
+ );
+ assert_no_staging(&fixture.destination);
+ }
+
+ /// Go `TestShareSourceMutationAndMetadataFailure`, source branch: the
+ /// donor changed between our hash and the clone, so the clone is not the
+ /// bytes we verified.
+ #[test]
+ fn share_discards_a_clone_of_a_source_that_changed() {
+ let fixture = fixture!();
+ let source_file = fixture.source.join("asset");
+ let length = fixture.data.len();
+ let ops = Ops {
+ clone: &|source, dir, name| {
+ // The donor changes BEFORE the clone, so the staged copy is
+ // not the bytes we hashed.
+ write_test_file(&source_file, &vec![b'x'; length]);
+ clone_file(source, dir, name)
+ },
+ metadata: &PRODUCTION_METADATA,
+ };
+ let report = share_with(
+ &Cancel::never(),
+ &fixture.source,
+ &fixture.destination,
+ &["asset"],
+ &ops,
+ )
+ .expect("pass");
+ assert_eq!(report.cloned, 0, "{report}");
+ assert_eq!(skipped(&report, "source_changed"), 1, "{report}");
+ assert_eq!(
+ std::fs::read(fixture.destination.join("asset")).expect("read"),
+ fixture.data,
+ "unverified content was published"
+ );
+ assert_no_staging(&fixture.destination);
+ }
+
+ /// Go `TestShareRejectsMetadataContentCorruption`. Restoring metadata
+ /// must not be able to rewrite content and still pass.
+ #[test]
+ fn share_discards_a_clone_whose_metadata_changed_its_content() {
+ let fixture = fixture!();
+ let length = fixture.data.len();
+ let ops = Ops {
+ clone: &PRODUCTION_CLONE,
+ metadata: &|fd, wanted| {
+ let staged = staging_paths(&fixture.destination)
+ .into_iter()
+ .next()
+ .expect("one staged clone");
+ write_test_file(&staged.join("clone"), &vec![b'x'; length]);
+ PRODUCTION_METADATA(fd, wanted)
+ },
+ };
+ let report = share_with(
+ &Cancel::never(),
+ &fixture.source,
+ &fixture.destination,
+ &["asset"],
+ &ops,
+ )
+ .expect("pass");
+ assert_eq!(report.cloned, 0, "{report}");
+ assert_eq!(skipped(&report, "metadata_changed_content"), 1, "{report}");
+ assert_eq!(
+ std::fs::read(fixture.destination.join("asset")).expect("read"),
+ fixture.data,
+ "corrupted content was published"
+ );
+ assert_no_staging(&fixture.destination);
+ }
+
+ /// Go `TestShareDetectsDestinationWriterAndCancellation`, writer branch:
+ /// a concurrent writer to the destination aborts the pass and its bytes
+ /// survive.
+ #[test]
+ fn share_refuses_to_publish_over_a_concurrent_write() {
+ let fixture = fixture!();
+ let destination_file = fixture.destination.join("asset");
+ let ops = Ops {
+ clone: &|source, dir, name| {
+ clone_file(source, dir, name)?;
+ write_test_file(&destination_file, b"new edit");
+ Ok(())
+ },
+ metadata: &PRODUCTION_METADATA,
+ };
+ let error = share_with(
+ &Cancel::never(),
+ &fixture.source,
+ &fixture.destination,
+ &["asset"],
+ &ops,
+ )
+ .expect_err("a concurrent writer must abort the pass");
+ assert!(
+ matches!(error, ShareError::DestinationChanged(_)),
+ "{error}"
+ );
+ assert_eq!(
+ std::fs::read(&destination_file).expect("read"),
+ b"new edit",
+ "the concurrent write was lost"
+ );
+ assert_no_staging(&fixture.destination);
+ }
+
+ /// Go `TestShareDetectsDestinationWriterAndCancellation`, cancel branch.
+ /// Cancellation must not publish and must not leave staging behind.
+ #[test]
+ fn share_aborts_on_cancellation_and_keeps_the_original() {
+ let fixture = fixture!();
+ let token = Cancel::new();
+ let ops = Ops {
+ clone: &|source, dir, name| {
+ clone_file(source, dir, name)?;
+ token.cancel();
+ Ok(())
+ },
+ metadata: &PRODUCTION_METADATA,
+ };
+ let error = share_with(
+ &token,
+ &fixture.source,
+ &fixture.destination,
+ &["asset"],
+ &ops,
+ )
+ .expect_err("cancellation must abort the pass");
+ assert!(matches!(error, ShareError::Cancelled), "{error}");
+ assert_eq!(
+ std::fs::read(fixture.destination.join("asset")).expect("read"),
+ fixture.data,
+ "a cancelled pass published something"
+ );
+ assert_no_staging(&fixture.destination);
+ }
+
+ /// A pass cancelled before it starts shares nothing at all.
+ #[test]
+ fn a_pass_cancelled_before_it_starts_shares_nothing() {
+ let fixture = fixture!();
+ let token = Cancel::new();
+ token.cancel();
+ let error =
+ share_cancellable(&fixture.source, &fixture.destination, &["asset"], &token)
+ .expect_err("cancellation must abort the pass");
+ assert!(matches!(error, ShareError::Cancelled), "{error}");
+ assert_no_staging(&fixture.destination);
+ }
+
+ /// Go `TestShareRejectsDestinationParentReplacement`. The parent is
+ /// re-resolved through the descriptor we pinned, so a directory swapped
+ /// underneath us is caught — and the new parent's file is not
+ /// overwritten by a clone staged in the old one.
+ #[test]
+ fn share_refuses_to_publish_into_a_replaced_destination_parent() {
+ let fixture = fixture!();
+ for root in [&fixture.source, &fixture.destination] {
+ std::fs::create_dir(root.join("sub")).expect("mkdir");
+ std::fs::rename(root.join("asset"), root.join("sub/asset")).expect("move");
+ }
+ let sub = fixture.destination.join("sub");
+ let ops = Ops {
+ clone: &|source, dir, name| {
+ clone_file(source, dir, name)?;
+ std::fs::rename(&sub, fixture.destination.join("saved")).expect("rename away");
+ std::fs::create_dir(&sub).expect("mkdir");
+ write_test_file(&sub.join("asset"), b"replacement");
+ Ok(())
+ },
+ metadata: &PRODUCTION_METADATA,
+ };
+ let error = share_with(
+ &Cancel::never(),
+ &fixture.source,
+ &fixture.destination,
+ &["sub/asset"],
+ &ops,
+ )
+ .expect_err("a replaced parent must abort the pass");
+ assert!(
+ matches!(error, ShareError::DestinationChanged(_)),
+ "{error}"
+ );
+ assert_eq!(
+ std::fs::read(sub.join("asset")).expect("read"),
+ b"replacement",
+ "the new parent was overwritten"
+ );
+ assert_eq!(
+ std::fs::read(fixture.destination.join("saved/asset")).expect("read"),
+ fixture.data,
+ "the old parent was overwritten"
+ );
+ assert_no_staging(&fixture.destination);
+ }
+
+ /// Go `TestSharingIncompleteCleanupFailsWithoutDeletingUnknownData`.
+ /// A staging directory we cannot empty is reported, and the data in it
+ /// is left for the operator instead of being deleted recursively.
+ #[test]
+ fn an_incomplete_cleanup_aborts_without_deleting_unknown_data() {
+ let fixture = fixture!();
+ let ops = Ops {
+ clone: &PRODUCTION_CLONE,
+ metadata: &|fd, wanted| {
+ let staged = staging_paths(&fixture.destination)
+ .into_iter()
+ .next()
+ .expect("one staging directory");
+ write_test_file(&staged.join("unknown"), b"preserve this");
+ PRODUCTION_METADATA(fd, wanted)
+ },
+ };
+ let error = share_with(
+ &Cancel::never(),
+ &fixture.source,
+ &fixture.destination,
+ &["asset"],
+ &ops,
+ )
+ .expect_err("an incomplete cleanup must abort the pass");
+ assert!(matches!(error, ShareError::Cleanup(_)), "{error}");
+ let survived = staging_paths(&fixture.destination);
+ assert_eq!(survived.len(), 1, "{error}");
+ assert_eq!(
+ std::fs::read(survived[0].join("unknown")).expect("read"),
+ b"preserve this",
+ "cleanup recursively deleted unknown data"
+ );
+ assert_eq!(
+ std::fs::read(fixture.destination.join("asset")).expect("read"),
+ fixture.data,
+ "the cleanup error changed bytes"
+ );
+ }
+
+ // ── preflight and attributes ─────────────────────────────────────
+
+ /// Go `TestAllocationAndACLAttributes`, which is also the live check
+ /// that the `getattrlist` response offsets in this module are right:
+ /// every one of these assertions depends on them.
+ #[test]
+ fn attributes_report_allocation_and_detect_acls() {
+ let Some(root) = ApfsDir::new() else {
+ eprintln!("skipping: the temporary directory is not on APFS");
+ return;
+ };
+ let source = root.path().join("source");
+ let target = root.path().join("target");
+ write_test_file(&source, &vec![b'a'; 128 * 1024]);
+
+ let (before, _) = attributes_of(&source);
+ assert!(!before.acl, "an ordinary file has no ACL: {before:?}");
+ assert!(
+ before.private_bytes >= 128 * 1024,
+ "an ordinary file allocates its own data: {before:?}"
+ );
+
+ if let Err(error) = clonefile_path(&source, &target) {
+ eprintln!("skipping: APFS clone unavailable: {error}");
+ return;
+ }
+ let (from, _) = attributes_of(&source);
+ let (into, _) = attributes_of(&target);
+ assert_ne!(from.clone_id, 0, "a clone has a data stream");
+ assert_eq!(from.clone_id, into.clone_id, "clonefile did not share data");
+ assert_eq!(
+ into.private_bytes, 0,
+ "the clone still allocates its own data"
+ );
+
+ add_acl(&target);
+ assert!(attributes_of(&target).0.acl, "ACL undetected");
+ }
+
+ fn clonefile_path(source: &Path, target: &Path) -> io::Result<()> {
+ let source = cstr(source.as_os_str().as_bytes())?;
+ let target = cstr(target.as_os_str().as_bytes())?;
+ // SAFETY: both are valid C strings for the duration of the call.
+ let code = unsafe { libc::clonefile(source.as_ptr(), target.as_ptr(), 0) };
+ if code != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+ }
+
+ /// The preflight is a hint, but it must never disagree with the check
+ /// the pass itself makes.
+ #[test]
+ fn the_preflight_agrees_with_the_pass() {
+ let fixture = fixture!();
+ assert_eq!(
+ filesystem_reason(&fixture.source, &fixture.destination),
+ "",
+ "one APFS volume must preflight clean"
+ );
+ assert_eq!(
+ filesystem_reason(Path::new("/does/not/exist"), &fixture.destination),
+ "filesystem cannot be verified"
+ );
+ let report = share(&fixture.source, &fixture.source, &["asset"]).expect("pass");
+ assert!(report.reason.contains("same directory"), "{report}");
+ assert_no_staging(&fixture.destination);
+ }
+
+ // ── end to end ───────────────────────────────────────────────────
+
+ /// The real path: a git repository, a real linked worktree, and a large
+ /// tracked file shared between them. Everything above works on
+ /// synthetic fixtures; this proves the module composes with the
+ /// checkout it will be pointed at.
+ #[test]
+ fn shares_a_large_tracked_file_into_a_real_git_worktree() {
+ let Some(root) = ApfsDir::new() else {
+ eprintln!("skipping: the temporary directory is not on APFS");
+ return;
+ };
+ if std::process::Command::new("git")
+ .arg("--version")
+ .output()
+ .is_err()
+ {
+ eprintln!("skipping: git is not installed");
+ return;
+ }
+ let repo = root.path().join("repo");
+ let worktree = root.path().join("slot");
+ let git = |dir: &Path, args: &[&str]| {
+ let output = std::process::Command::new("git")
+ .args(args)
+ .current_dir(dir)
+ .output()
+ .expect("git must be installed");
+ assert!(
+ output.status.success(),
+ "git {args:?} failed: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ };
+ let git_bytes = |dir: &Path, args: &[&str]| {
+ let output = std::process::Command::new("git")
+ .args(args)
+ .current_dir(dir)
+ .output()
+ .expect("git must be installed");
+ assert!(
+ output.status.success(),
+ "git {args:?} failed: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ output.stdout
+ };
+
+ git(
+ root.path(),
+ &["init", "--initial-branch=main", &repo.to_string_lossy()],
+ );
+ git(&repo, &["config", "user.email", "t@t.com"]);
+ git(&repo, &["config", "user.name", "T"]);
+ let payload: Vec = (0..1024 * 1024).map(|index| (index % 251) as u8).collect();
+ write_test_file(&repo.join("payload.bin"), &payload);
+ git(&repo, &["add", "payload.bin"]);
+ git(&repo, &["commit", "-m", "add payload"]);
+ git(
+ &repo,
+ &[
+ "worktree",
+ "add",
+ "--detach",
+ &worktree.to_string_lossy(),
+ "HEAD",
+ ],
+ );
+
+ assert_eq!(
+ filesystem_reason(&repo, &worktree),
+ "",
+ "the repo and the pool slot must be on one APFS volume"
+ );
+ let report = share(&repo, &worktree, &["payload.bin"]).expect("pass");
+ assert_eq!(report.cloned, 1, "{report}");
+ assert_eq!(report.logical_bytes, payload.len() as u64, "{report}");
+ assert!(
+ report.private_bytes_reduced > 0,
+ "the checkout did not stop allocating its own data: {report}"
+ );
+
+ // The checkout is still byte-for-byte what git wrote, and git still
+ // considers the worktree clean.
+ assert_eq!(
+ std::fs::read(worktree.join("payload.bin")).expect("read"),
+ payload,
+ "sharing corrupted the checkout"
+ );
+ let status = git_bytes(
+ &worktree,
+ &["-c", "core.fsmonitor=false", "status", "--porcelain=v1"],
+ );
+ assert!(
+ status.is_empty(),
+ "git status after sharing: {}",
+ String::from_utf8_lossy(&status)
+ );
+
+ // The source kept its own allocation, so a write to the worktree
+ // has to be free of side effects.
+ assert_eq!(
+ std::fs::read(repo.join("payload.bin")).expect("read"),
+ payload,
+ "the source changed"
+ );
+ let (source_attributes, _) = attributes_of(&repo.join("payload.bin"));
+ let (worktree_attributes, _) = attributes_of(&worktree.join("payload.bin"));
+ assert_ne!(source_attributes.clone_id, 0);
+ assert_eq!(
+ source_attributes.clone_id, worktree_attributes.clone_id,
+ "the worktree copy is not sharing data with the repository"
+ );
+ // APFS reports zero private bytes at BOTH ends of a shared data
+ // stream, so only the clone's own figure proves the saving landed.
+ assert_eq!(
+ worktree_attributes.private_bytes, 0,
+ "the worktree copy still allocates its own data"
+ );
+ assert_no_staging(&worktree);
+
+ git(
+ &repo,
+ &["worktree", "remove", "--force", &worktree.to_string_lossy()],
+ );
+ }
+ }
+}
+
+// ─── everything else ─────────────────────────────────────────────────────────
+
+/// Non-macOS builds. Fourteen lines, like Go's `sharing_other.go`: the reason
+/// is already decided, and touching the caller's filesystem would be behavior
+/// this module has no way to make safe.
+#[cfg(not(target_os = "macos"))]
+mod other {
+ use super::{Cancel, Report, ShareError};
+ use std::path::Path;
+
+ pub fn filesystem_reason(_source: &Path, _destination: &Path) -> String {
+ "requires macOS APFS".to_string()
+ }
+
+ /// Does not inspect the paths it is handed.
+ pub fn share(
+ _source: &Path,
+ _destination: &Path,
+ _paths: &[impl AsRef],
+ ) -> Result {
+ unsupported()
+ }
+
+ pub fn share_cancellable(
+ source: &Path,
+ destination: &Path,
+ paths: &[impl AsRef],
+ _cancel: &Cancel,
+ ) -> Result {
+ share(source, destination, paths)
+ }
+
+ fn unsupported() -> Result {
+ Ok(Report {
+ reason: "requires macOS APFS".to_string(),
+ ..Report::default()
+ })
+ }
+
+ #[cfg(test)]
+ mod tests {
+ use super::*;
+
+ /// Go `sharing_other_test.go`: the unsupported platform must not
+ /// inspect the paths it is handed, and must report rather than fail.
+ #[test]
+ fn unsupported_platform_does_not_inspect_paths() {
+ let report = share(
+ Path::new("nonexistent-source"),
+ Path::new("nonexistent-destination"),
+ &["asset"],
+ )
+ .expect("unsupported platforms never fail the pass");
+ // `SUPPORTED` is covered by `supported_tests`; this module only
+ // compiles when it is false, so asserting it here would be a
+ // tautology.
+ assert_eq!(report.cloned, 0);
+ assert!(report.reason.contains("requires macOS"), "{report}");
+ assert!(report.skipped.is_empty());
+ }
+
+ #[test]
+ fn unsupported_platform_preflight_always_refuses() {
+ assert_eq!(
+ filesystem_reason(Path::new("/"), Path::new("/")),
+ "requires macOS APFS"
+ );
+ }
+ }
+}
+
+#[cfg(test)]
+mod supported_tests {
+ #[test]
+ fn support_matches_the_platform() {
+ assert_eq!(super::SUPPORTED, cfg!(target_os = "macos"));
+ }
+
+ #[test]
+ fn minimum_size_is_the_go_constant() {
+ assert_eq!(super::MINIMUM_SIZE, 64 * 1024);
+ }
+}
diff --git a/crates/treehouse-core/src/gc.rs b/crates/treehouse-core/src/gc.rs
index b9af118..be1ee88 100644
--- a/crates/treehouse-core/src/gc.rs
+++ b/crates/treehouse-core/src/gc.rs
@@ -142,12 +142,8 @@ impl Pool {
}
}
};
- let (candidate, skipped) = self.analyze_gc_candidate(
- wt,
- context.default_ref.as_deref(),
- opts,
- now,
- );
+ let (candidate, skipped) =
+ self.analyze_gc_candidate(wt, context.default_ref.as_deref(), opts, now);
if let Some(skip) = skipped {
result.skipped.push(skip);
continue;
@@ -837,6 +833,17 @@ mod tests {
"must resolve the worktree's own repository, got: {:?}",
context.repo_root
);
- assert!(!context.repo_root.starts_with(&pool.root));
+ // `Pool::root` IS the owning repository (`Pool::open` sets it to
+ // `repo_root`), so asserting against it just re-derived the same path
+ // and could only ever fail by symlink aliasing: on macOS git resolves
+ // `/var` → `/private/var` so the two strings differ and the assertion
+ // held, while on Linux `/tmp` is not a symlink and they matched.
+ // The mistake this test must actually catch is the repo collapsing to
+ // the pool directory, so compare against that.
+ assert!(
+ !context.repo_root.starts_with(pool.pool_dir()),
+ "the owning repository must not collapse to the pool directory, got: {:?}",
+ context.repo_root
+ );
}
}
diff --git a/crates/treehouse-core/src/git/mod.rs b/crates/treehouse-core/src/git/mod.rs
index 03f8fa9..50b1011 100644
--- a/crates/treehouse-core/src/git/mod.rs
+++ b/crates/treehouse-core/src/git/mod.rs
@@ -7,6 +7,12 @@
//! MSVC CRT rules handle spaces).
use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+// The safety check and the reset it authorizes must be named together or not
+// at all — see [`ResetGuard`]. Re-exported so an implementor can write the
+// signature without also importing the seam module.
+pub use crate::vcs::ResetGuard;
/// A git repository, split into its common (main) dir and optional linked
/// worktree dir.
@@ -59,6 +65,12 @@ impl GitError {
///
/// Every method uses the exact git invocation from the Go baseline and
/// interprets exit codes identically.
+///
+/// The tail of the trait — [`Self::name`], [`Self::resolve_for_worktree`], the
+/// guarded-reset pair, and the seeding hooks — is the SEAM: it is what lets a
+/// second backend answer for its own worktrees without any call site changing.
+/// All of it defaults, so this trait is complete for the one backend that
+/// ships today and open for the one that does not.
pub trait GitBackend: Send + Sync {
/// Root of the repository containing `start` (Go `FindRepoRootFrom`).
fn repo_root(&self, start: &Path) -> Result;
@@ -153,6 +165,171 @@ pub trait GitBackend: Send + Sync {
/// The ref to check out for `branch` (Go `branchRef`): strictly-ahead
/// ref wins, origin on divergence, whichever exists otherwise.
fn branch_ref(&self, repo: &GitRepo, branch: &str) -> String;
+
+ // ─── Backend identity ───────────────────────────────────────────────────
+ //
+ // Everything below is declared here with a default that either delegates
+ // to the seam or refuses, so adding a backend never requires editing this
+ // trait — and therefore never risks colliding with whoever else is editing
+ // a dispatch site. Defaults are NOT "and that is fine": each one names the
+ // backend that MUST override it.
+
+ /// This backend's name (Go `Backend.Name`), matching the name it is
+ /// registered under in [`crate::vcs::BackendRegistry`].
+ ///
+ /// Defaults to `"git"`, which is correct for the only backend this port
+ /// ships today and WRONG for any other. A backend that forgets to override
+ /// it cannot be found under its own marker, so its worktrees are refused
+ /// loudly rather than answered by git — a failure that is annoying rather
+ /// than silent, which is the safe direction for a wrong name.
+ fn name(&self) -> &'static str {
+ "git"
+ }
+
+ /// The backend that owns `path` for operations that REWRITE its checkout
+ /// (Go `destructiveBackendForWorktree`).
+ ///
+ /// Dispatches on the path's OWN marker, never on which backend was asked:
+ /// the configured backend must not answer for a slot of the other flavor.
+ /// A path with no marker is REFUSED, never answered by the enclosing
+ /// repository — in the supported in-project pool layout that repository is
+ /// the user's working tree, and rewriting it is irreversible.
+ ///
+ /// Returns a shared handle rather than an owned backend: dispatch is a
+ /// registry lookup, so handing out a clone per call would both cost more
+ /// and risk the caller acting on a different instance than everyone else.
+ ///
+ /// The default routes through
+ /// [`crate::vcs::destructive_backend_for_worktree`], so every backend gets
+ /// registry dispatch for free. Override only to restrict the answer; do
+ /// not re-implement the marker rule.
+ fn resolve_for_worktree(&self, path: &Path) -> Result, GitError> {
+ crate::vcs::destructive_backend_for_worktree(path)
+ }
+
+ // ─── The guarded-reset pair ─────────────────────────────────────────────
+
+ /// Reports whether `worktree` can be reset to `branch` without discarding
+ /// committed work, and captures BOTH the immutable reset target and the
+ /// HEAD the ancestry check ran against (Go `IsWorktreeSafeToReset`).
+ ///
+ /// The two travel together in a [`ResetGuard`] because they are only
+ /// meaningful together: the caller must hand both to
+ /// [`Self::reset_worktree_to_ref`] so the check and the reset share one
+ /// target and a HEAD that moved in between is refused.
+ ///
+ /// Fails CLOSED — an unresolvable ref, an unreadable HEAD, or a git
+ /// failure is an `Err`, and callers treat that as "not safe". `guard.safe
+ /// == false` is a real answer the caller may weigh; `Err` means the
+ /// question could not be answered at all.
+ ///
+ /// The logic lives in [`crate::vcs::is_worktree_safe_to_reset`] because it
+ /// needs the git binary, which only a concrete backend holds. The git
+ /// implementation forwards to it with `self.git_bin()`.
+ fn is_worktree_safe_to_reset(
+ &self,
+ worktree: &Path,
+ branch: &str,
+ ) -> Result {
+ let _ = (worktree, branch); // Refuses by design; see the default's contract.
+ Err(crate::vcs::unsupported(
+ self.name(),
+ "is_worktree_safe_to_reset",
+ ))
+ }
+
+ /// Resets `worktree` to an ALREADY RESOLVED commit, re-verifying first (Go
+ /// `ResetWorktreeToRef`).
+ ///
+ /// `expected_head` is the HEAD [`Self::is_worktree_safe_to_reset`] recorded.
+ /// The re-read and the destructive update both run while holding git's own
+ /// `HEAD.lock` (created `O_CREAT|O_EXCL`), so a concurrent commit,
+ /// checkout, merge, or rebase cannot slip a new commit in after the
+ /// comparison. When `require_clean` is set, dirtiness is re-checked under
+ /// that same lock before the tree is touched.
+ ///
+ /// REFUSES — before touching anything — if the marker is gone, if either
+ /// argument is not a commit id, if HEAD moved, or if `require_clean` and
+ /// the tree is dirty. A plain reset without that re-read is WORSE than no
+ /// reset: callers believe the guards and the reset shared one target, so a
+ /// commit landing in the gap is discarded while every check reports success.
+ ///
+ /// The logic lives in [`crate::vcs::reset_worktree_to_ref`]; the git
+ /// implementation forwards to it with `self.git_bin()`.
+ fn reset_worktree_to_ref(
+ &self,
+ worktree: &Path,
+ reset_ref: &str,
+ expected_head: &str,
+ require_clean: bool,
+ ) -> Result<(), GitError> {
+ let _ = (worktree, reset_ref, expected_head, require_clean);
+ Err(crate::vcs::unsupported(
+ self.name(),
+ "reset_worktree_to_ref",
+ ))
+ }
+
+ // ─── Capabilities the later agents implement ────────────────────────────
+ //
+ // Declared NOW, defaulting to a refusal, so the jj and seeding agents never
+ // have to edit this trait. Each default says which backend must override it.
+
+ /// The shared git metadata directory for `start` (Go `CommonGitDir`): where
+ /// the repo-local, untracked `.git/info/exclude` lives.
+ ///
+ /// Backends with no usable git dir return an error and callers degrade
+ /// gracefully — an absent exclude file is normal, not a failure.
+ fn common_git_dir(&self, start: &Path) -> Result {
+ let _ = start;
+ Err(crate::vcs::unsupported(self.name(), "common_git_dir"))
+ }
+
+ /// Copies backend-specific ignored files into a new or recycled worktree
+ /// and returns THEIR PATHS (Go `SeedWorktree`).
+ ///
+ /// The returned inventory is what cleanup trusts later, rather than
+ /// re-reading mutable worktree metadata — so it is only as trustworthy as
+ /// this function's honesty about what it wrote.
+ ///
+ /// `manifest` is `None` to use the committed `.worktreeinclude` at the
+ /// DESTINATION HEAD, and `Some` to supply the manifest bytes directly; an
+ /// empty `Some(&[])` selects nothing.
+ fn seed_worktree(
+ &self,
+ repo: &GitRepo,
+ worktree: &Path,
+ manifest: Option<&[u8]>,
+ ) -> Result, GitError> {
+ let _ = (repo, worktree, manifest);
+ Err(crate::vcs::unsupported(self.name(), "seed_worktree"))
+ }
+
+ /// Resets a worktree after removing its trusted seed inventory (Go
+ /// `ResetWorktreeWithSeededPaths`).
+ ///
+ /// `seeded` is validated by [`crate::vcs::validate_seed_inventory`] before
+ /// anything is deleted on its word. **A `None` or empty inventory must NOT
+ /// authorize ignored-file deletion**: it means the caller lost its
+ /// bookkeeping, and honoring it converts a lost record into silent data
+ /// loss. Every implementation must call the validator first.
+ fn reset_worktree_with_seeded_paths(
+ &self,
+ worktree: &Path,
+ branch: &str,
+ seeded: &[String],
+ ) -> Result<(), GitError> {
+ let _ = (worktree, branch);
+ // Validate BEFORE the capability refusal. An empty inventory is
+ // refused on its own terms — not as "unsupported" — so the log says the
+ // caller lost its bookkeeping, which is a different bug from the
+ // backend lacking the capability.
+ crate::vcs::validate_seed_inventory(seeded)?;
+ Err(crate::vcs::unsupported(
+ self.name(),
+ "reset_worktree_with_seeded_paths",
+ ))
+ }
}
/// 6-hex sha256 of a string (Go `ShortHash`), used for pool dir naming.
diff --git a/crates/treehouse-core/src/git/shell.rs b/crates/treehouse-core/src/git/shell.rs
index e132f21..3c2fef6 100644
--- a/crates/treehouse-core/src/git/shell.rs
+++ b/crates/treehouse-core/src/git/shell.rs
@@ -208,6 +208,56 @@ impl ShellGitBackend {
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
+
+ /// Runs git and returns RAW stdout bytes (never lossy-converted).
+ ///
+ /// Needed wherever git's output is a protocol rather than text: the
+ /// NUL-separated records of `ls-files -z`, and the blob of a committed
+ /// manifest. `String::from_utf8_lossy` would replace a non-UTF-8 byte with
+ /// U+FFFD, and a path that survives that substitution is a path the copy
+ /// then cannot open — or worse, one that names a DIFFERENT file than the
+ /// one git selected.
+ fn run_bytes(&self, cwd: Option<&Path>, args: &[&str]) -> Result, GitError> {
+ self.run_bytes_os(cwd, args.iter().map(|s| OsString::from(*s)).collect())
+ }
+
+ /// [`Self::run_bytes`] for an argument list carrying a caller-supplied path
+ /// (the temporary `--exclude-from` file is written from a manifest, so its
+ /// path is not a compile-time literal).
+ fn run_bytes_os(&self, cwd: Option<&Path>, args: Vec) -> Result, GitError> {
+ let command = args
+ .iter()
+ .map(|a| a.to_string_lossy().into_owned())
+ .collect::>()
+ .join(" ");
+ let output = self.run_os(cwd, args);
+ if !output.status.success() {
+ let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
+ return Err(GitError::new(
+ format!("git {command}"),
+ message,
+ GitErrorKind::Other,
+ ));
+ }
+ Ok(output.stdout)
+ }
+}
+
+/// Splits git's NUL-separated record output into owned paths.
+///
+/// A trailing NUL is the record terminator, not an empty final record; a
+/// missing one (empty output) yields no records at all rather than one empty
+/// path that would later be validated and rejected.
+fn split_nul(bytes: &[u8]) -> Vec {
+ if bytes.is_empty() {
+ return Vec::new();
+ }
+ let body = bytes.strip_suffix(&[0]).unwrap_or(bytes);
+ String::from_utf8_lossy(body)
+ .split('\0')
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string())
+ .collect()
}
/// Shared exit-code check: success is `Ok`, anything else is the stderr text
@@ -314,6 +364,214 @@ fn run_one(git_bin: &Path, cwd: Option<&Path>, args: &[&str]) -> Result Result>, GitError> {
+ let listed = self.run_bytes(
+ Some(worktree),
+ &[
+ "ls-tree",
+ "-z",
+ "--name-only",
+ "--full-tree",
+ "HEAD",
+ "--",
+ WORKTREE_INCLUDE,
+ ],
+ )?;
+ if !split_nul(&listed).iter().any(|n| n == WORKTREE_INCLUDE) {
+ return Ok(None);
+ }
+ // A tree entry of the same name (a directory, say) must not be read as a
+ // manifest: `cat-file blob` would fail with a confusing message, and
+ // treating the failure as "no manifest" would hide a real misconfiguration.
+ let spec = format!("HEAD:{WORKTREE_INCLUDE}");
+ let kind = self.run_stdout(
+ Some(worktree),
+ &["cat-file", "-t", &spec],
+ GitErrorKind::Other,
+ )?;
+ if kind.trim() != "blob" {
+ return Err(GitError::new(
+ format!("git cat-file -t {spec}"),
+ format!("committed {WORKTREE_INCLUDE} is not a file"),
+ GitErrorKind::Other,
+ ));
+ }
+ Ok(Some(
+ self.run_bytes(Some(worktree), &["cat-file", "blob", &spec])?,
+ ))
+ }
+
+ /// The ignored paths a manifest selects in `repo` (Go `selectedSeedPathsWithManifestEnv`).
+ ///
+ /// Git itself evaluates the pattern language, by handing the manifest to
+ /// `ls-files` as an exclude file. Reimplementing gitignore matching in Rust
+ /// would silently diverge on negation, escaping, and `**`, and the divergence
+ /// would show up as "the wrong files got copied" — so the patterns are given
+ /// to the tool that owns them.
+ ///
+ /// The second `ls-files` pass is the security-relevant one: it intersects
+ /// the manifest's selections with the paths the repository ACTUALLY ignores.
+ /// A manifest that names a tracked file selects nothing, so
+ /// `.worktreeinclude` can never be used to smuggle a tracked file's content
+ /// into a worktree.
+ fn selected_seed_paths(
+ &self,
+ repo_root: &Path,
+ manifest: &[u8],
+ ) -> Result, GitError> {
+ // The temp file must live and be written before `ls-files` reads it, and
+ // must not outlive the call: it holds repository-selection content in a
+ // predictable path, so it is removed on every path out.
+ let exclude = tempfile::NamedTempFile::new().map_err(|e| {
+ GitError::new(
+ "git ls-files --exclude-from",
+ format!("creating temporary exclude file: {e}"),
+ GitErrorKind::Other,
+ )
+ })?;
+ std::fs::write(exclude.path(), manifest).map_err(|e| {
+ GitError::new(
+ "git ls-files --exclude-from",
+ format!("writing temporary exclude file: {e}"),
+ GitErrorKind::Other,
+ )
+ })?;
+
+ let exclude_from = format!("--exclude-from={}", exclude.path().display());
+ let selected = self.run_bytes(
+ Some(repo_root),
+ &["ls-files", "-z", "--others", "--ignored", &exclude_from],
+ )?;
+ if selected.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let ignored = self.run_bytes(
+ Some(repo_root),
+ &[
+ "ls-files",
+ "-z",
+ "--others",
+ "--ignored",
+ "--exclude-standard",
+ ],
+ )?;
+ let ignored: std::collections::HashSet = split_nul(&ignored).into_iter().collect();
+
+ Ok(split_nul(&selected)
+ .into_iter()
+ .filter(|name| ignored.contains(name))
+ .collect())
+ }
+}
+
+/// Whether the destination worktree tracks `name` or anything at or beneath it.
+///
+/// Checked in the DESTINATION, not the source: the worktree may be cut from a
+/// different commit than the source repo's current checkout, so the source's
+/// index says nothing about what this worktree already has. A seed must never
+/// overwrite tracked content — that is a working-tree edit dressed up as a copy.
+fn destination_tracks(tracked: &[String], name: &str) -> bool {
+ if tracked.iter().any(|t| t == name) {
+ return true;
+ }
+ // A tracked DESCENDANT counts: the manifest selected a directory-shaped
+ // pattern, and something inside it is tracked at the destination.
+ let prefix = format!("{name}/");
+ if tracked.iter().any(|t| t.starts_with(&prefix)) {
+ return true;
+ }
+ // So does a tracked ANCESTOR: `config` is not itself listed, but
+ // `config/settings.env` is, and seeding a file over it would clobber it.
+ let mut ancestor = name;
+ while let Some((head, _)) = ancestor.rsplit_once('/') {
+ if tracked.iter().any(|t| t == head) {
+ return true;
+ }
+ ancestor = head;
+ }
+ false
+}
+
+/// Creates `worktree/rel`'s parent directories, refusing to replace a file.
+///
+/// A seed whose ancestor exists as a regular file is refused rather than
+/// removed: the alternative is deleting something the user (or git) put there
+/// to make room for a copy.
+fn ensure_seed_parent_dir(worktree: &Path, rel: &str) -> Result<(), GitError> {
+ let Some((parent, _)) = rel.rsplit_once('/') else {
+ return Ok(()); // Top-level seed: the worktree root is its parent.
+ };
+ let mut current = worktree.to_path_buf();
+ for part in parent.split('/') {
+ current.push(part);
+ match std::fs::symlink_metadata(¤t) {
+ Ok(meta) if meta.is_dir() => continue,
+ Ok(_) => {
+ return Err(GitError::new(
+ "seed worktree",
+ format!(
+ "refusing to replace existing seed ancestor {}",
+ current.display()
+ ),
+ GitErrorKind::Other,
+ ));
+ }
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+ std::fs::create_dir(¤t).map_err(|e| {
+ GitError::new(
+ "seed worktree",
+ format!("creating {}: {e}", current.display()),
+ GitErrorKind::Other,
+ )
+ })?;
+ }
+ Err(e) => {
+ return Err(GitError::new(
+ "seed worktree",
+ format!("reading {}: {e}", current.display()),
+ GitErrorKind::Other,
+ ));
+ }
+ }
+ }
+ Ok(())
+}
+
+/// Refuses a worktree path that is not a plain directory.
+///
+/// Seeded content is written BELOW this path, so a symlinked destination would
+/// redirect every write outside the pool — into the user's real repository, or
+/// anywhere else the link points.
+fn require_plain_worktree_dir(worktree: &Path) -> Result<(), GitError> {
+ let meta = std::fs::symlink_metadata(worktree).map_err(|e| {
+ GitError::new(
+ "seed worktree",
+ format!("reading worktree {}: {e}", worktree.display()),
+ GitErrorKind::Other,
+ )
+ })?;
+ if meta.file_type().is_symlink() || !meta.is_dir() {
+ return Err(GitError::new(
+ "seed worktree",
+ format!("refusing to seed symlinked worktree {}", worktree.display()),
+ GitErrorKind::Other,
+ ));
+ }
+ Ok(())
+}
+
impl GitBackend for ShellGitBackend {
fn repo_root(&self, start: &Path) -> Result {
repo_root_from(start, &self.git_bin)
@@ -663,6 +921,239 @@ impl GitBackend for ShellGitBackend {
_ => remote,
}
}
+
+ fn common_git_dir(&self, start: &Path) -> Result {
+ // --path-format=absolute lands on Git ≥2.31. Older git rejects the
+ // flag outright, so the fallback is a second invocation rather than a
+ // parse of its stderr: a relative answer is then resolved against
+ // `start`, which is where git resolved it from.
+ let out = match self.run_stdout(
+ Some(start),
+ &["rev-parse", "--path-format=absolute", "--git-common-dir"],
+ GitErrorKind::Other,
+ ) {
+ Ok(out) => out,
+ Err(_) => self.run_stdout(
+ Some(start),
+ &["rev-parse", "--git-common-dir"],
+ GitErrorKind::Other,
+ )?,
+ };
+ let path = PathBuf::from(out.trim());
+ if path.is_absolute() {
+ Ok(path)
+ } else {
+ Ok(start.join(path))
+ }
+ }
+
+ fn seed_worktree(
+ &self,
+ repo: &GitRepo,
+ worktree: &Path,
+ manifest: Option<&[u8]>,
+ ) -> Result, GitError> {
+ // The destination is validated BEFORE anything is read or written. A
+ // symlinked worktree would redirect every seeded write outside the pool,
+ // and running git inside it first would resolve that link and report
+ // whatever repository it points at — an error message that names the
+ // wrong problem for the operator who has to fix it.
+ require_plain_worktree_dir(worktree)?;
+
+ // `None` selects the committed manifest; `Some` REPLACES it. An empty
+ // `Some` is therefore "seed nothing" and must not fall back — a caller
+ // that computed an empty selection means it, and reading the committed
+ // manifest instead would copy files the caller excluded.
+ let manifest: Vec = match manifest {
+ Some(bytes) => bytes.to_vec(),
+ None => self
+ .committed_worktree_include(worktree)?
+ .unwrap_or_default(),
+ };
+ if manifest.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let selected = self.selected_seed_paths(&repo.common_dir, &manifest)?;
+ if selected.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ // What the DESTINATION already tracks, which seeding must not touch.
+ let tracked = split_nul(&self.run_bytes(Some(worktree), &["ls-files", "-z"])?);
+ let selected: Vec = selected
+ .into_iter()
+ .filter(|name| !destination_tracks(&tracked, name))
+ .collect();
+ if selected.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let mut copied: Vec = Vec::new();
+ for name in selected {
+ let source = repo.common_dir.join(&name);
+ let meta = std::fs::symlink_metadata(&source).map_err(|e| {
+ GitError::new(
+ "seed worktree",
+ format!("reading {}: {e}", source.display()),
+ GitErrorKind::Other,
+ )
+ })?;
+
+ // A symlink is FLATTENED into a regular file holding its target,
+ // rather than recreated as a link. Copying the link would put a
+ // dangling or escaping reference into the worktree; copying the
+ // target text keeps the seed data-only, with nothing to follow.
+ let (data, mode) = if meta.file_type().is_symlink() {
+ let target = std::fs::read_link(&source).map_err(|e| {
+ GitError::new(
+ "seed worktree",
+ format!("reading link {}: {e}", source.display()),
+ GitErrorKind::Other,
+ )
+ })?;
+ (target.to_string_lossy().into_owned().into_bytes(), 0o666)
+ } else if meta.is_file() {
+ (
+ std::fs::read(&source).map_err(|e| {
+ GitError::new(
+ "seed worktree",
+ format!("reading {}: {e}", source.display()),
+ GitErrorKind::Other,
+ )
+ })?,
+ mode_of(&meta),
+ )
+ } else {
+ // Directories and special files are not seeds. `ls-files`
+ // lists files, so this is unreachable in practice; skipping is
+ // the safe reading of anything unexpected.
+ continue;
+ };
+
+ ensure_seed_parent_dir(worktree, &name)?;
+
+ let dest = worktree.join(&name);
+ // create_new == O_EXCL: never clobber a file that appeared between
+ // the selection and this write.
+ let mut file = std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(&dest)
+ .map_err(|e| {
+ GitError::new(
+ "seed worktree",
+ format!("creating {}: {e}", dest.display()),
+ GitErrorKind::Other,
+ )
+ })?;
+ // Recorded BEFORE the write completes: a path that exists belongs
+ // in the cleanup inventory even if writing it then fails, or the
+ // next reset would leave an unrecorded file behind forever.
+ copied.push(name.clone());
+ std::io::Write::write_all(&mut file, &data).map_err(|e| {
+ GitError::new(
+ "seed worktree",
+ format!("writing {}: {e}", dest.display()),
+ GitErrorKind::Other,
+ )
+ })?;
+ drop(file);
+ set_mode(&dest, mode).map_err(|e| {
+ GitError::new(
+ "seed worktree",
+ format!("setting mode on {}: {e}", dest.display()),
+ GitErrorKind::Other,
+ )
+ })?;
+ }
+ Ok(copied)
+ }
+
+ fn reset_worktree_with_seeded_paths(
+ &self,
+ worktree: &Path,
+ branch: &str,
+ seeded: &[String],
+ ) -> Result<(), GitError> {
+ // The inventory is validated BEFORE anything else happens, including
+ // before the reset itself. An empty or malformed inventory must
+ // authorize zero deletions — it means the caller lost its bookkeeping,
+ // and treating that as "delete nothing silently" would leave the
+ // reset looking successful while the pool's own seeds accumulate.
+ crate::vcs::validate_seed_inventory(seeded)?;
+ require_worktree_marker(worktree)?;
+ require_plain_worktree_dir(worktree)?;
+
+ // Seeds are removed FIRST, while the worktree is still known to be the
+ // one that was checked, and BEFORE the tracked tree is rewritten: a
+ // tracked path can only appear at a seeded path after `read-tree -u`
+ // restores it, and that ordering is what makes the inventory the
+ // authority on what may be deleted.
+ for name in seeded {
+ let target = worktree.join(name);
+ match std::fs::symlink_metadata(&target) {
+ Ok(meta) if meta.is_dir() => {
+ std::fs::remove_dir_all(&target).map_err(|e| {
+ GitError::new(
+ "reset worktree",
+ format!("removing seeded directory {}: {e}", target.display()),
+ GitErrorKind::Other,
+ )
+ })?;
+ }
+ Ok(_) => {
+ std::fs::remove_file(&target).map_err(|e| {
+ GitError::new(
+ "reset worktree",
+ format!("removing seeded file {}: {e}", target.display()),
+ GitErrorKind::Other,
+ )
+ })?;
+ }
+ // Already gone: nothing to remove, and NOT a failure. The
+ // inventory is a permission to delete, not an obligation.
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
+ Err(e) => {
+ return Err(GitError::new(
+ "reset worktree",
+ format!("reading seeded path {}: {e}", target.display()),
+ GitErrorKind::Other,
+ ));
+ }
+ }
+ }
+
+ self.reset_worktree(worktree, branch)
+ }
+}
+
+/// The permission bits of a metadata value (Go `info.Mode().Perm()`).
+#[cfg(unix)]
+fn mode_of(meta: &std::fs::Metadata) -> u32 {
+ use std::os::unix::fs::PermissionsExt;
+ meta.permissions().mode() & 0o777
+}
+
+#[cfg(not(unix))]
+fn mode_of(_meta: &std::fs::Metadata) -> u32 {
+ 0o644
+}
+
+/// Applies permission bits, a no-op where the platform has none.
+///
+/// Best effort by design: a seed that copied correctly but could not be
+/// chmod'ed is still a usable seed, and failing the whole acquisition over it
+/// would leave the worktree half-seeded.
+#[cfg(unix)]
+fn set_mode(path: &Path, mode: u32) -> std::io::Result<()> {
+ use std::os::unix::fs::PermissionsExt;
+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
+}
+
+#[cfg(not(unix))]
+fn set_mode(_path: &Path, _mode: u32) -> std::io::Result<()> {
+ Ok(())
}
impl ShellGitBackend {
@@ -993,4 +1484,518 @@ mod tests {
err.message
);
}
+
+ // ── .worktreeinclude seeding ─────────────────────────────────────────────
+
+ /// A repo with a committed `.gitignore` (`*`, so everything is ignored) and
+ /// a committed `.worktreeinclude` manifest, plus a detached worktree.
+ /// Returns `(TempDir, repo, worktree)`; the manifest is `""` for none.
+ fn seed_repo(manifest: &str) -> (tempfile::TempDir, PathBuf, PathBuf) {
+ let dir = tempfile::tempdir().unwrap();
+ let repo = dir.path().join("repo");
+ let worktree = dir.path().join("worktree");
+ must_git(
+ None,
+ &["init", "--initial-branch=main", repo.to_str().unwrap()],
+ );
+ must_git(Some(&repo), &["config", "user.email", "test@test.com"]);
+ must_git(Some(&repo), &["config", "user.name", "Test"]);
+ // Keep tracked-file byte assertions independent of the host's checkout
+ // conversion: GitHub's Windows runners set core.autocrlf=true, which
+ // would write CRLF into the worktree and break every "committed
+ // content" comparison. Git's default elsewhere is no conversion, so
+ // this makes the fixture agree with what CI already does.
+ must_git(Some(&repo), &["config", "core.autocrlf", "false"]);
+ std::fs::write(repo.join(".gitignore"), b"*\n").unwrap();
+ std::fs::write(repo.join(".worktreeinclude"), manifest.as_bytes()).unwrap();
+ // -f: the manifest is itself ignored by `.gitignore` containing `*`,
+ // and it still has to be committed — it is the one file seeding reads
+ // from the OBJECT store, never the working tree.
+ must_git(
+ Some(&repo),
+ &["add", "-f", ".gitignore", ".worktreeinclude"],
+ );
+ must_git(Some(&repo), &["commit", "-m", "seed manifest"]);
+ must_git(
+ Some(&repo),
+ &["worktree", "add", "--detach", worktree.to_str().unwrap()],
+ );
+ (dir, repo, worktree)
+ }
+
+ /// Writes `contents` to `root/name`, creating parent directories.
+ fn write_under(root: &Path, name: &str, contents: &str) {
+ let path = root.join(name);
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(path, contents).unwrap();
+ }
+
+ /// The end-to-end shape: seed a worktree, then reset it with the recorded
+ /// inventory, and prove both halves of the contract.
+ ///
+ /// The last assertion is the one that matters. `clean -fd` does not remove
+ /// IGNORED files, so a reset would leave the seed behind whether or not the
+ /// inventory did anything — and a naive implementation that simply ran the
+ /// reset would pass the first check while never actually cleaning anything.
+ /// The user-placed file is the mirror image: it must survive because it is
+ /// not in the inventory, and it would also survive a correct implementation.
+ /// Together they pin that the deletion is driven by the inventory and by
+ /// nothing else.
+ #[test]
+ fn integration_seed_then_reset_removes_only_the_recorded_seeds() {
+ let (_dir, repo, worktree) = seed_repo("*.env\n");
+ let backend = ShellGitBackend::discover().unwrap();
+ let repo_ref = GitRepo {
+ common_dir: repo.clone(),
+ worktree: None,
+ };
+
+ // An ignored file the manifest selects, and one it does not.
+ write_under(&repo, ".env", "SECRET=1\n");
+ write_under(&repo, "notes.txt", "not selected\n");
+
+ let seeded = backend
+ .seed_worktree(&repo_ref, &worktree, None)
+ .expect("seeding a committed manifest must succeed");
+
+ assert_eq!(
+ seeded,
+ vec![".env".to_string()],
+ "the inventory must record exactly what was copied"
+ );
+ assert_eq!(
+ std::fs::read_to_string(worktree.join(".env")).unwrap(),
+ "SECRET=1\n",
+ "the selected ignored file must be seeded"
+ );
+ assert!(
+ !worktree.join("notes.txt").exists(),
+ "a file the manifest does not select must not be seeded"
+ );
+
+ // A file the USER put in the worktree after acquisition. Ignored, and
+ // matched by the same `*.env` pattern, but never in the inventory.
+ write_under(&worktree, "user.env", "MINE=1\n");
+
+ backend
+ .reset_worktree_with_seeded_paths(&worktree, "main", &seeded)
+ .expect("reset with a trusted inventory must succeed");
+
+ assert!(
+ !worktree.join(".env").exists(),
+ "the recorded seed must be removed by the reset"
+ );
+ assert_eq!(
+ std::fs::read_to_string(worktree.join("user.env")).unwrap(),
+ "MINE=1\n",
+ "an ignored file the user placed must SURVIVE: the inventory is a \
+ permission to delete specific paths, never a licence to sweep \
+ ignored files"
+ );
+ }
+
+ /// An inventory that is absent, or one naming a path outside the worktree,
+ /// must authorize ZERO deletions. This is the fail-closed property the
+ /// whole design turns on: a lost or tampered state file must never become
+ /// "delete the user's ignored files".
+ #[test]
+ fn integration_reset_refuses_an_untrustworthy_inventory() {
+ let (_dir, repo, worktree) = seed_repo("*.env\n");
+ let backend = ShellGitBackend::discover().unwrap();
+ let repo_ref = GitRepo {
+ common_dir: repo.clone(),
+ worktree: None,
+ };
+ write_under(&repo, ".env", "SECRET=1\n");
+ let seeded = backend.seed_worktree(&repo_ref, &worktree, None).unwrap();
+ assert_eq!(seeded, vec![".env".to_string()]);
+ // A second ignored file that is NOT in the inventory.
+ write_under(&worktree, "user.env", "MINE=1\n");
+
+ for bad in [
+ vec![], // lost bookkeeping
+ vec!["".to_string()], // empty component
+ vec!["../escape.env".to_string()], // climbs out of the worktree
+ vec!["/etc/passwd".to_string()], // absolute
+ vec![".git/config".to_string()], // the repository's own metadata
+ vec!["nested/../../out".to_string()], // traversal mid-path
+ ] {
+ backend
+ .reset_worktree_with_seeded_paths(&worktree, "main", &bad)
+ .expect_err("an untrustworthy inventory must be refused");
+ assert!(
+ worktree.join("user.env").exists() && worktree.join(".env").exists(),
+ "a refused reset must delete nothing, got inventory {bad:?}"
+ );
+ }
+ }
+
+ /// Git owns the manifest's pattern language. These are the cases a
+ /// hand-rolled matcher gets wrong, so they are pinned against git itself.
+ #[test]
+ fn integration_seed_honours_git_exclude_semantics() {
+ for (manifest, want, unwanted) in [
+ // Negation: a later pattern re-includes a file.
+ (
+ "*.env\n!important.env\n",
+ vec!["app.env"],
+ vec!["important.env"],
+ ),
+ // Anchored: `/` binds to the repo root.
+ ("/root.env\n", vec!["root.env"], vec!["nested/root.env"]),
+ // `**` spanning directories.
+ (
+ "build/**/cache/*.json\n",
+ vec!["build/cache/a.json", "build/one/two/cache/b.json"],
+ vec!["build/one/data.json"],
+ ),
+ // Comments and spaces are part of the pattern language.
+ (
+ "# a comment\nname with space\n",
+ vec!["name with space"],
+ vec!["a comment"],
+ ),
+ ] {
+ let (_dir, repo, worktree) = seed_repo(manifest);
+ let backend = ShellGitBackend::discover().unwrap();
+ let repo_ref = GitRepo {
+ common_dir: repo.clone(),
+ worktree: None,
+ };
+ for name in want.iter().chain(unwanted.iter()) {
+ write_under(&repo, name, "seeded\n");
+ }
+
+ let seeded = backend
+ .seed_worktree(&repo_ref, &worktree, None)
+ .unwrap_or_else(|e| panic!("seeding {manifest:?} failed: {e}"));
+
+ for name in &want {
+ assert!(
+ worktree.join(name).exists(),
+ "{manifest:?} must select {name}"
+ );
+ }
+ for name in &unwanted {
+ assert!(
+ !worktree.join(name).exists(),
+ "{manifest:?} must NOT select {name}"
+ );
+ }
+ // The inventory is the record of what was written, so it must match
+ // what actually landed on disk.
+ for name in &want {
+ assert!(
+ seeded.iter().any(|p| p == name),
+ "{name} must appear in the returned inventory for {manifest:?}"
+ );
+ }
+ }
+ }
+
+ /// A TRACKED file named by the manifest must not be seeded. Source ignore
+ /// rules say nothing about the destination's index, and the destination may
+ /// be cut from a different commit — so a manifest that names a tracked path
+ /// selects nothing rather than overwriting it.
+ #[test]
+ fn integration_seed_never_overwrites_tracked_content() {
+ let (_dir, repo, worktree) = seed_repo("*.env\n");
+ let backend = ShellGitBackend::discover().unwrap();
+ let repo_ref = GitRepo {
+ common_dir: repo.clone(),
+ worktree: None,
+ };
+
+ // Tracked files the manifest would otherwise select: one named exactly,
+ // one beneath a selected path. `-f` because `.gitignore` is `*`.
+ write_under(&repo, "config/settings.env", "COMMITTED\n");
+ write_under(&repo, "loose.env", "COMMITTED\n");
+ must_git(
+ Some(&repo),
+ &["add", "-f", "config/settings.env", "loose.env"],
+ );
+ must_git(Some(&repo), &["commit", "-m", "tracked"]);
+
+ // Change the SOURCE copy of the tracked file without committing it. If
+ // seeding copies from the working tree, this newer content lands in the
+ // worktree and the assertion below catches it.
+ write_under(&repo, "loose.env", "SOURCE-DIRTIED\n");
+
+ // Re-cut the worktree at the new HEAD, so the destination index is what
+ // decides what seeding may not touch. A DIFFERENT commit is the point:
+ // source ignore rules say nothing about the destination's index.
+ must_git(
+ Some(&repo),
+ &["worktree", "remove", "--force", worktree.to_str().unwrap()],
+ );
+ must_git(
+ Some(&repo),
+ &["worktree", "add", "--detach", worktree.to_str().unwrap()],
+ );
+
+ // A genuinely ignored file, so the test distinguishes "seeded nothing
+ // at all" from "skipped exactly the tracked ones".
+ write_under(&repo, "real.env", "IGNORED\n");
+
+ let seeded = backend
+ .seed_worktree(&repo_ref, &worktree, None)
+ .expect("seeding must succeed");
+
+ // The tracked files DO exist in the worktree — git checked them out. What
+ // must not have happened is a copy over the top of them, so the
+ // assertion is on content: seeding writes the SOURCE's bytes, and
+ // these two differ between source and committed content below.
+ assert_eq!(
+ std::fs::read_to_string(worktree.join("loose.env")).unwrap(),
+ "COMMITTED\n",
+ "a tracked file the manifest names must keep its committed content"
+ );
+ assert_eq!(
+ std::fs::read_to_string(worktree.join("config/settings.env")).unwrap(),
+ "COMMITTED\n",
+ "a tracked file beneath a selected path must keep its committed content"
+ );
+ assert!(
+ worktree.join("real.env").exists(),
+ "a genuinely ignored file must still be seeded"
+ );
+ assert!(
+ !seeded
+ .iter()
+ .any(|p| p == "loose.env" || p == "config/settings.env"),
+ "tracked paths must not reach the inventory, got {seeded:?}"
+ );
+ }
+
+ /// No committed manifest, an empty one, and an explicit empty override must
+ /// all seed nothing — and an explicit override must not fall back to the
+ /// committed manifest, or a caller that computed "select nothing" would get
+ /// the repository's selections instead.
+ #[test]
+ fn integration_seed_is_a_no_op_without_a_manifest() {
+ let (_dir, repo, worktree) = seed_repo("*.env\n");
+ let backend = ShellGitBackend::discover().unwrap();
+ let repo_ref = GitRepo {
+ common_dir: repo.clone(),
+ worktree: None,
+ };
+ write_under(&repo, ".env", "SECRET=1\n");
+
+ // An explicit EMPTY override selects nothing even though the committed
+ // manifest would select `.env`.
+ assert!(
+ backend
+ .seed_worktree(&repo_ref, &worktree, Some(b""))
+ .unwrap()
+ .is_empty(),
+ "an explicitly empty manifest must select nothing, not fall back"
+ );
+ assert!(
+ !worktree.join(".env").exists(),
+ "an empty override must not fall back to the committed manifest"
+ );
+
+ // A manifest that is present but empty commits the same no-op.
+ assert!(
+ backend
+ .seed_worktree(&repo_ref, &worktree, Some(b"*.env\n"))
+ .unwrap()
+ .contains(&".env".to_string())
+ );
+
+ // A repo with no `.worktreeinclude` at all seeds nothing and does not
+ // error — most repos do not seed.
+ let (_d2, repo2, worktree2) = seed_repo("");
+ must_git(Some(&repo2), &["rm", "--cached", "-f", ".worktreeinclude"]);
+ must_git(Some(&repo2), &["commit", "-m", "drop manifest"]);
+ write_under(&repo2, ".env", "SECRET=1\n");
+ let repo_ref2 = GitRepo {
+ common_dir: repo2.clone(),
+ worktree: None,
+ };
+ let backend2 = ShellGitBackend::discover().unwrap();
+ // The destination worktree was cut before the manifest was dropped, so
+ // rebuild it to read the current HEAD.
+ must_git(
+ Some(&repo2),
+ &["worktree", "remove", "--force", worktree2.to_str().unwrap()],
+ );
+ must_git(
+ Some(&repo2),
+ &["worktree", "add", "--detach", worktree2.to_str().unwrap()],
+ );
+ assert!(
+ backend2
+ .seed_worktree(&repo_ref2, &worktree2, None)
+ .unwrap()
+ .is_empty(),
+ "a repo with no committed manifest must seed nothing"
+ );
+ }
+
+ /// The manifest is read from the committed blob only. An uncommitted edit to
+ /// `.worktreeinclude` must not widen what gets copied into a worktree —
+ /// that would let a dirty or mid-merge repo select files the commit never
+ /// authorized.
+ #[test]
+ fn integration_seed_ignores_an_uncommitted_manifest() {
+ let (_dir, repo, worktree) = seed_repo("");
+ let backend = ShellGitBackend::discover().unwrap();
+ let repo_ref = GitRepo {
+ common_dir: repo.clone(),
+ worktree: None,
+ };
+ write_under(&repo, ".env", "SECRET=1\n");
+ // Uncommitted: selects `.env`, but was never committed.
+ std::fs::write(repo.join(".worktreeinclude"), b"*.env\n").unwrap();
+
+ assert!(
+ backend
+ .seed_worktree(&repo_ref, &worktree, None)
+ .unwrap()
+ .is_empty(),
+ "an uncommitted .worktreeinclude must not authorize any seed"
+ );
+ assert!(
+ !worktree.join(".env").exists(),
+ "the uncommitted manifest must not have been honored"
+ );
+ }
+
+ /// Seeding writes below `worktree`, so a SYMLINKED destination would
+ /// redirect every copy outside the pool — into the user's real repository,
+ /// or anywhere else the link points.
+ #[test]
+ #[cfg(unix)]
+ fn integration_seed_refuses_a_symlinked_worktree() {
+ let (_dir, repo, worktree) = seed_repo("*.env\n");
+ let backend = ShellGitBackend::discover().unwrap();
+ let repo_ref = GitRepo {
+ common_dir: repo.clone(),
+ worktree: None,
+ };
+ write_under(&repo, ".env", "SECRET=1\n");
+
+ // Replace the worktree with a symlink to a directory OUTSIDE the pool.
+ // `worktree remove` already deletes the directory, so nothing is left
+ // to remove — only the path to re-point at the link.
+ let elsewhere = tempfile::tempdir().unwrap();
+ must_git(
+ Some(&repo),
+ &["worktree", "remove", "--force", worktree.to_str().unwrap()],
+ );
+ std::os::unix::fs::symlink(elsewhere.path(), &worktree).unwrap();
+
+ let err = backend
+ .seed_worktree(&repo_ref, &worktree, None)
+ .expect_err("a symlinked worktree must be refused");
+ assert!(
+ err.message.contains("symlinked worktree"),
+ "the refusal must say why, got: {}",
+ err.message
+ );
+ assert!(
+ !elsewhere.path().join(".env").exists(),
+ "no seed may be written through a symlinked worktree"
+ );
+ }
+
+ /// `--git-common-dir` is where the repo-local, untracked `.git/info/exclude`
+ /// lives, and an in-project pool is recorded there.
+ #[test]
+ fn integration_common_git_dir_resolves_the_info_directory() {
+ let (_dir, repo) = temp_repo();
+ let backend = ShellGitBackend::discover().unwrap();
+ let common = backend.common_git_dir(&repo).unwrap();
+ assert!(
+ common.join("info").is_absolute(),
+ "the git dir must be absolute, got {}",
+ common.display()
+ );
+ // For a linked worktree it is the MAIN repo's git dir, not the
+ // worktree's private one — that is what makes one exclude file cover
+ // every worktree in the pool.
+ let wt = repo.join("wt");
+ must_git(
+ Some(&repo),
+ &["worktree", "add", "--detach", wt.to_str().unwrap(), "main"],
+ );
+ let from_worktree = backend.common_git_dir(&wt).unwrap();
+ assert_eq!(
+ from_worktree, common,
+ "a linked worktree must resolve to the owning repository's git dir"
+ );
+ }
+
+ /// The inventory [`GitBackend::seed_worktree`] returns must survive a round
+ /// trip through the pool state file, because it is the ONLY record of what
+ /// a later reset is allowed to delete. A build that drops it on write turns
+ /// every subsequent reset into either a refusal (inventory lost) or, worse,
+ /// an unrecorded set of files nothing will ever clean up.
+ ///
+ /// The fields are carried in `WorktreeEntry::extra` today (there are no
+ /// typed `seeded_paths` fields yet), so this also pins that the flatten map
+ /// keeps working for the one field whose absence would be silent.
+ #[test]
+ fn integration_seeded_inventory_round_trips_through_the_state_file() {
+ use crate::state::{State, WorktreeEntry};
+
+ let (_dir, repo, worktree) = seed_repo("*.env\n");
+ let backend = ShellGitBackend::discover().unwrap();
+ let repo_ref = GitRepo {
+ common_dir: repo.clone(),
+ worktree: None,
+ };
+ write_under(&repo, ".env", "SECRET=1\n");
+
+ let seeded = backend.seed_worktree(&repo_ref, &worktree, None).unwrap();
+ assert_eq!(seeded, vec![".env".to_string()]);
+
+ // Record the inventory the way an acquisition would, then write the
+ // whole state file out and read it back.
+ let mut entry = WorktreeEntry {
+ name: "1".to_string(),
+ path: worktree.to_string_lossy().into_owned(),
+ ..Default::default()
+ };
+ entry.extra.insert(
+ "seeded_paths".to_string(),
+ serde_json::to_value(&seeded).unwrap(),
+ );
+ entry
+ .extra
+ .insert("seed_inventory_known".to_string(), true.into());
+ let state = State {
+ worktrees: vec![entry],
+ ..Default::default()
+ };
+ let json = serde_json::to_string(&state).unwrap();
+ assert!(
+ json.contains(r#""seeded_paths":[".env"]"#),
+ "the inventory must reach the state file, got: {json}"
+ );
+
+ let read_back: State = serde_json::from_str(&json).unwrap();
+ let recovered: Vec =
+ serde_json::from_value(read_back.worktrees[0].extra["seeded_paths"].clone())
+ .expect("seeded_paths must read back as a path list");
+ assert_eq!(
+ recovered, seeded,
+ "an inventory that changes across a state write would either \
+ refuse a legitimate reset or delete the wrong path"
+ );
+
+ // And the recovered inventory still authorizes exactly the seeded file:
+ // a user-placed ignored file added after the write is untouched.
+ write_under(&worktree, "user.env", "MINE=1\n");
+ backend
+ .reset_worktree_with_seeded_paths(&worktree, "main", &recovered)
+ .expect("the recovered inventory must still authorize the reset");
+ assert!(!worktree.join(".env").exists(), "the seed must be removed");
+ assert!(
+ worktree.join("user.env").exists(),
+ "a user-placed ignored file must still survive a reset driven by \
+ the RECOVERED inventory"
+ );
+ }
}
diff --git a/crates/treehouse-core/src/hardening.rs b/crates/treehouse-core/src/hardening.rs
index 2814785..d9f2947 100644
--- a/crates/treehouse-core/src/hardening.rs
+++ b/crates/treehouse-core/src/hardening.rs
@@ -104,6 +104,7 @@ mod tests {
holder: "race".to_string(),
ttl: None,
}),
+ ..Default::default()
})
.unwrap();
let lease_id = acquired.lease.as_ref().unwrap().id.clone();
diff --git a/crates/treehouse-core/src/lib.rs b/crates/treehouse-core/src/lib.rs
index 9301802..a09da64 100644
--- a/crates/treehouse-core/src/lib.rs
+++ b/crates/treehouse-core/src/lib.rs
@@ -13,6 +13,7 @@ pub mod destroy;
pub mod discovery;
pub mod doctor;
pub mod env;
+pub mod fileclone;
pub mod gc;
pub mod git;
pub mod hardening;
@@ -28,6 +29,7 @@ pub mod run;
pub mod state;
pub mod state_file;
pub mod updater;
+pub mod vcs;
pub mod worktree;
// ─── TreehouseCore wrapper ─────────────────────────────────────────────────────
diff --git a/crates/treehouse-core/src/pool.rs b/crates/treehouse-core/src/pool.rs
index c01c876..2b97306 100644
--- a/crates/treehouse-core/src/pool.rs
+++ b/crates/treehouse-core/src/pool.rs
@@ -19,14 +19,15 @@ use std::sync::Arc;
use crate::config::{TreehouseConfig, resolve_pool_dir_with_env};
use crate::env::{DefaultEnv, TreehouseEnv};
-use crate::git::{GitBackend, GitRepo};
+use crate::git::{GitBackend, GitError, GitErrorKind, GitRepo};
use crate::hooks;
use crate::lease::{Lease, LeaseInfo, mark_acquired_lease};
use crate::lock::{DEFAULT_LOCK_TIMEOUT, LockError, with_state_lock};
use crate::process::{ProcessInfo, ProcessTable};
use crate::reservation;
-use crate::state::{State, WorktreeEntry, ZERO_TIME, heal_state};
+use crate::state::{SeedInventory, State, WorktreeEntry, ZERO_TIME, heal_state};
use crate::state_file;
+use crate::vcs;
/// Options for opening a pool.
#[derive(Debug, Clone)]
@@ -85,6 +86,20 @@ pub struct AcquireOptions {
pub worktree_path: Option,
/// Lease the worktree (non-interactive) instead of an owner reservation.
pub lease: Option,
+ /// Replace the committed `.worktreeinclude` for THIS acquisition (Go
+ /// `IncludeManifest`, `--include-file`).
+ ///
+ /// `None` uses the manifest committed at the destination worktree's HEAD.
+ /// `Some(bytes)` — including `Some(&[])` — replaces it, so an empty file
+ /// means "seed nothing" and must NOT fall back to the committed one.
+ pub include_manifest: Option>,
+ /// Opt in to copy-on-write sharing of tracked file data for a FRESH slot
+ /// (Go `APFSSharing`, `--apfs-sharing fresh`). Defaults to off.
+ ///
+ /// Only ever applied to a slot this acquisition just created. A recycled
+ /// slot may still have external writers, and neither reuse nor return is
+ /// an invitation to sweep it.
+ pub apfs_sharing: bool,
}
/// Options for a lease acquisition.
@@ -171,7 +186,10 @@ pub(crate) fn resolve_worktree_path(
.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());
+ .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() {
@@ -516,6 +534,31 @@ impl Pool {
Ok(self.git.is_dirty(path)?)
}
+ /// Whether a pool SLOT is dirty, answered by the backend that slot's own
+ /// marker names (Go `vcs.IsDirty`, vcs.go:648-650).
+ ///
+ /// Go's `IsDirty` is exactly `backendForWorktree(path).IsDirty(path)` — it
+ /// dispatches on the SLOT, never on whichever backend the pool happened to be
+ /// opened with. Asking `self.git` instead would run git commands inside a
+ /// `.jj` workspace and report the failure in git's vocabulary, which hides
+ /// the real cause. For a git slot the answer is unchanged, because the
+ /// registry's git and the pool's git are both `ShellGitBackend::discover()`
+ /// over the same `GIT_BIN` → `PATH` lookup.
+ ///
+ /// A markerless path still answers: [`vcs::backend_for_worktree`] falls
+ /// back to the default backend for reads, because reading a status is
+ /// harmless where rewriting a checkout is not. A slot the seam cannot
+ /// resolve at all reads as CLEAN, preserving the `.unwrap_or(false)` this
+ /// call site already applied — and `status` only reaches here after
+ /// [`slot_backend_name`] has refused anything that is not a git slot as
+ /// damaged.
+ fn slot_is_dirty(&self, slot: &Path) -> bool {
+ match vcs::backend_for_worktree(slot) {
+ Ok(backend) => backend.is_dirty(slot).unwrap_or(false),
+ Err(_) => false,
+ }
+ }
+
/// Whether `dir` is a treehouse pool directory (Go `pool.IsPoolDir`).
///
/// The state file is the marker, exactly as upstream: a directory that only
@@ -628,13 +671,13 @@ impl Pool {
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) {
+ 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",
- ),
+ std::io::Error::new(std::io::ErrorKind::AlreadyExists, "branch already exists"),
));
}
}
@@ -660,7 +703,13 @@ impl Pool {
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(
+ let AcquiredSlot {
+ name,
+ path,
+ lease,
+ origin,
+ prior_inventory,
+ } = acquire_locked(
&self.dir,
self.lock_timeout,
self.config.max_trees,
@@ -673,11 +722,37 @@ impl Pool {
&requester,
)?;
- // Step 3 (OUTSIDE lock): reset the worktree to the default branch.
+ // Step 3 (OUTSIDE lock): reset the worktree to the base branch.
// The reservation held since step 2 is the anti-TOCTOU wall.
- self.git
- .reset_worktree(Path::new(&path), &branch)
- .map_err(PoolError::Git)?;
+ //
+ // Dispatched through the VCS seam rather than straight at `self.git`,
+ // so a slot whose marker names another backend is handled by THAT
+ // backend (Go `vcs.ResetWorktree`). For a git slot this resolves to the
+ // git backend; for a slot with no marker it refuses instead of
+ // resetting whatever repository encloses the pool.
+ let slot = Path::new(&path);
+ let backend = vcs::destructive_backend_for_worktree(slot).map_err(PoolError::Git)?;
+ reset_for_acquisition(&backend, slot, &branch, &prior_inventory, origin)?;
+
+ // Step 3a (OUTSIDE lock): copy the ignored files this pool is
+ // responsible for into the slot (Go `seedWorktree`, pool.go:697).
+ //
+ // Runs on BOTH origins, after the reset and before the branch is
+ // created, matching Go. The returned inventory is the only record of
+ // what a later reset may delete, so it is computed here and persisted
+ // in step 4.
+ let seeded = backend
+ .seed_worktree(&repo, slot, opts.include_manifest.as_deref())
+ .map_err(|e| {
+ PoolError::Git(GitError::new(
+ format!("seeding worktree {}", slot.display()),
+ format!(
+ "failed to seed .worktreeinclude into {}: {e}",
+ slot.display()
+ ),
+ GitErrorKind::Other,
+ ))
+ })?;
// Step 3b (OUTSIDE lock): create and check out the requested branch.
//
@@ -686,8 +761,8 @@ impl Pool {
// 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)
+ backend
+ .create_branch(slot, new_branch)
.map_err(PoolError::Git)?;
}
@@ -698,13 +773,40 @@ impl Pool {
heal_state(&mut state, |pid| self.process.started_at(pid));
// Reservation check: if the worktree vanished or was re-acquired
// mid-reset, surface it rather than returning a stale handle.
- if state.worktrees.iter().any(|w| w.name == name) {
+ if let Some(wt) = state.worktrees.iter_mut().find(|w| w.name == name) {
+ // The seed inventory is recorded HERE, under the second lock,
+ // and only once the files are actually on disk. Recording it
+ // earlier would let a crash between the two leave a reset
+ // authorized to delete files that were never copied.
+ wt.set_seed_inventory(SeedInventory {
+ paths: seeded.clone(),
+ known: true,
+ backend: backend.name().to_string(),
+ // Go records a jj seed-authentication identity here. This
+ // port seeds git worktrees only and has no such marker, so
+ // the field stays empty rather than carrying a value that
+ // nothing would ever verify.
+ auth_identity: String::new(),
+ });
+ wt.set_base_branch(&branch);
state_file::write_state(&self.dir, &state)
.map_err(|e| PoolError::Io("writing state".to_string(), e))?;
}
Ok(())
})?;
+ // Step 4b (OUTSIDE lock): opt-in APFS sharing, FRESH slots only
+ // (Go pool.go:782-795).
+ //
+ // Deliberately not on the recycled path: a reused slot may still have
+ // external writers, and neither `return` nor reuse is an invitation to
+ // sweep it. The module's own contract requires exclusive ownership of
+ // the destination for the whole pass, which only a slot this
+ // acquisition just created can promise.
+ if origin == SlotOrigin::Fresh && opts.apfs_sharing {
+ share_slot_files(&self.root, slot, &mut std::io::stderr());
+ }
+
// Step 5 (OUTSIDE lock): run post_create hooks (lease mode routes
// stdout to stderr so machine output stays clean).
if !self.config.hooks.post_create.is_empty() {
@@ -835,20 +937,48 @@ impl Pool {
// LOCK #1: find + validate preconditions + before_reset (under lock,
// per Go doc — caller's termination/detachment can't race). The entry
// is still reserved.
- with_pool_lock(&self.dir, self.lock_timeout, || {
+ //
+ // The seed inventory is captured HERE, under the lock that proves the
+ // entry is still releasable, and handed to the reset outside it. Reading
+ // it after the unlock would race a concurrent acquisition that has
+ // already reserved the slot and re-seeded it: the reset would then
+ // delete files belonging to the NEW occupant.
+ let prior_inventory = 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, &self.process)?;
+ let wt = releasable_worktree(&mut state, worktree_path, preconditions, &self.process)?;
+ let inventory = wt.seed_inventory();
if let Some(cb) = before_reset {
cb()?;
}
- Ok(())
+ Ok(inventory)
})?;
// OUTSIDE the lock: reset the worktree. The reservation is still held,
// so no acquire/destroy can take it mid-reset.
- self.git
- .reset_worktree(Path::new(worktree_path), &branch)
- .map_err(PoolError::Git)?;
+ //
+ // A slot whose seed inventory is KNOWN is reset through
+ // `reset_worktree_with_seeded_paths`, which removes exactly those files
+ // and then resets (Go `ResetWorktreeWithSeededPaths`, pool.go:1060). An
+ // ignored file the pool copied is invisible to `clean -fd`, so without
+ // this the pool's own seeds accumulate in a slot forever.
+ //
+ // A slot with NO known inventory authorizes no deletion at all, and
+ // resets plainly instead: the tracked tree is restored and nothing the
+ // pool cannot account for is removed.
+ let slot = Path::new(worktree_path);
+ match prior_inventory.authorized_paths() {
+ Some(seeded) => {
+ vcs::destructive_backend_for_worktree(slot)
+ .map_err(PoolError::Git)?
+ .reset_worktree_with_seeded_paths(slot, &branch, seeded)
+ .map_err(PoolError::Git)?;
+ }
+ None => {
+ self.git
+ .reset_worktree(slot, &branch)
+ .map_err(PoolError::Git)?;
+ }
+ }
// LOCK #2: RE-VALIDATE the preconditions AND clear in ONE atomic lock.
// This is what makes release exactly-once (ABA-safe): a concurrent
@@ -860,6 +990,12 @@ impl Pool {
wt.owner_pid = 0;
wt.owner_started_at = 0;
crate::state::clear_lease(wt);
+ // A returned slot holds nothing this pool put there: the inventory
+ // becomes a VERIFIED empty one (Go `releaseEntry`, pool.go:1466),
+ // so the next acquisition reads "seeded nothing" and deletes
+ // nothing, rather than acting on a list that no longer describes
+ // the slot.
+ wt.clear_seed_inventory();
state_file::write_state(&self.dir, &state)
.map_err(|e| PoolError::Io("writing state".to_string(), e))?;
Ok(())
@@ -929,7 +1065,7 @@ impl Pool {
// 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) {
+ } else if self.slot_is_dirty(Path::new(&wt.path)) {
ws.status = STATUS_DIRTY.to_string();
}
// A slot the recovery scan adopted but could not read is
@@ -993,7 +1129,7 @@ fn acquire_locked(
branch: &str,
repo: &GitRepo,
requester: &RequesterIdentity,
-) -> Result<(String, String, Option), PoolError> {
+) -> Result {
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));
@@ -1065,10 +1201,17 @@ fn acquire_locked(
};
if let Some((name, path)) = candidate {
// Stamp the reservation (persisted now).
+ let prior = state.worktrees[i].seed_inventory();
let lease_info = mark_acquired_entry(&mut state.worktrees[i], opts, process);
state_file::write_state(dir, &state)
.map_err(|e| PoolError::Io("writing state".to_string(), e))?;
- return Ok((name, path, lease_info));
+ return Ok(AcquiredSlot {
+ name,
+ path,
+ lease: lease_info,
+ origin: SlotOrigin::Recycled,
+ prior_inventory: prior,
+ });
}
}
@@ -1101,11 +1244,42 @@ fn acquire_locked(
state_file::write_state(dir, &state)
.map_err(|e| PoolError::Io("writing state".to_string(), e))?;
let path_str = wt_path.to_string_lossy().into_owned();
- Ok((name, path_str, lease_info))
+ Ok(AcquiredSlot {
+ name,
+ path: path_str,
+ lease: lease_info,
+ origin: SlotOrigin::Fresh,
+ // A slot this call just created has nothing in it that we seeded,
+ // so there is no inventory to honour and none to remove.
+ prior_inventory: SeedInventory::default(),
+ })
})
.map_err(Pool::lock_err_ty)
}
+/// Whether a slot was reused or created, and what its previous occupant left.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum SlotOrigin {
+ /// An available slot this acquisition handed back.
+ Recycled,
+ /// A worktree this acquisition just created.
+ Fresh,
+}
+
+/// A slot the pool has reserved, returned by [`acquire_locked`].
+///
+/// `prior_inventory` is read under the SAME lock that stamped the reservation.
+/// Reading it afterwards would be a race: the entry that recorded it may have
+/// been rewritten by a concurrent acquisition between the unlock and the read,
+/// and a reset that removes the wrong paths is not a recoverable mistake.
+pub(crate) struct AcquiredSlot {
+ pub name: String,
+ pub path: String,
+ pub lease: Option,
+ pub origin: SlotOrigin,
+ pub prior_inventory: SeedInventory,
+}
+
/// The outcome of comparing a candidate slot against the requester's clone.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum IdentityMatch {
@@ -1185,15 +1359,23 @@ impl RequesterIdentity {
/// 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.
+ /// This is the reuse loop's cheap SCREEN, not the authoritative guard, and
+ /// the two are deliberately different calls. The screen asks only "is this
+ /// slot worth taking?" and a wrong answer costs one skipped slot, so it
+ /// stays a single `merge-base` against an unresolved ref. The authoritative
+ /// pair — [`crate::vcs::is_worktree_safe_to_reset`] and
+ /// [`crate::vcs::reset_worktree_to_ref`], reached from
+ /// [`reset_for_acquisition`] as step 3 of [`Pool::get`] — pins the verified
+ /// commit SHA together with the HEAD the ancestry check observed, and makes
+ /// the RESET re-verify both under git's own `HEAD.lock`. Nothing between
+ /// this screen and that reset can discard a commit silently, which is the
+ /// whole reason the pair exists.
+ ///
+ /// Go's SECOND reading — the slot's recorded base branch, consulted when the
+ /// first says unsafe — is still absent, because this port's
+ /// `WorktreeEntry` carries Go's `base_branch` only as an uninterpreted
+ /// `extra` field, so its "HEAD did not move between the two readings"
+ /// guard has nothing to compare.
fn safe_to_reset(&self, git: &dyn GitBackend, slot: &Path, branch: &str) -> bool {
let Some(root) = &self.root else {
return false;
@@ -1209,6 +1391,358 @@ impl RequesterIdentity {
}
}
+/// Restores `slot` to `branch` for a new occupant, closing the two races a
+/// plain `reset_worktree` leaves open.
+///
+/// Go does this with one call, `vcs.ResetWorktreeToRefWithSeededPaths` — guard,
+/// then seed removal, then the guarded reset, all under git's `HEAD.lock`
+/// (gitvcs.go:808). This seam ships the guard (`is_worktree_safe_to_reset` +
+/// `reset_worktree_to_ref`) and the seed-removal validator
+/// ([`vcs::validate_seed_inventory`]) as separate pieces, so this composes them
+/// in Go's order.
+///
+/// **Why the guarded pair and not `reset_worktree`.** Between the reuse loop's
+/// safety check and the reset, a commit can land, or a file can be modified.
+/// A plain reset discards both while every check reports success. The guarded
+/// pair re-reads HEAD and re-checks dirtiness while holding git's own
+/// `HEAD.lock`, so a change in that window is refused instead of destroyed.
+/// When the checks pass, the worktree lands on exactly the commit it would
+/// have before — same commit, same contents, plus two races closed.
+///
+/// `require_clean` is true only for a RECYCLED slot. Go's fresh path never
+/// resets at all, so it never requires a clean tree; requiring it here would
+/// newly fail an acquisition whose checkout filters left an untracked file
+/// behind — a case that succeeds today and has nothing to do with the races.
+fn reset_for_acquisition(
+ backend: &Arc,
+ slot: &Path,
+ branch: &str,
+ prior: &SeedInventory,
+ origin: SlotOrigin,
+) -> Result<(), PoolError> {
+ // Non-git slots keep the pre-seam behavior. The guarded helpers shell out
+ // to git, and a jj workspace has no git HEAD to lock — running them there
+ // would guard the wrong repository rather than a wrong one.
+ //
+ // A git binary we cannot resolve is the same situation: the pool was
+ // opened, so this is unexpected, but a slot that resets unguarded is
+ // exactly what happened before this change and losing the guard is better
+ // than failing every acquisition.
+ let guarded = if backend.name() == vcs::BACKEND_GIT {
+ resolved_git_bin()
+ } else {
+ None
+ };
+
+ let Some(git_bin) = guarded else {
+ return backend.reset_worktree(slot, branch).map_err(PoolError::Git);
+ };
+
+ // The guard is taken BEFORE anything is deleted, so a refusal leaves the
+ // slot exactly as it was. Both SHAs it returns travel into the reset: they
+ // are only meaningful as a pair, and re-deriving either at reset time is
+ // the bug the pair exists to prevent.
+ let guard = vcs::is_worktree_safe_to_reset(&git_bin, backend.as_ref(), slot, branch)
+ .map_err(PoolError::Git)?;
+ if !guard.safe {
+ return Err(PoolError::Git(GitError::new(
+ "worktree reset",
+ format!(
+ "refusing to reset {} to {branch:?}: its HEAD holds commits that \
+ branch does not contain",
+ slot.display()
+ ),
+ GitErrorKind::Other,
+ )));
+ }
+
+ // Only now, with the slot proven reusable, remove what the pool itself put
+ // there. `authorized_paths` returns None for a missing OR empty inventory,
+ // so an entry that lost its bookkeeping deletes nothing — the safety
+ // property this whole seam exists for.
+ if let Some(paths) = prior.authorized_paths() {
+ remove_seeded_paths(slot, paths)?;
+ }
+
+ vcs::reset_worktree_to_ref(
+ &git_bin,
+ backend.as_ref(),
+ slot,
+ &guard.reset_ref,
+ &guard.head,
+ origin == SlotOrigin::Recycled,
+ )
+ .map_err(PoolError::Git)
+}
+
+/// Deletes exactly the paths a prior seeding recorded, and nothing else.
+///
+/// [`vcs::validate_seed_inventory`] runs FIRST and gates everything: it refuses
+/// an empty list outright and rejects any entry that is absolute, contains `..`,
+/// carries a backslash or NUL, or starts with `.git`/`.jj`. Only once the whole
+/// inventory has been cleared is a single path touched.
+///
+/// Removal happens before the tracked tree is rewritten, so the inventory stays
+/// the authority on what may be deleted: a tracked path can only appear at a
+/// seeded location after `read-tree -u` restores it.
+///
+/// A listed path that is already gone is not a failure — the inventory is
+/// permission to delete, not an obligation. A path that is neither a file nor a
+/// directory (a socket, a device) IS a failure: refusing leaves the operator
+/// looking at a real file this tool cannot safely remove, which beats silently
+/// unlinking something it did not create.
+fn remove_seeded_paths(slot: &Path, seeded: &[String]) -> Result<(), PoolError> {
+ vcs::validate_seed_inventory(seeded).map_err(PoolError::Git)?;
+ for name in seeded {
+ let target = slot.join(name);
+ match std::fs::symlink_metadata(&target) {
+ // `symlink_metadata` does not follow, so a symlink is removed as a
+ // link rather than as whatever it points at. Seeding flattens
+ // symlinks into regular files precisely so this never has to guess.
+ Ok(meta) if meta.is_dir() => std::fs::remove_dir_all(&target).map_err(|e| {
+ PoolError::Git(GitError::new(
+ "worktree reset",
+ format!("removing seeded directory {}: {e}", target.display()),
+ GitErrorKind::Other,
+ ))
+ })?,
+ Ok(meta) if meta.is_file() || meta.file_type().is_symlink() => {
+ std::fs::remove_file(&target).map_err(|e| {
+ PoolError::Git(GitError::new(
+ "worktree reset",
+ format!("removing seeded file {}: {e}", target.display()),
+ GitErrorKind::Other,
+ ))
+ })?;
+ }
+ Ok(_) => {
+ return Err(PoolError::Git(GitError::new(
+ "worktree reset",
+ format!(
+ "seeded path {} is neither a file nor a directory; refusing \
+ to remove it",
+ target.display()
+ ),
+ GitErrorKind::Other,
+ )));
+ }
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
+ Err(e) => {
+ return Err(PoolError::Git(GitError::new(
+ "worktree reset",
+ format!("reading seeded path {}: {e}", target.display()),
+ GitErrorKind::Other,
+ )));
+ }
+ }
+ }
+ Ok(())
+}
+
+/// Shares tracked file data between the source checkout and a FRESH slot
+/// (Go `vcs.ShareWorktreeFiles`, gitvcs/sharing.go:19).
+///
+/// **Every failure path degrades to the plain copy the worktree already has.**
+/// The files are written by `git worktree add` before this runs, so a refusal
+/// here leaves a correct worktree that merely does not share storage. Failing
+/// the acquisition instead would make a performance option an availability
+/// hazard, and would be a behavior change for anyone who opts in and then hits
+/// a filesystem this module declines to touch.
+fn share_slot_files(repo_root: &Path, slot: &Path, err: &mut dyn std::io::Write) {
+ use crate::fileclone;
+
+ if !fileclone::SUPPORTED {
+ let _ = writeln!(err, "APFS sharing skipped: only supported on macOS/APFS");
+ return;
+ }
+ let reason = fileclone::filesystem_reason(repo_root, slot);
+ if !reason.is_empty() {
+ let _ = writeln!(err, "APFS sharing skipped: {reason}");
+ return;
+ }
+ let Some(git_bin) = resolved_git_bin() else {
+ let _ = writeln!(
+ err,
+ "APFS sharing skipped: no git binary to enumerate tracked paths"
+ );
+ return;
+ };
+
+ // The module's contract is that the caller EXCLUSIVELY owns the destination
+ // for the whole pass. Git's checkout and reference-transaction hooks run
+ // before this insertion point and may have left asynchronous writers
+ // behind, and a finished hook is not evidence that they are gone. Without
+ // these checks the pass could race a live writer and publish bytes it never
+ // hashed.
+ if let Some(reason) = concurrent_writer_reason(repo_root, &git_bin) {
+ let _ = writeln!(err, "APFS sharing skipped: {reason}");
+ return;
+ }
+
+ let listed = match git_out(&git_bin, slot, &["ls-files", "-z"]) {
+ Ok(v) => v,
+ Err(_) => {
+ let _ = writeln!(
+ err,
+ "APFS sharing skipped: tracked paths cannot be enumerated"
+ );
+ return;
+ }
+ };
+ let paths: Vec = String::from_utf8_lossy(&listed)
+ .split('\0')
+ .filter(|s| !s.is_empty())
+ .map(str::to_string)
+ .collect();
+ if paths.is_empty() {
+ return;
+ }
+
+ match fileclone::share(repo_root, slot, &paths) {
+ Ok(report) => {
+ let _ = writeln!(err, "{report}");
+ }
+ Err(e) => {
+ let _ = writeln!(
+ err,
+ "APFS sharing skipped: {e}; the worktree keeps its plain copy"
+ );
+ }
+ }
+}
+
+/// The git binary this pool was opened with, re-resolved through the same
+/// `GIT_BIN` → `PATH` lookup [`crate::git::ShellGitBackend::discover`] uses.
+fn resolved_git_bin() -> Option {
+ crate::git::ShellGitBackend::discover()
+ .ok()
+ .map(|g| g.git_bin().to_path_buf())
+}
+
+/// Runs `git ` in `cwd` and returns raw stdout, NUL bytes intact.
+///
+/// Bytes, not text: `ls-files -z` separates paths with NUL precisely so that a
+/// filename containing a newline still round-trips, and decoding here would
+/// throw that away.
+///
+/// Failures are labelled by [`crate::vcs::git_label`], the one rule for what
+/// goes in a [`GitError`]'s `command` field — spelling "git " in here as well
+/// is what rendered `git git ls-files -z: …`.
+fn git_out(git_bin: &Path, cwd: &Path, args: &[&str]) -> Result, GitError> {
+ let out = raw_git(git_bin, cwd, args)?;
+ if !out.0 {
+ return Err(GitError::new(
+ crate::vcs::git_label(args),
+ String::from_utf8_lossy(&out.2).trim().to_string(),
+ GitErrorKind::Other,
+ ));
+ }
+ Ok(out.1)
+}
+
+/// Runs `git ` in `cwd`, returning `(success, stdout, stderr)` WITHOUT
+/// treating a nonzero exit as an error.
+///
+/// Needed because `git config --get` exits 1 to mean "not set", which is the
+/// ordinary case — and which a caller must be able to tell apart from a real
+/// failure. Collapsing both into one `Err` makes the common case look like an
+/// unverifiable one.
+fn raw_git(
+ git_bin: &Path,
+ cwd: &Path,
+ args: &[&str],
+) -> Result<(bool, Vec, Vec), GitError> {
+ let out = std::process::Command::new(git_bin)
+ .args(args)
+ .current_dir(cwd)
+ .output()
+ .map_err(|e| {
+ GitError::new(
+ crate::vcs::git_label(args),
+ e.to_string(),
+ GitErrorKind::Other,
+ )
+ })?;
+ Ok((out.status.success(), out.stdout, out.stderr))
+}
+
+/// Whether something may still be writing to these checkouts, which would make
+/// an exclusive-ownership assumption unsafe.
+///
+/// A user-defined fsmonitor, and an executable `post-checkout` or
+/// `reference-transaction` hook, may both have started background processes.
+/// Go checks the same two things for the same reason (gitvcs/sharing.go:26-56).
+/// This does NOT check smudge/clean filters: a filter that reports itself
+/// configured would mean giving up coverage on every LFS checkout, and Go
+/// declines the same trade.
+fn concurrent_writer_reason(repo_root: &Path, git_bin: &Path) -> Option<&'static str> {
+ match raw_git(git_bin, repo_root, &["config", "--get", "core.fsmonitor"]) {
+ Ok((true, value, _)) => {
+ // Booleans select git's BUILT-IN monitor, not a script. Only a
+ // non-boolean value names something that could be running.
+ let v = String::from_utf8_lossy(&value).trim().to_ascii_lowercase();
+ let builtin = matches!(
+ v.as_str(),
+ "" | "false" | "0" | "no" | "off" | "true" | "1" | "yes" | "on"
+ );
+ if !builtin {
+ return Some("Git fsmonitor hook may have started a writer");
+ }
+ }
+ // Exit code 1 is git's documented "key not set" — the ordinary case,
+ // and the one Go treats as safe (gitvcs/sharing.go:30-31). Any other
+ // failure leaves us unable to tell "unset" from "we could not ask",
+ // and unverifiable is not safe.
+ Ok((false, _, _)) => {}
+ Err(_) => return Some("Git fsmonitor configuration cannot be verified"),
+ }
+ for hook in ["post-checkout", "reference-transaction"] {
+ if let Some(reason) = hook_may_have_started_a_writer(repo_root, git_bin, hook) {
+ return Some(reason);
+ }
+ }
+ None
+}
+
+/// Whether `hook` is installed AND executable at the repository root.
+fn hook_may_have_started_a_writer(root: &Path, git_bin: &Path, hook: &str) -> Option<&'static str> {
+ let out = git_out(
+ git_bin,
+ root,
+ &["rev-parse", "--git-path", &format!("hooks/{hook}")],
+ )
+ .ok()?;
+ // Only the trailing newline is framing; whitespace inside a path is part
+ // of the path, and trimming it would point the check at the wrong file.
+ let raw = String::from_utf8_lossy(&out);
+ let reported = raw.strip_suffix('\n').unwrap_or(&raw);
+ let reported = Path::new(reported);
+ let path = if reported.is_absolute() {
+ reported.to_path_buf()
+ } else {
+ root.join(reported)
+ };
+ match std::fs::symlink_metadata(&path) {
+ Ok(meta) if !meta.file_type().is_symlink() && meta.is_file() && is_executable(&meta) => {
+ Some("Git hook may have started a writer")
+ }
+ Ok(_) => None,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
+ Err(_) => Some("Git hook configuration cannot be verified"),
+ }
+}
+
+#[cfg(unix)]
+fn is_executable(meta: &std::fs::Metadata) -> bool {
+ use std::os::unix::fs::PermissionsExt;
+ meta.permissions().mode() & 0o111 != 0
+}
+
+#[cfg(not(unix))]
+fn is_executable(_meta: &std::fs::Metadata) -> bool {
+ false
+}
+
/// 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.
@@ -1461,23 +1995,28 @@ pub(crate) fn next_free_name(pool_dir: &Path, state: &State) -> String {
/// 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.
+/// A slot is "damaged" for BOTH reasons the seam distinguishes — no marker at
+/// all, and a marker whose target cannot be read — because a slot in either
+/// state is one this pool must not touch. That collapsing is safe here and
+/// only here: the caller SKIPS, and skipping is the correct answer for both.
+/// The distinction matters one level up, in
+/// [`vcs::destructive_backend_for_worktree`], which must report a read failure
+/// as a failure rather than answer it with the enclosing repository. So the
+/// marker rule itself lives in [`vcs::worktree_backend_name`] and this is the
+/// pool's own damage test on top of it.
+///
+/// Only a GIT slot counts as intact. A `.jj` marker is a real answer from the
+/// seam, but the pool lifecycle cannot manage a jj slot yet — its reuse checks
+/// are git calls, and running them in a non-git tree fails in terms that hide
+/// the real cause. Reporting such a slot as damaged keeps it out of the reuse
+/// loop AND keeps `is_readable_worktree` / destroy's unverified
+/// classification exactly as they are. Admitting another flavor is a
+/// one-line change HERE once the pool carries flavor state for it (Go
+/// `pool.go`'s `wantFlavor` counters); nothing else in the pool needs to move.
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(),
+ match vcs::worktree_backend_name(worktree) {
+ Ok(Some(name @ vcs::BACKEND_GIT)) => name.to_string(),
+ _ => String::new(),
}
}
@@ -2034,12 +2573,522 @@ mod tests {
};
run_git(&["config", "user.email", "t@t.com"]);
run_git(&["config", "user.name", "T"]);
+ // Keep tracked-file byte assertions independent of the host's checkout
+ // conversion: GitHub's Windows runners set core.autocrlf=true, which
+ // would write CRLF into the slot and break every "committed content"
+ // comparison. Git's default elsewhere is no conversion, so this makes
+ // the fixture agree with what CI already does.
+ run_git(&["config", "core.autocrlf", "false"]);
std::fs::write(repo.join("README.md"), b"hi\n").unwrap();
run_git(&["add", "."]);
run_git(&["commit", "-m", "init"]);
(dir, repo)
}
+ // ─── acquisition: seeding, guarded reset, and the seed-removal contract ──
+
+ /// A repo whose committed `.worktreeinclude` selects `*.env`, plus one
+ /// ignored file for it to find. `*.env` matches nothing tracked, so the
+ /// seed list is exactly what the manifest names.
+ ///
+ /// The ignore rule and the manifest are committed with `-f` because
+ /// `.gitignore` and `.worktreeinclude` ignore themselves by pattern in
+ /// most setups — the same thing `seed_repo` in `git/shell.rs` does.
+ fn seeding_repo() -> (tempfile::TempDir, PathBuf, tempfile::TempDir) {
+ let (dir, repo) = init_repo();
+ std::fs::write(repo.join(".worktreeinclude"), "*.env\n").unwrap();
+ std::fs::write(repo.join(".gitignore"), "*.env\n").unwrap();
+ 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(&["add", "-f", ".gitignore", ".worktreeinclude"]);
+ run_git(&["commit", "-m", "add seeding manifest"]);
+ std::fs::write(repo.join(".env"), "SECRET=from-source\n").unwrap();
+ (dir, repo, tempfile::tempdir().unwrap())
+ }
+
+ fn pool_for(repo: &Path, home: &Path) -> Pool {
+ let opts = OpenOptions {
+ config: TreehouseConfig {
+ root: Some(home.to_string_lossy().into_owned()),
+ ..TreehouseConfig::default_config()
+ },
+ ..Default::default()
+ };
+ Pool::open(repo, None, &opts).unwrap()
+ }
+
+ fn entry_for<'a>(state: &'a State, name: &str) -> &'a WorktreeEntry {
+ state
+ .worktrees
+ .iter()
+ .find(|w| w.name == name)
+ .expect("the entry must be in state")
+ }
+
+ #[test]
+ fn acquisition_seeds_the_worktree_and_records_the_inventory() {
+ let (_dir, repo, home) = seeding_repo();
+ let pool = pool_for(&repo, home.path());
+
+ let acquired = pool
+ .get(&AcquireOptions {
+ branch: Some("main".into()),
+ skip_fetch: true,
+ ..Default::default()
+ })
+ .unwrap();
+
+ assert_eq!(
+ std::fs::read_to_string(acquired.path.join(".env")).unwrap(),
+ "SECRET=from-source\n",
+ "the committed .worktreeinclude must actually seed the worktree"
+ );
+
+ let state = State::read_state(&pool.dir).unwrap();
+ let inventory = entry_for(&state, &acquired.name).seed_inventory();
+ assert!(
+ inventory.known,
+ "an acquisition that seeded files must record a KNOWN inventory"
+ );
+ assert_eq!(inventory.paths, vec![".env".to_string()]);
+ assert_eq!(inventory.backend, vcs::BACKEND_GIT);
+ assert_eq!(
+ inventory.authorized_paths(),
+ Some([".env".to_string()].as_slice()),
+ "the recorded inventory is what the next reset may delete"
+ );
+ }
+
+ #[test]
+ fn recycling_a_slot_reseeds_it_and_leaves_a_users_ignored_file_alone() {
+ let (_dir, repo, home) = seeding_repo();
+ let pool = pool_for(&repo, home.path());
+ let opts = AcquireOptions {
+ branch: Some("main".into()),
+ skip_fetch: true,
+ ..Default::default()
+ };
+
+ let first = pool.get(&opts).unwrap();
+ assert!(first.path.join(".env").exists());
+
+ // A file the USER placed, not the pool. Same ignore class, different
+ // author — the distinction the whole seed inventory exists to draw.
+ std::fs::write(first.path.join("mine.env"), "KEEP=mine\n").unwrap();
+ pool.release(&first.path.to_string_lossy()).unwrap();
+
+ let second = pool.get(&opts).unwrap();
+ assert_eq!(second.name, first.name, "the slot must have been recycled");
+ assert_eq!(
+ std::fs::read_to_string(second.path.join(".env")).unwrap(),
+ "SECRET=from-source\n",
+ "the seed must be present again after the recycle reset"
+ );
+ assert_eq!(
+ std::fs::read_to_string(second.path.join("mine.env")).unwrap(),
+ "KEEP=mine\n",
+ "an ignored file the pool did not seed must survive the reset"
+ );
+
+ let state = State::read_state(&pool.dir).unwrap();
+ let inventory = entry_for(&state, &second.name).seed_inventory();
+ assert_eq!(
+ inventory.paths,
+ vec![".env".to_string()],
+ "only what the pool seeded may be listed; the user's file must not be"
+ );
+ }
+
+ #[test]
+ fn an_include_manifest_selects_exactly_what_it_lists() {
+ let (_dir, repo, home) = seeding_repo();
+ let pool = pool_for(&repo, home.path());
+
+ // The committed manifest selects `*.env`; this one selects nothing, so
+ // the acquisition must seed nothing and must NOT fall back to it.
+ let acquired = pool
+ .get(&AcquireOptions {
+ branch: Some("main".into()),
+ skip_fetch: true,
+ include_manifest: Some(Vec::new()),
+ ..Default::default()
+ })
+ .unwrap();
+ assert!(
+ !acquired.path.join(".env").exists(),
+ "an empty manifest means seed nothing, not 'use the committed one'"
+ );
+ let state = State::read_state(&pool.dir).unwrap();
+ assert!(
+ entry_for(&state, &acquired.name)
+ .seed_inventory()
+ .authorized_paths()
+ .is_none(),
+ "seeding nothing must leave an inventory that authorizes nothing"
+ );
+
+ pool.release(&acquired.path.to_string_lossy()).unwrap();
+
+ // A manifest that names a real ignored file seeds exactly that file.
+ let acquired = pool
+ .get(&AcquireOptions {
+ branch: Some("main".into()),
+ skip_fetch: true,
+ include_manifest: Some(b"other.env\n".to_vec()),
+ ..Default::default()
+ })
+ .unwrap();
+ assert!(
+ !acquired.path.join(".env").exists(),
+ "the committed manifest must be REPLACED, not merged"
+ );
+ assert!(!acquired.path.join("other.env").exists());
+ let state = State::read_state(&pool.dir).unwrap();
+ assert!(
+ entry_for(&state, &acquired.name)
+ .seed_inventory()
+ .authorized_paths()
+ .is_none(),
+ "a manifest naming an absent file selects nothing, which is valid"
+ );
+ }
+
+ #[test]
+ fn releasing_a_seeded_slot_clears_its_inventory_to_a_verified_empty_one() {
+ let (_dir, repo, home) = seeding_repo();
+ let pool = pool_for(&repo, home.path());
+ let acquired = pool
+ .get(&AcquireOptions {
+ branch: Some("main".into()),
+ skip_fetch: true,
+ ..Default::default()
+ })
+ .unwrap();
+
+ pool.release(&acquired.path.to_string_lossy()).unwrap();
+
+ let state = State::read_state(&pool.dir).unwrap();
+ let inventory = entry_for(&state, &acquired.name).seed_inventory();
+ assert!(
+ inventory.known,
+ "a released slot holds nothing we seeded — that is a claim, not a gap"
+ );
+ assert!(
+ inventory.paths.is_empty(),
+ "the stale list must not outlive the files it named"
+ );
+ assert!(
+ !acquired.path.join(".env").exists(),
+ "release removes the pool's own seeds"
+ );
+ }
+
+ // ─── the guarded reset ───────────────────────────────────────────────────
+
+ /// Builds a linked worktree at `main` and returns the pool's reset
+ /// function bound to it, so a test can stage a race and then reset.
+ fn reset_fixture() -> (
+ tempfile::TempDir,
+ PathBuf,
+ PathBuf,
+ Arc,
+ PathBuf,
+ ) {
+ let (dir, repo) = init_repo();
+ let slot = dir.path().join("slot");
+ let git: Arc = Arc::new(crate::git::ShellGitBackend::discover().unwrap());
+ let out = std::process::Command::new("git")
+ .args(["worktree", "add", "--detach"])
+ .arg(&slot)
+ .arg("main")
+ .current_dir(&repo)
+ .output()
+ .unwrap();
+ assert!(out.status.success(), "{out:?}");
+ let bin = crate::git::ShellGitBackend::discover()
+ .unwrap()
+ .git_bin()
+ .to_path_buf();
+ (dir, repo, slot, git, bin)
+ }
+
+ #[test]
+ fn a_slot_whose_head_moved_since_the_safety_check_is_not_reset() {
+ // The race the guarded pair exists for. `Pool::get` takes the guard and
+ // resets in one function; here the two calls are made separately with a
+ // commit landing between them, which is exactly the interleaving the
+ // pre-seam code silently discarded.
+ let (_d, _repo, slot, git, bin) = reset_fixture();
+ let guard = vcs::is_worktree_safe_to_reset(&bin, git.as_ref(), &slot, "main").unwrap();
+ assert!(guard.safe);
+
+ // The window: a commit lands in the slot after the check.
+ let run_git = |args: &[&str]| {
+ let out = std::process::Command::new("git")
+ .args(args)
+ .current_dir(&slot)
+ .output()
+ .unwrap();
+ assert!(out.status.success(), "git {args:?} failed");
+ };
+ std::fs::write(slot.join("landed.txt"), "work\n").unwrap();
+ run_git(&["add", "-A"]);
+ run_git(&["commit", "-m", "landed after the check"]);
+ let moved_head = std::process::Command::new("git")
+ .args(["rev-parse", "HEAD"])
+ .current_dir(&slot)
+ .output()
+ .unwrap();
+ let moved_head = String::from_utf8_lossy(&moved_head.stdout)
+ .trim()
+ .to_string();
+
+ let err = vcs::reset_worktree_to_ref(
+ &bin,
+ git.as_ref(),
+ &slot,
+ &guard.reset_ref,
+ &guard.head,
+ true,
+ )
+ .expect_err("a HEAD that moved since the check must refuse the reset");
+
+ assert!(
+ err.message.contains("HEAD changed since safety check"),
+ "the refusal must name the actual cause, got: {}",
+ err.message
+ );
+ assert_eq!(
+ moved_head,
+ std::process::Command::new("git")
+ .args(["rev-parse", "HEAD"])
+ .current_dir(&slot)
+ .output()
+ .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
+ .unwrap(),
+ "the commit that landed in the window must still be there"
+ );
+ assert!(
+ slot.join("landed.txt").exists(),
+ "a refused reset must not touch the worktree"
+ );
+ }
+
+ #[test]
+ fn acquisition_refuses_a_slot_holding_commits_the_branch_does_not_contain() {
+ // The pool-level consequence of the same guard: `reset_for_acquisition`
+ // is what `Pool::get` calls, and it must refuse rather than discard.
+ let (_d, _repo, slot, git, _bin) = reset_fixture();
+ let run_git = |args: &[&str]| {
+ let out = std::process::Command::new("git")
+ .args(args)
+ .current_dir(&slot)
+ .output()
+ .unwrap();
+ assert!(out.status.success(), "git {args:?} failed");
+ };
+ std::fs::write(slot.join("unlanded.txt"), "work\n").unwrap();
+ run_git(&["add", "-A"]);
+ run_git(&["commit", "-m", "unlanded"]);
+
+ let err = reset_for_acquisition(
+ &git,
+ &slot,
+ "main",
+ &SeedInventory::default(),
+ SlotOrigin::Recycled,
+ )
+ .expect_err("a slot ahead of its branch must not be reset");
+
+ assert!(
+ matches!(&err, PoolError::Git(e) if e.message.contains("commits that")),
+ "got: {err}"
+ );
+ assert!(
+ slot.join("unlanded.txt").exists(),
+ "the unlanded work must survive a refused reset"
+ );
+ }
+
+ #[test]
+ fn a_recycled_slot_is_reset_under_the_clean_tree_the_reuse_rules_require() {
+ // The no-race counterpart: when the checks pass, the guarded reset does
+ // exactly what the plain reset did. The reuse rules in `acquire_locked`
+ // already refuse a dirty slot, so a recycled slot arrives here clean and
+ // this is the ordinary production shape.
+ let (_d, _repo, slot, git, _bin) = reset_fixture();
+ let head_before = head_of(&slot);
+
+ reset_for_acquisition(
+ &git,
+ &slot,
+ "main",
+ &SeedInventory::default(),
+ SlotOrigin::Recycled,
+ )
+ .unwrap();
+
+ assert_eq!(
+ head_of(&slot),
+ head_before,
+ "a safe reset lands the slot on exactly the commit it already held"
+ );
+ assert_eq!(
+ std::fs::read_to_string(slot.join("README.md")).unwrap(),
+ "hi\n",
+ "tracked content is untouched by a reset of a clean tree"
+ );
+ }
+
+ #[test]
+ fn a_fresh_slot_reset_restores_content_and_sweeps_untracked_files() {
+ // `require_clean` is false for a FRESH slot, matching Go, which never
+ // requires a clean tree on the creation path. That is the one case where
+ // the reset actually has work to do — a checkout filter may have left an
+ // untracked file — and where the pre-seam `reset_worktree` restored the
+ // tracked tree and swept the rest.
+ let (_d, _repo, slot, git, _bin) = reset_fixture();
+ std::fs::write(slot.join("README.md"), "clobbered\n").unwrap();
+ std::fs::write(slot.join("junk.txt"), "untracked\n").unwrap();
+
+ reset_for_acquisition(
+ &git,
+ &slot,
+ "main",
+ &SeedInventory::default(),
+ SlotOrigin::Fresh,
+ )
+ .unwrap();
+
+ assert_eq!(
+ std::fs::read_to_string(slot.join("README.md")).unwrap(),
+ "hi\n",
+ "read-tree --reset -u must restore tracked content"
+ );
+ assert!(
+ !slot.join("junk.txt").exists(),
+ "clean -fd must still sweep untracked files"
+ );
+ }
+
+ #[test]
+ fn a_recycled_slot_that_became_dirty_is_refused_not_overwritten() {
+ // The other race the guard closes. `acquire_locked` proved the tree clean
+ // before releasing the lock; something changed it afterwards. Wiping that
+ // would discard the change while every check reported success.
+ let (_d, _repo, slot, git, _bin) = reset_fixture();
+ std::fs::write(slot.join("README.md"), "edited after the reuse check\n").unwrap();
+
+ let err = reset_for_acquisition(
+ &git,
+ &slot,
+ "main",
+ &SeedInventory::default(),
+ SlotOrigin::Recycled,
+ )
+ .expect_err("a tree that went dirty after the check must refuse the reset");
+
+ assert!(
+ matches!(&err, PoolError::Git(e) if e.message.contains("dirty after safety check")),
+ "got: {err}"
+ );
+ assert_eq!(
+ std::fs::read_to_string(slot.join("README.md")).unwrap(),
+ "edited after the reuse check\n",
+ "a refused reset must not overwrite what it refused to trust"
+ );
+ }
+
+ /// `git rev-parse HEAD` in `slot`.
+ fn head_of(slot: &Path) -> String {
+ let out = std::process::Command::new("git")
+ .args(["rev-parse", "HEAD"])
+ .current_dir(slot)
+ .output()
+ .unwrap();
+ assert!(out.status.success(), "git rev-parse failed in {slot:?}");
+ String::from_utf8_lossy(&out.stdout).trim().to_string()
+ }
+
+ // ─── the seed-removal contract ───────────────────────────────────────────
+
+ #[test]
+ fn an_empty_inventory_authorizes_no_deletion() {
+ // The safety property in isolation. An entry that lost its bookkeeping
+ // must delete nothing — turning that loss into deletion would make a
+ // bookkeeping bug look like a feature.
+ let dir = tempfile::tempdir().unwrap();
+ let slot = dir.path().join("slot");
+ std::fs::create_dir_all(&slot).unwrap();
+ std::fs::write(slot.join("keep.env"), "KEEP\n").unwrap();
+
+ let err = remove_seeded_paths(&slot, &[]).expect_err("an empty inventory must refuse");
+ assert!(matches!(&err, PoolError::Git(e) if e.message.contains("seed inventory")));
+ assert!(slot.join("keep.env").exists());
+ }
+
+ #[test]
+ fn a_traversing_seed_path_is_refused_before_anything_is_deleted() {
+ let dir = tempfile::tempdir().unwrap();
+ let slot = dir.path().join("slot");
+ std::fs::create_dir_all(&slot).unwrap();
+ let outside = dir.path().join("precious");
+ std::fs::write(&outside, "DO NOT TOUCH\n").unwrap();
+ std::fs::write(slot.join("a.env"), "a\n").unwrap();
+
+ // The first entry is benign; the second escapes. Validation covers the
+ // WHOLE list before the first delete, or a traversal in position two
+ // would be preceded by an unvalidated removal in position one.
+ let err = remove_seeded_paths(&slot, &["a.env".to_string(), "../precious".to_string()])
+ .expect_err("a traversing path must be refused");
+ assert!(matches!(&err, PoolError::Git(e) if e.message.contains("invalid seeded path")));
+ assert!(slot.join("a.env").exists(), "nothing may be deleted");
+ assert_eq!(std::fs::read_to_string(&outside).unwrap(), "DO NOT TOUCH\n");
+ }
+
+ #[test]
+ fn seed_removal_touches_only_the_listed_paths() {
+ let dir = tempfile::tempdir().unwrap();
+ let slot = dir.path().join("slot");
+ std::fs::create_dir_all(slot.join("nested")).unwrap();
+ std::fs::write(slot.join("a.env"), "a\n").unwrap();
+ std::fs::write(slot.join("b.env"), "b\n").unwrap();
+ std::fs::write(slot.join("nested/c.env"), "c\n").unwrap();
+
+ remove_seeded_paths(&slot, &["a.env".to_string(), "nested/c.env".to_string()]).unwrap();
+
+ assert!(!slot.join("a.env").exists());
+ assert!(!slot.join("nested/c.env").exists());
+ assert!(
+ slot.join("b.env").exists(),
+ "a path the inventory does not name must never be removed"
+ );
+ assert!(
+ slot.join("nested").is_dir(),
+ "the inventory names a file, not the directory holding it"
+ );
+ }
+
+ #[test]
+ fn a_seed_path_that_is_already_gone_is_not_a_failure() {
+ // The inventory is permission to delete, not an obligation. A slot whose
+ // seeds the user deleted by hand must still reset.
+ let dir = tempfile::tempdir().unwrap();
+ let slot = dir.path().join("slot");
+ std::fs::create_dir_all(&slot).unwrap();
+ remove_seeded_paths(&slot, &["never-existed.env".to_string()]).unwrap();
+ }
+
/// An env whose ONLY override is the pool root — the shape the CLI builds
/// for `--env-path` (cli.rs:246-315).
struct PoolRootEnv(PathBuf);
@@ -2193,6 +3242,41 @@ mod tests {
assert_eq!(slot_backend_name(dir.path()), "");
}
+ #[test]
+ fn slot_backend_name_does_not_admit_a_jj_workspace_yet() {
+ // The seam answers "jj" for this path; the pool does not. The reuse
+ // loop's checks are git calls, so admitting a non-git slot here would
+ // run them in a tree git cannot read and report the failure in terms
+ // that hide the cause. Documented as the one-line change the jj
+ // backend makes once the pool carries flavor state.
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::create_dir(dir.path().join(".jj")).unwrap();
+ assert_eq!(vcs::worktree_backend_name(dir.path()).unwrap(), Some("jj"));
+ assert_eq!(slot_backend_name(dir.path()), "");
+ }
+
+ #[test]
+ fn slot_backend_name_agrees_with_the_seam_on_every_git_case() {
+ // The marker rule is implemented once, in the seam. This pins the two
+ // to the same answers so a future edit to one cannot drift from the
+ // other: the pool's damage test and the dispatch the reset actually
+ // goes through MUST agree, or a slot could be classified healthy here
+ // and refused — or worse, the reverse — a few lines later.
+ for case in ["absent", "git-dir", "git-file"] {
+ let dir = tempfile::tempdir().unwrap();
+ match case {
+ "git-dir" => std::fs::create_dir(dir.path().join(".git")).unwrap(),
+ "git-file" => std::fs::write(dir.path().join(".git"), b"gitdir: /x\n").unwrap(),
+ _ => {}
+ }
+ let via_pool = slot_backend_name(dir.path());
+ let via_seam = vcs::destructive_backend_for_worktree(dir.path())
+ .map(|b| b.name().to_string())
+ .unwrap_or_default();
+ assert_eq!(via_pool, via_seam, "disagreement on {case}");
+ }
+ }
+
// ─── 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
@@ -2437,9 +3521,22 @@ mod tests {
// 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();
+ // Git writes this pointer with forward slashes on EVERY platform, and on a
+ // Windows runner it writes the LONG form of a temp path whose Rust side
+ // is the 8.3 short form (`RUNNER~1` vs `runneradmin`). Neither is a
+ // difference in what the path NAMES, so compare the resolved paths the
+ // same way the product does (`same_file`, pool.rs:2032) — that is also
+ // why this test failing here was never evidence of a product bug.
+ let gitdir_path = Path::new(gitdir.trim().strip_prefix("gitdir:").unwrap().trim());
+ let gitdir_real = std::fs::canonicalize(gitdir_path).unwrap_or_else(|_| {
+ panic!("the slot's gitdir pointer must name a real directory: {gitdir}")
+ });
+ let clone_a_real = std::fs::canonicalize(&clone_a).unwrap();
assert!(
- gitdir.contains(clone_a.to_str().unwrap()),
- "clone A must own the slot, got gitdir: {gitdir}"
+ gitdir_real.starts_with(&clone_a_real),
+ "clone A must own the slot: gitdir {:?} is not inside {:?}",
+ gitdir_real,
+ clone_a_real
);
// Clone B, with the pool at max_trees: the only way forward would be to
@@ -2593,17 +3690,16 @@ mod tests {
..Default::default()
})
.expect_err("--branch must refuse an existing branch");
- assert!(
- err.to_string().contains("already exists"),
- "got: {err}"
- );
+ 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"] {
+ 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");
}
@@ -2723,10 +3819,10 @@ mod tests {
#[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
+ "{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");
@@ -2735,39 +3831,46 @@ mod tests {
assert!(validate_worktree_path_template("").is_ok());
}
+ /// An absolute root for the fake paths below. `/pool` has a root but no
+ /// volume, so it is absolute on unix and drive-RELATIVE on Windows, where
+ /// `is_absolute` also requires a prefix — the resolver would refuse its own
+ /// template. Never touched on disk: `resolve_worktree_path` is pure string
+ /// expansion.
+ fn fake_abs_root(unix: &str, windows: &str) -> PathBuf {
+ if cfg!(windows) {
+ PathBuf::from(windows)
+ } else {
+ PathBuf::from(unix)
+ }
+ }
+
/// `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");
+ let repo = fake_abs_root("/src/myrepo", r"C:\src\myrepo");
+ let pool_dir = fake_abs_root("/pool", r"C:\pool");
assert_eq!(
- resolve_worktree_path(repo, pool_dir, "3", "", false).unwrap(),
- PathBuf::from("/pool/3/myrepo"),
+ resolve_worktree_path(&repo, &pool_dir, "3", "", false).unwrap(),
+ pool_dir.join("3").join("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"),
+ resolve_worktree_path(&repo, &pool_dir, "3", "", true).unwrap(),
+ pool_dir.join("3").join("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")
+ resolve_worktree_path(&repo, &pool_dir, "3", "{pool}/{slot}/{repo}", false).unwrap(),
+ pool_dir.join("3").join("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.
+ // 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")
+ resolve_worktree_path(&repo, &pool_dir, "3", "{repo_parent}/{repo}/{slot}", false)
+ .unwrap(),
+ repo.join("3")
);
// Relative templates are refused: a worktree must land where the caller
// named, not wherever the process happens to be running.
@@ -2782,11 +3885,11 @@ mod tests {
/// 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");
+ let repo = fake_abs_root("/src/myrepo", r"C:\src\myrepo");
+ let pool_dir = fake_abs_root("/pool", r"C:\pool");
assert_eq!(
- resolve_worktree_path(repo, pool_dir, "2", "{pool}/{slot}/{repo}-x", true).unwrap(),
- PathBuf::from("/pool/2/myrepo-x"),
+ resolve_worktree_path(&repo, &pool_dir, "2", "{pool}/{slot}/{repo}-x", true).unwrap(),
+ pool_dir.join("2").join("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 a68dcdb..71151c4 100644
--- a/crates/treehouse-core/src/process.rs
+++ b/crates/treehouse-core/src/process.rs
@@ -15,7 +15,7 @@ use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Duration;
-use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System};
+use sysinfo::{Pid, ProcessRefreshKind, ProcessStatus, ProcessesToUpdate, RefreshKind, System};
/// A process found running inside a worktree. Display is `name (pid)`.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -122,6 +122,36 @@ impl ProcessTable {
let mut result = Vec::new();
let system = self.system.lock().unwrap();
for (pid, process) in system.processes() {
+ // A zombie has already terminated — it holds no cwd, runs no
+ // instructions, and cannot block `git worktree remove`. It must
+ // never read as "a process is still using this worktree".
+ //
+ // This check is load-bearing, not defensive. sysinfo refreshes
+ // `cwd` as `UpdateKind::OnlyIfNotSet` (system.rs:2478), so once a
+ // pid's cwd is cached it is NEVER re-read: `needs_update` returns
+ // `p.cwd.is_none()` (system.rs:2336). When Linux SIGKILLs a child
+ // the pid survives as a zombie, `/proc//cwd` disappears — and
+ // sysinfo keeps the stale cached path. Without this filter the
+ // post-kill survivor scan in `destroy` sees the dead pid, and
+ // `--include-in-use` skipped with "worktree processes still
+ // running after termination" even though nothing was running.
+ // `status` IS re-read on every refresh, so it is the fresh signal.
+ //
+ // That is true on unix and FALSE on Windows: sysinfo's Windows
+ // backend assigns `status` exactly once, as
+ // `ProcessStatus::Run` in `ProcessInner::new`, and `update()`
+ // never reassigns it — so this comparison alone can never be true
+ // there. A Windows process killed by `TerminateProcess` also
+ // lingers in the `CreateToolhelp32Snapshot` listing until the
+ // last handle to it closes, and because cwd is
+ // `UpdateKind::OnlyIfNotSet` it is read back with its stale
+ // worktree path. Same symptom as the unix case: the post-kill
+ // survivor scan in `destroy` saw the dead pid and `--include-in-use`
+ // skipped with "worktree processes still running after
+ // termination" even though nothing was running.
+ if process.status() == ProcessStatus::Zombie || has_terminated(pid.as_u32()) {
+ continue;
+ }
let cwd = match process.cwd() {
Some(c) => c.to_path_buf(),
None => continue, // exited or permission-restricted: skip
@@ -331,6 +361,46 @@ fn terminate_process_windows(pid: i32) -> std::io::Result<()> {
}
}
+/// Whether `pid` names a process that has already exited, so a scan must not
+/// report it as still running inside a worktree.
+///
+/// Windows keeps a terminated process in the process table until every handle
+/// to it is closed, so `CreateToolhelp32Snapshot` still lists it and sysinfo
+/// still hands back its cached cwd. `GetExitCodeProcess` is the authoritative
+/// signal: it returns `STILL_ACTIVE` only while the process is actually
+/// running, and the real exit code once it has terminated. Go needs none of
+/// this because gopsutil re-reads cwd live per process and skips on read
+/// failure; sysinfo caches cwd, so liveness has to be asked for explicitly.
+///
+/// Fails CLOSED: a process we cannot open is reported as still running, so a
+/// permission error can never be mistaken for "safe to delete".
+#[cfg(windows)]
+fn has_terminated(pid: u32) -> bool {
+ use windows_sys::Win32::Foundation::CloseHandle;
+ use windows_sys::Win32::System::Threading::{
+ GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
+ };
+ /// `GetExitCodeProcess` reports this while the process has not exited.
+ const STILL_ACTIVE: u32 = 259;
+ unsafe {
+ let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
+ if handle.is_null() {
+ return false;
+ }
+ let mut exit_code = 0u32;
+ let ok = GetExitCodeProcess(handle, &mut exit_code) != 0;
+ CloseHandle(handle);
+ ok && exit_code != STILL_ACTIVE
+ }
+}
+
+/// unix: a zombie is reported through `ProcessStatus::Zombie` and caught by the
+/// status check in `find_in_worktree`, so there is nothing extra to do here.
+#[cfg(not(windows))]
+fn has_terminated(_pid: u32) -> bool {
+ false
+}
+
/// Absolute + symlink-resolved path (Go `resolvePath`): returns the canonical
/// path, or the input if resolution fails.
fn absolute_and_resolve(p: &Path) -> Option {
diff --git a/crates/treehouse-core/src/run.rs b/crates/treehouse-core/src/run.rs
index 6eeb249..181fd2a 100644
--- a/crates/treehouse-core/src/run.rs
+++ b/crates/treehouse-core/src/run.rs
@@ -322,12 +322,7 @@ mod tests {
// 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();
+ 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
diff --git a/crates/treehouse-core/src/state.rs b/crates/treehouse-core/src/state.rs
index 4d7ede8..02b53f4 100644
--- a/crates/treehouse-core/src/state.rs
+++ b/crates/treehouse-core/src/state.rs
@@ -127,23 +127,80 @@ pub struct WorktreeEntry {
/// 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.
+ /// Fields this build does not model as struct fields, preserved verbatim.
///
- /// Go's entry carries acquisition metadata the port has not implemented
- /// yet (`base_branch`, `seeded_paths`, `seed_inventory_*`, `seed_backend`,
+ /// Go's entry carries acquisition metadata that has no dedicated field
+ /// here (`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.
+ /// listed, returned and destroyed by this build without damage.
///
- /// Populated on read, emitted on write, never interpreted here.
+ /// Populated on read, emitted on write. Unmodelled keys are never
+ /// interpreted. The seed-inventory and base-branch keys ARE interpreted,
+ /// but only through the typed accessors above
+ /// ([`Self::seed_inventory`], [`Self::set_seed_inventory`],
+ /// [`Self::base_branch`], [`Self::set_base_branch`]), never by poking at
+ /// this map from a caller: the accessors own Go's key names and `omitempty`
+ /// shape, and a caller that hand-rolls either re-introduces the
+ /// silent-deletion bug this map exists to prevent.
#[serde(flatten)]
pub extra: std::collections::BTreeMap,
}
+/// Go's five seed-inventory fields, read and written as one value.
+///
+/// Upstream splits `SeededPaths`, `SeedInventoryKnown`, `SeedInventoryDigest`,
+/// `SeedBackend` and `SeedAuthIdentity` across a `WorktreeEntry` (state.go:52-58).
+/// They are split on the wire because that is what JSON is, but they are a
+/// single fact — "these ignored files exist because this pool copied them" — and
+/// every one of them is only meaningful alongside `SeedInventoryKnown`. Reading
+/// them independently is how a caller ends up treating "nobody recorded what was
+/// copied" as the claim "nothing was copied", which authorizes deletions nobody
+/// approved.
+///
+/// [`Self::authorized_paths`] is the one method callers should reach for: it is
+/// the only thing standing between a state file and an arbitrary path deletion.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct SeedInventory {
+ /// The paths this pool copied into the worktree.
+ pub paths: Vec,
+ /// Whether `paths` is a verified record rather than lost bookkeeping.
+ pub known: bool,
+ /// The backend that performed the seeding (`git`, `jj`).
+ pub backend: String,
+ /// Go's seed-authentication identity. Empty unless a backend records one;
+ /// this port has no jj seed authentication, so it stays empty for git.
+ pub auth_identity: String,
+}
+
+impl SeedInventory {
+ /// The paths a reset is authorized to delete, or `None` when nothing is.
+ ///
+ /// `None` covers both "no inventory was ever recorded" and "the recorded
+ /// inventory is empty". Neither authorizes a deletion:
+ /// [`crate::vcs::validate_seed_inventory`] refuses an empty list outright,
+ /// and a missing record is a caller that lost its bookkeeping. Turning that
+ /// loss into deletion would make a bookkeeping bug look like a feature.
+ pub fn authorized_paths(&self) -> Option<&[String]> {
+ if self.known && !self.paths.is_empty() {
+ Some(&self.paths)
+ } else {
+ None
+ }
+ }
+}
+
+/// Wire keys for the seed-inventory fields (Go `WorktreeEntry` JSON tags).
+const K_SEEDED_PATHS: &str = "seeded_paths";
+const K_SEED_INVENTORY_KNOWN: &str = "seed_inventory_known";
+const K_SEED_BACKEND: &str = "seed_backend";
+const K_SEED_AUTH_IDENTITY: &str = "seed_auth_identity";
+/// Go's `base_branch` (state.go:48).
+const K_BASE_BRANCH: &str = "base_branch";
+
impl WorktreeEntry {
/// A lease that has passed its TTL. Permanent leases (zero `expires_at`)
/// are never stale.
@@ -155,6 +212,145 @@ impl WorktreeEntry {
pub fn is_valid_lease(&self, now: DateTime) -> bool {
self.leased && (self.expires_at == ZERO_TIME || now < self.expires_at)
}
+
+ /// The seed inventory recorded for this slot (Go's five seed fields).
+ ///
+ /// Total by construction: every key that is absent, `null`, or of the wrong
+ /// JSON type reads as its zero value rather than failing the whole state
+ /// read. A state file written by another build is data to be preserved, not
+ /// a parse error — and one malformed field must not make an entire pool
+ /// unreadable, which would turn a recoverable metadata problem into an
+ /// acquisition failure.
+ pub fn seed_inventory(&self) -> SeedInventory {
+ SeedInventory {
+ paths: string_list(self.extra.get(K_SEEDED_PATHS)),
+ known: self
+ .extra
+ .get(K_SEED_INVENTORY_KNOWN)
+ .and_then(serde_json::Value::as_bool)
+ .unwrap_or(false),
+ backend: string_field(self.extra.get(K_SEED_BACKEND)),
+ auth_identity: string_field(self.extra.get(K_SEED_AUTH_IDENTITY)),
+ }
+ }
+
+ /// Records `inventory` as this slot's trusted seed inventory.
+ ///
+ /// Written through [`Self::extra`] under Go's own JSON keys, and written
+ /// with Go's `omitempty` shape: a field at its zero value is REMOVED rather
+ /// than written as an empty value. Removing (not overwriting) is what stops
+ /// a cleared inventory from leaving a stale list behind for the next reader
+ /// — the difference between "seed nothing" and "seed whatever that was".
+ ///
+ /// Storage is the flatten map rather than a dedicated struct field because
+ /// that map is the module's load-bearing wire contract: it is what keeps
+ /// every field a newer treehouse wrote from being silently deleted by the
+ /// read-modify-write this type performs on each acquisition. Adding a
+ /// derived serde field named `seeded_paths` would move that key out of
+ /// `extra` and into a struct field — which reads as typed access but
+ /// silently narrows what round-trips.
+ pub fn set_seed_inventory(&mut self, inventory: SeedInventory) {
+ if inventory.paths.is_empty() {
+ self.extra.remove(K_SEEDED_PATHS);
+ } else {
+ self.extra.insert(
+ K_SEEDED_PATHS.to_string(),
+ serde_json::to_value(&inventory.paths).unwrap_or(serde_json::Value::Null),
+ );
+ }
+ set_omitempty(
+ &mut self.extra,
+ K_SEED_INVENTORY_KNOWN,
+ inventory.known.then_some(serde_json::Value::Bool(true)),
+ );
+ set_omitempty(
+ &mut self.extra,
+ K_SEED_BACKEND,
+ nonempty_value(&inventory.backend),
+ );
+ set_omitempty(
+ &mut self.extra,
+ K_SEED_AUTH_IDENTITY,
+ nonempty_value(&inventory.auth_identity),
+ );
+ }
+
+ /// Drops this slot's seed inventory, leaving an empty, *known* one.
+ ///
+ /// Mirrors Go's `setSeedInventory(wt, nil, true)`: a VERIFIED empty
+ /// inventory, not a lost one. The distinction is the difference between
+ /// "this slot has nothing in it that we put there" and "we no longer know",
+ /// and only the first is a claim cleanup may act on.
+ pub fn clear_seed_inventory(&mut self) {
+ self.set_seed_inventory(SeedInventory {
+ known: true,
+ ..Default::default()
+ });
+ }
+
+ /// The EXPLICIT base this slot was last cut from (Go `BaseBranch`).
+ ///
+ /// `None` when absent or non-string — distinct from `Some("")`, which Go
+ /// uses for "cut from the inferred default" and which therefore means
+ /// something a later acquisition may weigh.
+ pub fn base_branch(&self) -> Option<&str> {
+ self.extra
+ .get(K_BASE_BRANCH)
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.is_empty())
+ }
+
+ /// Records the base this slot was cut from (Go `BaseBranch`).
+ pub fn set_base_branch(&mut self, branch: &str) {
+ set_omitempty(&mut self.extra, K_BASE_BRANCH, nonempty_value(branch));
+ }
+}
+
+/// Writes `value` under `key`, or removes `key` when there is no value.
+///
+/// Go's `omitempty` in one place, so "clear this field" and "this field has no
+/// value" cannot drift into two different wire shapes.
+fn set_omitempty(
+ extra: &mut std::collections::BTreeMap,
+ key: &str,
+ value: Option,
+) {
+ match value {
+ Some(v) => {
+ extra.insert(key.to_string(), v);
+ }
+ None => {
+ extra.remove(key);
+ }
+ }
+}
+
+fn nonempty_value(s: &str) -> Option {
+ if s.is_empty() {
+ None
+ } else {
+ Some(serde_json::Value::String(s.to_string()))
+ }
+}
+
+/// Reads a JSON string field, treating absent, `null` and non-string as empty.
+fn string_field(v: Option<&serde_json::Value>) -> String {
+ v.and_then(|v| v.as_str()).unwrap_or_default().to_string()
+}
+
+/// Reads a JSON string list, keeping only the entries that are strings.
+///
+/// A list with a non-string entry is not a claim about paths, so it degrades to
+/// "nothing was seeded" rather than to a partially-trusted list that a reset
+/// would act on.
+fn string_list(v: Option<&serde_json::Value>) -> Vec {
+ v.and_then(|v| v.as_array())
+ .map(|a| {
+ a.iter()
+ .filter_map(|e| e.as_str().map(str::to_string))
+ .collect()
+ })
+ .unwrap_or_default()
}
/// The pool state file: an ordered list of managed worktrees.
@@ -899,7 +1095,12 @@ mod tests {
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);
+ // `Path::ends_with` compares whole path components, so this holds on
+ // both `/` and `\` platforms. `String::ends_with("1/myrepo")` is a
+ // substring test and hardcodes the POSIX separator: `quarantine_entry`
+ // renders the path from a joined PathBuf, which is `1\myrepo` on
+ // Windows, so the assertion could never pass there.
+ assert!(Path::new(&e.path).ends_with("1/myrepo"), "got {}", e.path);
}
#[test]
@@ -975,10 +1176,25 @@ mod tests {
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();
+ // Serialize rather than hand-format: a raw Windows pool path contains
+ // backslashes, and `\` is a JSON escape character, so an interpolated
+ // literal would not parse. The parse error routed this read into
+ // corrupt-state recovery, which quarantined the very entry the state
+ // file names — a failure of the fixture, not of adoption.
+ let recorded = State {
+ worktrees: vec![WorktreeEntry {
+ name: "1".into(),
+ path: spelled,
+ 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(&recorded).unwrap(),
+ )
+ .unwrap();
let s = State::read_state(dir.path()).unwrap();
assert_eq!(
@@ -1155,6 +1371,148 @@ mod tests {
// ─── Wire format: unknown Go fields must survive a read-modify-write ────
+ // ─── the typed seed-inventory layer ──────────────────────────────────────
+
+ /// A Go-written entry must read back through the typed accessors with the
+ /// exact values Go wrote, and write back in Go's shape.
+ ///
+ /// This is the round trip that matters: a pool written by an upstream build
+ /// is opened by this one, seeded-path removal has to act on what Go
+ /// recorded, and every write must leave a file the next reader — of either
+ /// build — still understands.
+ #[test]
+ fn a_go_written_seed_inventory_reads_and_writes_back_identically() {
+ let json = r#"{
+ "worktrees": [{
+ "name": "1",
+ "path": "/pool/1/repo",
+ "created_at": "2026-08-14T12:00:00Z",
+ "leased": false,
+ "base_branch": "main",
+ "seeded_paths": [".env", "vendor/"],
+ "seed_inventory_known": true,
+ "seed_backend": "git",
+ "seed_auth_identity": "auth-abc"
+ }]
+}"#;
+ let s: State = serde_json::from_str(json).unwrap();
+ let inventory = s.worktrees[0].seed_inventory();
+ assert!(inventory.known);
+ assert_eq!(inventory.paths, vec![".env", "vendor/"]);
+ assert_eq!(inventory.backend, "git");
+ assert_eq!(inventory.auth_identity, "auth-abc");
+ assert_eq!(s.worktrees[0].base_branch(), Some("main"));
+ assert_eq!(
+ inventory.authorized_paths(),
+ Some([".env".to_string(), "vendor/".to_string()].as_slice())
+ );
+
+ // And back out, with Go's `omitempty` shape preserved exactly.
+ let out = serde_json::to_string(&s).unwrap();
+ for key in [
+ "base_branch",
+ "seeded_paths",
+ "seed_inventory_known",
+ "seed_backend",
+ "seed_auth_identity",
+ ] {
+ assert!(out.contains(&format!("\"{key}\"")), "{key} lost: {out}");
+ }
+ }
+
+ /// A malformed or absent key must degrade to its zero value, never fail the
+ /// whole state read.
+ ///
+ /// One bad field must not make an entire pool unreadable: that would turn a
+ /// recoverable metadata problem into an acquisition failure for every slot
+ /// in it, which is a far worse outcome than ignoring the field.
+ #[test]
+ fn an_absent_or_malformed_seed_key_reads_as_zero_not_as_an_error() {
+ let json = r#"{
+ "worktrees": [
+ {"name":"1","path":"/p/1","created_at":"2026-08-14T12:00:00Z"},
+ {"name":"2","path":"/p/2","created_at":"2026-08-14T12:00:00Z",
+ "seeded_paths": ["ok.env", 42, null],
+ "seed_inventory_known": "yes",
+ "seed_backend": 7}
+ ]
+}"#;
+ let s: State = serde_json::from_str(json).expect("a bad field must not fail the read");
+
+ let absent = s.worktrees[0].seed_inventory();
+ assert!(!absent.known);
+ assert!(absent.paths.is_empty());
+ assert!(
+ absent.authorized_paths().is_none(),
+ "no record is not the same claim as 'nothing was seeded'"
+ );
+
+ let malformed = s.worktrees[1].seed_inventory();
+ assert!(
+ !malformed.known,
+ "a non-boolean seed_inventory_known cannot authorize anything"
+ );
+ assert_eq!(
+ malformed.paths,
+ vec!["ok.env".to_string()],
+ "non-string entries are dropped rather than trusted"
+ );
+ assert!(malformed.backend.is_empty());
+ assert!(
+ malformed.authorized_paths().is_none(),
+ "an unknown inventory authorizes no deletion however long the list is"
+ );
+ }
+
+ /// `known == true` with nothing in it is a real, different claim from
+ /// `known == false`. Collapsing them is how a cleared inventory turns into a
+ /// stale one.
+ #[test]
+ fn a_known_empty_inventory_differs_from_a_lost_one() {
+ let mut cleared = WorktreeEntry::default();
+ cleared.clear_seed_inventory();
+ assert!(cleared.seed_inventory().known);
+ assert!(cleared.seed_inventory().authorized_paths().is_none());
+
+ let never = WorktreeEntry::default();
+ assert!(!never.seed_inventory().known);
+
+ // Both authorize no deletion, but only one of them is a statement about
+ // the slot rather than an absence of one.
+ let cleared_json = serde_json::to_string(&cleared).unwrap();
+ assert!(
+ cleared_json.contains(r#""seed_inventory_known":true"#),
+ "a verified empty inventory must record that it is verified: {cleared_json}"
+ );
+ assert!(
+ !cleared_json.contains("seeded_paths"),
+ "an empty list must be omitted, not written as []: {cleared_json}"
+ );
+ }
+
+ /// Setting then clearing must REMOVE the keys, not overwrite them with
+ /// empty values — otherwise a later reader resurrects a stale list.
+ #[test]
+ fn clearing_removes_the_keys_rather_than_blanking_them() {
+ let mut wt = WorktreeEntry::default();
+ wt.set_seed_inventory(SeedInventory {
+ paths: vec![".env".into()],
+ known: true,
+ backend: "git".into(),
+ auth_identity: String::new(),
+ });
+ wt.set_base_branch("main");
+ assert!(wt.seed_inventory().authorized_paths().is_some());
+
+ wt.clear_seed_inventory();
+ wt.set_base_branch("");
+ assert!(wt.base_branch().is_none());
+ let json = serde_json::to_string(&wt).unwrap();
+ assert!(!json.contains("seeded_paths"), "{json}");
+ assert!(!json.contains("seed_backend"), "{json}");
+ assert!(!json.contains("base_branch"), "{json}");
+ }
+
#[test]
fn unknown_go_fields_round_trip() {
// Every state write is a read-modify-write of the whole file. Without
diff --git a/crates/treehouse-core/src/updater.rs b/crates/treehouse-core/src/updater.rs
index ae3b76f..f1d9e13 100644
--- a/crates/treehouse-core/src/updater.rs
+++ b/crates/treehouse-core/src/updater.rs
@@ -289,7 +289,9 @@ const DOWNLOAD_TIMEOUT_SECS: &str = "300";
const ASSET_PREFIX: &str = "treehouse-v";
/// Per-asset checksum sidecar published next to every release asset
-/// (release.yml:94,99).
+/// (release.yml:94,99). Note that the workflow strips the container extension
+/// before appending it — see [`checksum_sidecar_names`], which accepts both that
+/// spelling and the extension-retained one.
const CHECKSUM_SUFFIX: &str = ".sha256";
/// Go's aggregate checksum manifest (`updater.go:29`). The Rust release
@@ -421,10 +423,80 @@ pub fn matches_current_platform_asset(name: &str) -> bool {
)
}
-/// The checksum file that covers `archive_name`: this workflow's per-asset
-/// sidecar, or Go's aggregate manifest if a release ships one instead.
+/// Container extensions a release asset can carry.
+///
+/// Only `.tar.gz` and `.zip` are ever written by `release.yml`; the rest are
+/// here so a hand-uploaded asset does not silently lose its sidecar match just
+/// because its container is spelled differently. Ordered longest-first: `.gz`
+/// would otherwise strip the wrong end of `.tar.gz`.
+const ARCHIVE_EXTS: &[&str] = &[".tar.gz", ".tar.xz", ".tgz", ".txz", ".zip", ".gz", ".xz"];
+
+/// `name` without its container extension, or `None` if it has none.
+///
+/// The length guard keeps a bare `.gz` (or a name that is nothing but an
+/// extension) from stripping to an empty stem, which would make every
+/// `.sha256` name match.
+fn strip_archive_ext(name: &str) -> Option<&str> {
+ ARCHIVE_EXTS
+ .iter()
+ .find(|ext| name.len() > ext.len() && name.ends_with(**ext))
+ .map(|ext| &name[..name.len() - ext.len()])
+}
+
+/// Every sidecar name that could carry the hash for `archive_name`, most
+/// specific first.
+///
+/// Two spellings exist in the wild and both have to resolve:
+///
+/// - `treehouse-v0.2.0-macos-x86_64.tar.gz.sha256` — the conventional form,
+/// the archive's own name with `.sha256` appended.
+/// - `treehouse-v0.2.0-macos-x86_64.sha256` — the container extension stripped
+/// *first*. This is what `.github/workflows/release.yml` writes
+/// (`printf … > "treehouse-${TAG}-${{ matrix.suffix }}.sha256"`), and it is
+/// what every release published so far carries.
+///
+/// Accepting only the first spelling was the defect that made `treehouse
+/// update` fail on every platform: the workflow never published a file the
+/// updater was willing to look at, so `check_latest_result` resolved a
+/// `checksum_url` of `None` and `apply` refused to run. The updater is the
+/// tolerant side on purpose — matching the stripped spelling costs nothing and
+/// means no existing release has to be re-uploaded.
+///
+/// `every_published_asset_has_a_sidecar_the_updater_accepts` (in the
+/// `release_workflow_contract` test module) ties this list back to the workflow
+/// so the two cannot drift apart again.
+fn checksum_sidecar_names(archive_name: &str) -> Vec {
+ let mut names = vec![format!("{archive_name}{CHECKSUM_SUFFIX}")];
+ if let Some(stem) = strip_archive_ext(archive_name) {
+ let stripped = format!("{stem}{CHECKSUM_SUFFIX}");
+ if !names.contains(&stripped) {
+ names.push(stripped);
+ }
+ }
+ names
+}
+
+/// How good a match `candidate` is as the checksum file for `archive_name`:
+/// `None` when it is not one at all, otherwise a lower-is-better rank.
+///
+/// A rank rather than a bare bool because a release carrying both spellings has
+/// to resolve the same way on every run. The per-asset sidecars rank in
+/// [`checksum_sidecar_names`] order — most specific first — and Go's aggregate
+/// manifest ranks last, behind everything, because it is the only candidate
+/// that covers more than this one archive.
+fn checksum_rank(archive_name: &str, candidate: &str) -> Option {
+ let names = checksum_sidecar_names(archive_name);
+ names
+ .iter()
+ .position(|name| name == candidate)
+ .or_else(|| (candidate == AGGREGATE_CHECKSUM_FILE).then_some(names.len()))
+}
+
+/// Whether `candidate` is a checksum file covering `archive_name`: one of this
+/// release's per-asset sidecars ([`checksum_sidecar_names`]), 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
+ checksum_rank(archive_name, candidate).is_some()
}
// ─── Version check with assets ────────────────────────────────────────────────
@@ -450,10 +522,15 @@ pub fn check_latest_result(
.find(|a| matches_current_platform_asset(&a.name));
let checksum_url = archive.and_then(|archive| {
+ // Every candidate is vetted by `is_checksum_for` and then ordered by
+ // `checksum_rank`, so a release that somehow carries both sidecar
+ // spellings resolves the same way on every run rather than in whatever
+ // order GitHub happened to return the assets in.
release
.assets
.iter()
- .find(|c| is_checksum_for(&archive.name, &c.name))
+ .filter(|c| is_checksum_for(&archive.name, &c.name))
+ .min_by_key(|c| checksum_rank(&archive.name, &c.name))
.map(|c| c.browser_download_url.clone())
});
@@ -1602,9 +1679,18 @@ mod tests {
/// 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());
+ // The container and the entry name have to be the ones this platform
+ // actually publishes. `extract_binary` opens a zip and looks for
+ // `treehouse.exe` on Windows (Go `extractBinary`, updater.go:573-578),
+ // so a hard-coded tar.gz is read as a zip there and dies with
+ // "Could not find EOCD" before the update can be asserted on.
+ let archive_name = format!("treehouse-v9.9.9-{}.{}", os_arch_suffix(), archive_ext());
let archive = dir.join(&archive_name);
- let archive_bytes = make_tar_gz("treehouse", contents);
+ let archive_bytes = if cfg!(windows) {
+ make_zip(binary_name(), contents)
+ } else {
+ make_tar_gz(binary_name(), contents)
+ };
std::fs::write(&archive, &archive_bytes).unwrap();
let sidecar_name = format!("{archive_name}.sha256");
@@ -1688,9 +1774,14 @@ mod tests {
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 name = format!("treehouse-v9.9.9-{}.{}", os_arch_suffix(), archive_ext());
let archive = dir.path().join(&name);
- std::fs::write(&archive, make_tar_gz("README.md", b"no binary here")).unwrap();
+ let bytes = if cfg!(windows) {
+ make_zip("README.md", b"no binary here")
+ } else {
+ make_tar_gz("README.md", b"no binary here")
+ };
+ std::fs::write(&archive, bytes).unwrap();
let hash = sha256_file(&archive).unwrap();
let sidecar = dir.path().join(format!("{name}.sha256"));
std::fs::write(&sidecar, format!("{hash} {name}\n")).unwrap();
@@ -1804,3 +1895,347 @@ mod tests {
assert!(matches!(err, UpdateError::InsecureUrl(_)), "got {err:?}");
}
}
+
+// ─── release-workflow contract ────────────────────────────────────────────────
+//
+// `.github/workflows/release.yml` decides the names of the assets we download;
+// this module decides which of those names we will accept. Nothing in the type
+// system joins them, so the only thing that has ever stopped them drifting
+// apart is a test that reads the real workflow. This is that test.
+//
+// It reads the YAML as TEXT and parses the `tar -czf "…"`, `7z a "…"` and
+// `printf … > "….sha256"` lines out of it, rather than restating the naming as
+// a hardcoded fixture. A fixture would still be green after someone renamed an
+// asset in the workflow, which is precisely the bug this exists to catch.
+//
+// `#[cfg(test)]` because none of it is reachable from production — the parser
+// exists to read a file that only a test has any business reading.
+#[cfg(test)]
+mod release_workflow_contract {
+ use super::*;
+
+ //
+ // `.github/workflows/release.yml` decides the names of the assets we download;
+ // this module decides which of those names we will accept. Nothing in the type
+ // system joins them, so the only thing that has ever stopped them drifting
+ // apart is a test that reads the real workflow. This is that test.
+ //
+ // It reads the YAML as TEXT and parses the `tar -czf "…"`, `7z a "…"` and
+ // `printf … > "….sha256"` lines out of it, rather than restating the naming as
+ // a hardcoded fixture. A fixture would still be green after someone renamed an
+ // asset in the workflow, which is precisely the bug this exists to catch.
+
+ /// The release workflow as it actually exists in this repository.
+ ///
+ /// `include_str!` rather than a runtime read so a workflow that fails to parse
+ /// (or a path that moves) breaks the build instead of the release, and so the
+ /// checked-in copy is exactly the file that runs.
+ const RELEASE_WORKFLOW: &str = include_str!("../../../.github/workflows/release.yml");
+
+ /// The tag used to expand a template. [`is_checksum_for`] is a pure string
+ /// comparison, so the test is about the SHAPE of the asset name; any version
+ /// that looks like the ones we tag releases with does.
+ const SAMPLE_TAG: &str = "v0.2.0";
+
+ /// Expands the two shell variables the workflow's Package step uses.
+ ///
+ /// `$TAG`/`${TAG}` and `${{ matrix.suffix }}` are the only substitutions in the
+ /// asset names. A template containing anything else would be left with its
+ /// placeholders intact, so [`every_published_asset_has_a_sidecar_the_updater_accepts`]
+ /// would fail loudly rather than compare two unexpanded strings for equality.
+ fn expand(template: &str, suffix: &str) -> String {
+ template
+ .replace("${TAG}", SAMPLE_TAG)
+ .replace("$TAG", SAMPLE_TAG)
+ .replace("${{ matrix.suffix }}", suffix)
+ .replace("${matrix.suffix}", suffix)
+ }
+
+ /// The first `"…"` token on `line`, if the line starts with `marker`.
+ fn first_quoted_after<'a>(line: &'a str, marker: &str) -> Option<&'a str> {
+ let rest = line.trim().strip_prefix(marker)?;
+ let rest = rest.strip_prefix('"')?;
+ let end = rest.find('"')?;
+ Some(&rest[..end])
+ }
+
+ /// Every `suffix:` value in the build matrix — `linux-x86_64`, `windows-x86_64`,
+ /// … The job's `name:` line also mentions `matrix.suffix`, but not in the
+ /// `suffix: value` form this looks for.
+ fn matrix_suffixes() -> Vec {
+ RELEASE_WORKFLOW
+ .lines()
+ .filter_map(|line| line.trim().strip_prefix("suffix:"))
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty())
+ .collect()
+ }
+
+ /// Splits a matrix suffix into the `(os, arch)` the updater matches on, plus the
+ /// archive container that platform ships.
+ fn split_suffix(suffix: &str) -> (&str, &str, &str) {
+ let (os, arch) = suffix
+ .rsplit_once('-')
+ .unwrap_or_else(|| panic!("matrix suffix {suffix:?} is not '-'"));
+ let ext = if os == "windows" { "zip" } else { "tar.gz" };
+ (os, arch, ext)
+ }
+
+ /// The archive filenames the Package step creates: `tar -czf "NAME"` on unix,
+ /// `7z a "NAME"` on Windows.
+ fn archive_templates() -> Vec<&'static str> {
+ RELEASE_WORKFLOW
+ .lines()
+ .filter_map(|line| {
+ first_quoted_after(line, "tar -czf ").or_else(|| first_quoted_after(line, "7z a "))
+ })
+ .collect()
+ }
+
+ /// The archive filename the workflow builds for `suffix`, read out of the
+ /// Package step's `case`.
+ ///
+ /// Both templates sit in the same `case` statement, but only one arm runs per
+ /// platform: `$BIN_NAME` ends in `.exe` exactly when the matrix suffix is a
+ /// `windows-*` one (release.yml:73-75), and that arm packages a zip. Pairing
+ /// every template with every suffix would assert against asset names the
+ /// workflow never publishes.
+ fn archive_for_suffix(suffix: &str) -> String {
+ let (_, _, ext) = split_suffix(suffix);
+ let template = archive_templates()
+ .into_iter()
+ .find(|template| template.ends_with(ext))
+ .unwrap_or_else(|| panic!("release.yml publishes no {ext} archive"));
+ expand(template, suffix)
+ }
+
+ /// The checksum filenames the Package step writes: the right-hand side of a
+ /// `printf … > "NAME.sha256"` redirect.
+ ///
+ /// Scoped to `.sha256` on purpose. An aggregate `checksums.txt` written by the
+ /// workflow would be the Go manifest, which [`is_checksum_for`] already accepts
+ /// unconditionally; what has to be tied to the workflow is the per-asset sidecar,
+ /// because that is the name the two halves used to disagree about.
+ fn sidecar_templates() -> Vec<&'static str> {
+ RELEASE_WORKFLOW
+ .lines()
+ .filter_map(|line| line.rsplit_once('>').map(|(_, rhs)| rhs.trim()))
+ .filter(|rhs| rhs.starts_with('"') && rhs.ends_with(".sha256\""))
+ .map(|rhs| &rhs[1..rhs.len() - 1])
+ .collect()
+ }
+
+ /// The heart of the contract: for every (platform, archive) pair the workflow
+ /// publishes, the sidecar the workflow will emit must be one the updater
+ /// accepts — and every platform must actually be covered.
+ ///
+ /// This is the test that fails if either side moves alone. Narrow
+ /// [`is_checksum_for`] back to the extension-retained spelling and it fails on
+ /// the stripped form the workflow writes; rename an asset in the workflow and
+ /// it fails on the coverage assertion.
+ #[test]
+ fn every_published_asset_has_a_sidecar_the_updater_accepts() {
+ let suffixes = matrix_suffixes();
+ let archives = archive_templates();
+ let sidecars = sidecar_templates();
+
+ // A parser that silently matches nothing would make every assertion below
+ // vacuous and the test permanently green. Refuse to run in that state.
+ assert!(
+ !suffixes.is_empty(),
+ "parsed no matrix suffixes out of release.yml — the parser is broken"
+ );
+ assert!(
+ !archives.is_empty(),
+ "parsed no archive names out of release.yml — the parser is broken"
+ );
+ assert!(
+ !sidecars.is_empty(),
+ "parsed no `printf … > \"….sha256\"` lines out of release.yml — the parser \
+ is broken"
+ );
+
+ for suffix in &suffixes {
+ let sidecars: Vec = sidecars.iter().map(|name| expand(name, suffix)).collect();
+ let archive = archive_for_suffix(suffix);
+ assert!(
+ sidecars
+ .iter()
+ .any(|sidecar| is_checksum_for(&archive, sidecar)),
+ "release.yml publishes `{archive}` and writes {sidecars:?}, none of which \
+ is_checksum_for accepts for it"
+ );
+ }
+ }
+
+ /// The other half of the contract, and the one the workflow cannot check for
+ /// itself: the updater has to be able to FIND each asset. A sidecar name is
+ /// useless if the archive selector rejects the archive next to it.
+ #[test]
+ fn the_updater_can_select_every_archive_the_workflow_publishes() {
+ for suffix in matrix_suffixes() {
+ let (os, arch, ext) = split_suffix(&suffix);
+ let name = archive_for_suffix(&suffix);
+ assert!(
+ matches_asset(&name, os, arch, ext),
+ "release.yml publishes `{name}` for {os}/{arch}, which matches_asset \
+ rejects — `treehouse update` would never find it"
+ );
+ }
+ }
+
+ /// A sidecar that passed [`matches_asset`] would be downloaded *as* the binary
+ /// and unpacked as one, so the archive selector has to reject every name the
+ /// workflow hands it.
+ ///
+ /// Checked per platform rather than only for the running one: this is the same
+ /// string-vs-string comparison on every machine, and a Linux CI run is the only
+ /// place the Windows asset's spelling gets exercised here.
+ #[test]
+ fn no_sidecar_the_workflow_writes_can_be_mistaken_for_an_archive() {
+ for suffix in matrix_suffixes() {
+ let (os, arch, ext) = split_suffix(&suffix);
+ for template in sidecar_templates() {
+ let name = expand(template, &suffix);
+ assert!(
+ !matches_asset(&name, os, arch, ext),
+ "sidecar `{name}` matches the {os}/{arch} archive selector"
+ );
+ }
+ }
+ }
+
+ /// The specific mismatch this module was written for, spelled out so a future
+ /// reader does not have to reconstruct it from a diff: the workflow drops the
+ /// container extension, the updater used to keep it, and no release had both.
+ #[test]
+ fn is_checksum_for_accepts_both_sidecar_spellings() {
+ let archive = "treehouse-v0.2.0-macos-aarch64.tar.gz";
+ // What release.yml's Package step writes for the above archive.
+ assert!(is_checksum_for(
+ archive,
+ "treehouse-v0.2.0-macos-aarch64.sha256"
+ ));
+ // The conventional spelling, kept working for hand-uploaded releases.
+ assert!(is_checksum_for(
+ archive,
+ "treehouse-v0.2.0-macos-aarch64.tar.gz.sha256"
+ ));
+ // Go's aggregate manifest.
+ assert!(is_checksum_for(archive, AGGREGATE_CHECKSUM_FILE));
+ // The Windows container behaves the same way.
+ assert!(is_checksum_for(
+ "treehouse-v0.2.0-windows-x86_64.zip",
+ "treehouse-v0.2.0-windows-x86_64.sha256"
+ ));
+
+ // Fail closed on everything else: another platform's sidecar, the archive
+ // itself, a near-miss extension, and an empty name. A wrong checksum file
+ // that "looks close enough" would either fail the update on a valid release
+ // or — worse — verify against the wrong hash.
+ assert!(!is_checksum_for(
+ archive,
+ "treehouse-v0.2.0-linux-x86_64.sha256"
+ ));
+ assert!(!is_checksum_for(archive, archive));
+ assert!(!is_checksum_for(
+ archive,
+ "treehouse-v0.2.0-macos-aarch64.sha256.txt"
+ ));
+ assert!(!is_checksum_for(archive, ""));
+ }
+
+ /// Ordering is part of the contract: a release carrying both spellings must
+ /// resolve the same way on every run rather than in API order.
+ #[test]
+ fn the_exact_spelling_is_preferred_over_the_stripped_one() {
+ let names = checksum_sidecar_names("treehouse-v0.2.0-macos-aarch64.tar.gz");
+ assert_eq!(
+ names,
+ vec![
+ "treehouse-v0.2.0-macos-aarch64.tar.gz.sha256",
+ "treehouse-v0.2.0-macos-aarch64.sha256",
+ ]
+ );
+ }
+
+ /// `strip_archive_ext` must not strip a bare extension down to nothing, which
+ /// would make `.sha256` a match for every asset.
+ #[test]
+ fn strip_archive_ext_refuses_a_name_that_is_only_an_extension() {
+ assert_eq!(strip_archive_ext(".sha256"), None);
+ assert_eq!(strip_archive_ext(".gz"), None);
+ assert_eq!(strip_archive_ext("treehouse.tar.gz"), Some("treehouse"));
+ assert_eq!(strip_archive_ext("treehouse.zip"), Some("treehouse"));
+ assert_eq!(strip_archive_ext("treehouse"), None);
+ }
+
+ /// A `file://` URL for `path`, spelled the way curl and JSON both want it.
+ ///
+ /// `Path::display` renders `\` as the separator on Windows, and a backslash is
+ /// not a legal JSON escape - a body built by interpolating a Windows path does
+ /// not parse at all, so the fetch fails before the contract below is ever
+ /// checked. The same string is handed to curl, which wants
+ /// `file://C:/dir/name`, not `file://C:\dir\name`. Forward slashes are what
+ /// `file://` means on every platform, so normalize once here and build every
+ /// URL in this test from it - including the value the final assertion compares
+ /// against, so the two cannot end up disagreeing about separators either.
+ fn file_url(path: &Path) -> String {
+ format!("file://{}", path.display().to_string().replace('\\', "/"))
+ }
+
+ /// End-to-end over the names the workflow really publishes: the release JSON is
+ /// built from `expand`ed workflow templates, so this exercises the whole
+ /// lookup — archive selection, sidecar selection, and the `CheckResult` the CLI
+ /// hands to `apply` — with no name written by hand.
+ #[test]
+ fn check_latest_result_finds_the_workflows_own_sidecar_name() {
+ let suffix = format!(
+ "{}-{}",
+ if cfg!(target_os = "macos") {
+ "macos"
+ } else {
+ std::env::consts::OS
+ },
+ std::env::consts::ARCH
+ );
+ let asset = archive_for_suffix(&suffix);
+ let sidecar = expand(
+ sidecar_templates()
+ .first()
+ .expect("release.yml must write a per-asset sidecar"),
+ &suffix,
+ );
+
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::write(dir.path().join(&asset), b"archive bytes").unwrap();
+ // The sidecar's CONTENTS are also the workflow's: `printf '%s %s\n'
+ // ` — the full archive name, extension included.
+ std::fs::write(
+ dir.path().join(&sidecar),
+ format!("{} {asset}\n", "a".repeat(64)),
+ )
+ .unwrap();
+ let body = format!(
+ r#"{{"tag_name":"{SAMPLE_TAG}","assets":[
+ {{"name":"{asset}","browser_download_url":"{archive}"}},
+ {{"name":"{sidecar}","browser_download_url":"{sidecar_path}"}}]}}"#,
+ archive = file_url(&dir.path().join(&asset)),
+ sidecar_path = file_url(&dir.path().join(&sidecar)),
+ );
+ let api = dir.path().join("latest.json");
+ std::fs::write(&api, body).unwrap();
+
+ let result = check_latest_result(&file_url(&api), "0.0.1", false)
+ .expect("the release fixture must resolve");
+
+ assert_eq!(result.asset_name.as_deref(), Some(asset.as_str()));
+ assert_eq!(
+ result.checksum_url.as_deref(),
+ Some(file_url(&dir.path().join(&sidecar)).as_str()),
+ "`treehouse update` needs a checksum URL; the workflow's stripped sidecar \
+ name must resolve to one"
+ );
+ assert!(result.update_available, "{SAMPLE_TAG} is newer than 0.0.1");
+ }
+}
diff --git a/crates/treehouse-core/src/vcs/jj.rs b/crates/treehouse-core/src/vcs/jj.rs
new file mode 100644
index 0000000..f9640e7
--- /dev/null
+++ b/crates/treehouse-core/src/vcs/jj.rs
@@ -0,0 +1,2206 @@
+//! The jj (Jujutsu) backend — Go `internal/vcs/jjvcs`.
+//!
+//! # jj is not git, and this does not pretend otherwise
+//!
+//! The mapping this file implements, verbatim from the Go package doc:
+//!
+//! | git backend | jj backend |
+//! |------------------------|---------------------------------------------------------|
+//! | worktree | jj workspace (`jj workspace add` / `forget`) |
+//! | detached HEAD | nothing — jj working copies are anonymous commits |
+//! | dirty | the working-copy commit `@` is non-empty or described |
+//! | `reset --hard` | `jj abandon -r @` + `jj new [` (recoverable via `jj op restore`) |
+//! | `merge-base --is-ancestor` | the revset `@- & ~::][` is empty |
+//!
+//! Three consequences run through every method below:
+//!
+//! * **A jj workspace is not a git worktree and has no `.git` entry.** Pooled
+//! secondary workspaces are `.jj`-only trees. Nothing here may resolve a
+//! workspace's git dir by walking UP — in a COLOCATED repository that walk
+//! lands on the user's own working tree. See [`require_jj_workspace`].
+//! * **A bookmark is mutable** where a branch ref is not. The reset target is
+//! therefore resolved to a commit id ([`resolve_reset_ref`]) before anything
+//! destructive runs, exactly as the git backend pins `reset_ref`, and the
+//! pair travels together in a [`ResetGuard`].
+//! * **jj is lock-free.** `jj` snapshots the operation log and always commits,
+//! so no `flock` can exclude a parallel `jj commit`. The guarded reset is
+//! therefore a SINGLE `jj rebase` whose revset pins `@` by commit id: a
+//! concurrent move of `@` empties the revset, the rebase becomes a no-op, and
+//! the parked-on-target re-check that follows turns that into a REFUSAL
+//! rather than a silent success. See [`JjBackend::reset_worktree_to_ref`].
+//!
+//! # Testability
+//!
+//! `jj` is not installed on every machine that builds this crate, and a backend
+//! whose commands cannot be faked is a backend whose logic is untestable.
+//! Every invocation goes through [`JjRunner`], so the argv construction, the
+//! marker/flavor resolution, the fail-closed refusals and the whole
+//! clean-vs-dirty reset protocol are unit-tested against a scripted runner.
+//! What that does NOT cover is jj's own output format; see the coverage note
+//! on [`JjBackend::discover`].
+//!
+//! # Opt-in
+//!
+//! jj stays strictly opt-in (`TREEHOUSE_VCS` / `treehouse.toml` `vcs` / user
+//! config, in that precedence order) and only where a `.jj` directory actually
+//! exists. That precedence lives in the config layer. REGISTERING this backend
+//! is not opting into it: registration only makes a `.jj` marker answerable, and
+//! which backend CREATES a worktree is the configured backend's business.
+
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use sha2::{Digest, Sha256};
+
+use crate::git::{GitBackend, GitError, GitErrorKind, GitRepo, ResetGuard};
+
+use super::{BACKEND_GIT, BACKEND_JJ};
+
+/// jj's own trunk preference order (Go `defaultBranchCandidates`).
+const DEFAULT_BRANCH_CANDIDATES: [&str; 3] = ["main", "master", "trunk"];
+
+/// Template printing the commit id followed by a newline. jj's template language
+/// uses Rust-style string escapes, so the bytes handed to jj contain a literal
+/// backslash-n — a Rust `"\n"` here would hand jj a real newline inside a string
+/// literal and fail to parse.
+const COMMIT_ID_TEMPLATE: &str = r#"commit_id ++ "\n""#;
+
+/// Template printing `clean` or `dirty` for the working-copy commit.
+///
+/// `empty` is jj's emptiness predicate and `description` the commit message;
+/// a workspace is dirty when either is set. Running any jj command snapshots
+/// the working copy first, so filesystem changes are always reflected here.
+const DIRTY_TEMPLATE: &str = r#"if(empty && description == "", "clean", "dirty")"#;
+
+// ─── The command seam ─────────────────────────────────────────────────────────
+
+/// Runs `jj --color never ]` in a directory and returns trimmed stdout.
+///
+/// Split out so the backend's LOGIC is testable without jj installed. The argv
+/// handed here is the complete command line, `--color never` included: a runner
+/// must not add flags of its own, or the argv a test asserts is not the argv
+/// jj would have seen.
+pub(crate) trait JjRunner: Send + Sync {
+ fn run(&self, cwd: &Path, args: &[&str]) -> Result;
+}
+
+/// Runs the real `jj` binary, found by `JJ_BIN` → `PATH`.
+///
+/// `bin` is `None` when discovery found nothing. That is NOT a reason to refuse
+/// construction: the backend is still the right answer for a `.jj` marker, and
+/// every command then fails with the true cause ("jj binary not found") instead
+/// of the seam reporting "no backend is registered for jj", which sends the
+/// operator looking for a configuration problem that does not exist.
+struct RealJjRunner {
+ bin: Option,
+}
+
+impl RealJjRunner {
+ fn discover() -> Self {
+ Self {
+ bin: find_jj_binary(),
+ }
+ }
+}
+
+impl JjRunner for RealJjRunner {
+ fn run(&self, cwd: &Path, args: &[&str]) -> Result {
+ let command = format!("jj {}", args.join(" "));
+ let Some(bin) = &self.bin else {
+ return Err(GitError::new(
+ command,
+ "jj binary not found on PATH (set JJ_BIN)",
+ GitErrorKind::NotFound,
+ ));
+ };
+ let output = std::process::Command::new(bin)
+ .args(args)
+ .current_dir(cwd)
+ .output()
+ .map_err(|e| GitError::new(command.clone(), e.to_string(), GitErrorKind::Other))?;
+ if !output.status.success() {
+ return Err(GitError::new(
+ command,
+ String::from_utf8_lossy(&output.stderr).trim().to_string(),
+ GitErrorKind::Other,
+ ));
+ }
+ Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
+ }
+}
+
+/// `JJ_BIN` env override → `PATH`.
+fn find_jj_binary() -> Option {
+ if let Some(v) = std::env::var_os("JJ_BIN") {
+ let p = PathBuf::from(&v);
+ if p.is_file() {
+ return Some(p);
+ }
+ }
+ let path = std::env::var_os("PATH")?;
+ let exe = if cfg!(windows) { "jj.exe" } else { "jj" };
+ for dir in std::env::split_paths(&path) {
+ let candidate = dir.join(exe);
+ if candidate.is_file() {
+ return Some(candidate);
+ }
+ }
+ None
+}
+
+// ─── Pure helpers ─────────────────────────────────────────────────────────────
+
+/// The absolute form of `path` (Go `filepath.Abs`).
+fn absolute(path: &Path) -> Result {
+ if path.is_absolute() {
+ return Ok(path.to_path_buf());
+ }
+ let cwd = std::env::current_dir().map_err(|e| {
+ GitError::new(
+ "resolve absolute path",
+ format!("reading the working directory for {}: {e}", path.display()),
+ GitErrorKind::Other,
+ )
+ })?;
+ Ok(cwd.join(path))
+}
+
+/// Resolves symlinks, falling back to the input (Go `canonicalize`).
+///
+/// Root resolution must return ONE canonical form no matter which route
+/// produced the path: the pool identity is derived from the path string, so a
+/// repository reached through a symlink (macOS `/tmp`) would otherwise fork
+/// into a real pool and a phantom one.
+fn canonicalize(path: &Path) -> PathBuf {
+ std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
+}
+
+/// The bytes of a path, without a UTF-8 round trip.
+///
+/// The workspace name is a digest of the path, so a lossy conversion would give
+/// two different paths the same name on a filesystem that permits non-UTF-8.
+#[cfg(unix)]
+fn path_bytes(path: &Path) -> Vec {
+ use std::os::unix::ffi::OsStrExt;
+ path.as_os_str().as_bytes().to_vec()
+}
+
+#[cfg(not(unix))]
+fn path_bytes(path: &Path) -> Vec {
+ path.to_string_lossy().into_owned().into_bytes()
+}
+
+/// A stable jj workspace name derived from a worktree path (Go
+/// `workspaceNameFor`): `th-` followed by 16 bytes of sha256 of the ABSOLUTE
+/// path.
+///
+/// The name is path-keyed, which is what makes the self-healing forget in
+/// [`JjBackend::worktree_add`] safe: jj repositories do not record workspace
+/// directory paths, so a registration under this name can only have been left
+/// by a previous worktree at THIS path. A live workspace at another path holds a
+/// different digest and can never be deregistered by mistake.
+pub fn workspace_name_for(path: &Path) -> String {
+ let abs = absolute(path).unwrap_or_else(|_| path.to_path_buf());
+ let digest = Sha256::digest(path_bytes(&abs));
+ let mut name = String::from("th-");
+ for b in &digest[..16] {
+ name.push_str(&format!("{b:02x}"));
+ }
+ name
+}
+
+/// jj's commit id: 40 (git backend) or 64 (native backend) lowercase hex.
+///
+/// Stricter than the seam's git-side `is_commit_id`, which accepts 1..=64 hex
+/// characters because git ids are always 40. Matching jj's real widths matters
+/// because the value is interpolated into a revset
+/// (`@ & commit_id("...")`), where a truncated or padded value is not a commit
+/// id at all but a silent no-match.
+fn is_commit_id(s: &str) -> bool {
+ (s.len() == 40 || s.len() == 64)
+ && s.bytes()
+ .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
+}
+
+/// Whether `s` may be embedded in a revset expression.
+///
+/// jj revsets are a LANGUAGE, not a path grammar: `()`, `&`, `|`, `~`, `:`,
+/// `"`, `\` and whitespace are operators, and a branch name reaching a revset
+/// unquoted is revset injection — `foo" | ~root` would rewrite the very working
+/// copy this backend exists to protect. Go interpolates branch names raw; this
+/// port does not.
+///
+/// The allowlist keeps every name jj itself accepts in practice (letters,
+/// digits, `.`, `_`, `-`, `/`, and the `@` that separates a bookmark from its
+/// remote-tracking form, as in `main@origin`) and rejects everything else. The
+/// first character must be alphanumeric so a name can never OPEN with a revset
+/// symbol — notably so a bookmark can never be named exactly `@`, which would
+/// make `present(@)` resolve to the working-copy commit instead of a bookmark.
+///
+/// An exotic-but-legal jj bookmark name is refused with a clear error rather
+/// than silently rewritten. That is the fail-closed direction.
+fn revset_atom(s: &str) -> bool {
+ !s.is_empty()
+ && s.len() <= 255
+ && s.as_bytes()[0].is_ascii_alphanumeric()
+ && s.bytes()
+ .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/' | b'@'))
+}
+
+/// Reads the first line of a `commit_id ++ "\n"` template, erroring when empty.
+///
+/// Empty means the revset matched nothing. Reporting THAT — rather than handing
+/// the empty string on as a ref — is what turns "the bookmark does not resolve"
+/// into an error before a destructive command is built.
+fn first_commit_id(out: &str, what: &str) -> Result {
+ let id = out.lines().next().unwrap_or("").trim();
+ if id.is_empty() {
+ return Err(GitError::new(
+ "jj log",
+ format!("cannot resolve {what}"),
+ GitErrorKind::MergeRefUnresolvable,
+ ));
+ }
+ Ok(id.to_string())
+}
+
+/// Rewrites a secondary workspace's `.jj/repo` store pointer to an absolute,
+/// symlink-canonicalized path (Go `makeRepoPointerAbsolute`).
+///
+/// jj writes a RELATIVE pointer, which breaks when the pool directory and the
+/// repository do not move together — and the pool usually lives under
+/// `~/.treehouse`, far from the repo. Canonicalizing on write mirrors git,
+/// which stores physical paths in a worktree's `.git` gitdir pointer, so every
+/// later read agrees with `jj workspace root` (physical) and one repository
+/// resolves to ONE pool identity.
+///
+/// A MAIN workspace's `.jj/repo` is the store itself, not a pointer; there is
+/// nothing to rewrite and it is left untouched.
+fn make_repo_pointer_absolute(ws_root: &Path) -> Result<(), GitError> {
+ let repo_path = ws_root.join(".jj").join("repo");
+ let meta = std::fs::metadata(&repo_path).map_err(|e| {
+ GitError::new(
+ "make .jj/repo pointer absolute",
+ format!("inspecting {}: {e}", repo_path.display()),
+ GitErrorKind::Other,
+ )
+ })?;
+ if meta.is_dir() {
+ return Ok(());
+ }
+ let contents = std::fs::read_to_string(&repo_path).map_err(|e| {
+ GitError::new(
+ "make .jj/repo pointer absolute",
+ format!("reading {}: {e}", repo_path.display()),
+ GitErrorKind::Other,
+ )
+ })?;
+ let store = contents.trim();
+ let store_path = PathBuf::from(store);
+ if store_path.is_absolute() {
+ return Ok(());
+ }
+ let abs = canonicalize(&ws_root.join(".jj").join(store));
+ std::fs::write(&repo_path, abs.to_string_lossy().as_bytes()).map_err(|e| {
+ GitError::new(
+ "make .jj/repo pointer absolute",
+ format!("writing {}: {e}", repo_path.display()),
+ GitErrorKind::Other,
+ )
+ })
+}
+
+/// Resolves a workspace root to the MAIN repository root by reading the
+/// `.jj/repo` store pointer (Go `MainRootFromWorkspaceRoot`).
+///
+/// PURE FILE INSPECTION — no jj invocation — so it works for locating where a
+/// repository's configuration lives before any backend has been selected, and
+/// so it is unit-testable on a machine without jj.
+///
+/// - `.jj/repo` is a DIRECTORY: this is the main workspace; its own root is the
+/// answer.
+/// - `.jj/repo` is a FILE holding the store path (possibly relative to the
+/// workspace's `.jj` directory). The store lives at `/.jj/repo`, so the
+/// main root is two levels up from the store.
+///
+/// Canonicalized on read as well as on write, so a pointer written before
+/// canonicalization existed still resolves to the same pool identity.
+pub fn main_root_from_workspace_root(ws_root: &Path) -> Result {
+ let repo_path = ws_root.join(".jj").join("repo");
+ let meta = std::fs::metadata(&repo_path).map_err(|e| {
+ GitError::new(
+ "read .jj/repo pointer",
+ format!("cannot inspect {}: {e}", repo_path.display()),
+ GitErrorKind::Other,
+ )
+ })?;
+ if meta.is_dir() {
+ return Ok(canonicalize(ws_root));
+ }
+ let contents = std::fs::read_to_string(&repo_path).map_err(|e| {
+ GitError::new(
+ "read .jj/repo pointer",
+ format!("cannot read {}: {e}", repo_path.display()),
+ GitErrorKind::Other,
+ )
+ })?;
+ let store = contents.trim();
+ let store_path = if Path::new(store).is_absolute() {
+ PathBuf::from(store)
+ } else {
+ ws_root.join(".jj").join(store)
+ };
+ // store_path is /.jj/repo; the main root is two levels up.
+ let main_root = store_path
+ .parent()
+ .and_then(|p| p.parent())
+ .map(PathBuf::from)
+ .ok_or_else(|| {
+ GitError::new(
+ "read .jj/repo pointer",
+ format!("{} does not name a repository store", repo_path.display()),
+ GitErrorKind::Other,
+ )
+ })?;
+ Ok(canonicalize(&main_root))
+}
+
+/// Whether `detail` reads like a failure to reach or use the origin remote, in
+/// jj's vocabulary (Go `jjvcs.IsOriginAccessError`).
+///
+/// jj shells out to git for network transport, so its errors WRAP git's
+/// ("External git program failed" around "unable to access" / "Could not
+/// resolve host"); a local-path remote fails with jj's own "Could not find
+/// repository at". Patterns captured from real `jj git fetch` runs against
+/// unreachable remotes.
+///
+/// Classification is by CONTENT, never by which backend produced the error: the
+/// error already happened, and each backend owns its own vocabulary. Callers
+/// use it to emit a category-tagged SKIP, never a deletion.
+pub fn is_origin_access_error(detail: &str) -> bool {
+ detail.contains("External git program failed")
+ || detail.contains("unable to access")
+ || detail.contains("Could not resolve host")
+ || detail.contains("Could not find repository at")
+}
+
+// ─── The backend ──────────────────────────────────────────────────────────────
+
+/// [`GitBackend`] for Jujutsu repositories (Go `jjvcs.Backend`).
+///
+/// Every jj invocation goes through `runner`, so the backend can be exercised
+/// end-to-end against a scripted runner on a machine with no `jj` binary.
+pub struct JjBackend {
+ runner: Arc,
+}
+
+impl std::fmt::Debug for JjBackend {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("JjBackend").finish_non_exhaustive()
+ }
+}
+
+impl JjBackend {
+ /// Builds the backend against the discovered `jj` binary.
+ ///
+ /// Infallible by design: a missing `jj` must not remove the backend from the
+ /// registry, because a `.jj` marker with no registered backend reports "no
+ /// backend is registered for jj" — a configuration-shaped error for what is
+ /// a missing-binary problem. Instead every command fails with
+ /// `GitErrorKind::NotFound` and the true cause.
+ ///
+ /// # Coverage
+ ///
+ /// `jj` is not installed on every machine, so this backend is covered by
+ /// UNIT tests against a fake runner: argument construction, marker and
+ /// flavor resolution, the fail-closed refusals, and the clean/dirty reset
+ /// protocol are all exercised. **No end-to-end run against a real jj
+ /// repository is performed here**, and jj's own output formats are therefore
+ /// unverified by this crate's test suite.
+ pub fn discover() -> Self {
+ Self {
+ runner: Arc::new(RealJjRunner::discover()),
+ }
+ }
+
+ /// Builds the backend against a known `jj` binary.
+ pub fn with_bin(bin: PathBuf) -> Self {
+ Self {
+ runner: Arc::new(RealJjRunner { bin: Some(bin) }),
+ }
+ }
+
+ /// Builds the backend against a scripted [`JjRunner`].
+ ///
+ /// Exists so the seam's own tests can assert that a `.jj` selection is
+ /// dispatched to jj — the one claim that cannot be checked on a machine
+ /// without a jj binary, and the one a raw git fatal used to hide.
+ #[cfg(test)]
+ pub(crate) fn with_runner(runner: Arc) -> Self {
+ Self { runner }
+ }
+
+ /// Runs `jj --color never ` in `cwd`, returning trimmed stdout.
+ ///
+ /// `--color never` is prepended HERE rather than in each runner so the argv a
+ /// test asserts is byte-for-byte the argv jj receives. Without it a jj
+ /// invoked from a TTY emits escape sequences into every template and every
+ /// commit id this backend parses.
+ fn out(&self, cwd: &Path, args: &[&str]) -> Result {
+ let mut full: Vec<&str> = Vec::with_capacity(args.len() + 2);
+ full.push("--color");
+ full.push("never");
+ full.extend_from_slice(args);
+ self.runner.run(cwd, &full)
+ }
+
+ /// Whether the revset resolves to at least one commit (Go `revsetNonEmpty`).
+ ///
+ /// A jj failure is `false`, never an error: every caller uses this as a
+ /// yes/no test whose "no" is already a valid answer, and an unreadable
+ /// revset must not abort a decision the caller can safely make the other
+ /// way.
+ fn revset_non_empty(&self, dir: &Path, revset: &str) -> bool {
+ self.out(
+ dir,
+ &["log", "-r", revset, "--no-graph", "-T", COMMIT_ID_TEMPLATE],
+ )
+ .map(|o| !o.is_empty())
+ .unwrap_or(false)
+ }
+
+ /// Whether `a` is an ancestor of (or equal to) `b` (Go `isAncestor`).
+ fn is_ancestor(&self, dir: &Path, a: &str, b: &str) -> bool {
+ self.revset_non_empty(dir, &format!("({a}) & ::({b})"))
+ }
+
+ /// The working-copy commit id of `worktree` (Go `worktreeHead`).
+ fn worktree_head(&self, worktree: &Path) -> Result {
+ let out = self.out(
+ worktree,
+ &["log", "-r", "@", "--no-graph", "-T", COMMIT_ID_TEMPLATE],
+ )?;
+ first_commit_id(&out, "the working-copy commit")
+ }
+
+ /// Resolves `branch` to the immutable commit a reset must target (Go
+ /// `resolveResetRef`).
+ ///
+ /// The bookmark is pinned to a commit id HERE, before anything destructive.
+ /// jj bookmarks are mutable: a reset built from the NAME would target
+ /// whatever the bookmark had become by the time the command ran, which is
+ /// exactly what the seam's `ResetGuard` pair exists to prevent on the git
+ /// side.
+ fn resolve_reset_ref(&self, worktree: &Path, branch: &str) -> Result {
+ // A sibling workspace may have moved the repository since this one was
+ // last used; recover first so the commands below see current state.
+ // Best-effort: a stale repo is not itself a failure here.
+ let _ = self.out(worktree, &["workspace", "update-stale"]);
+ let reference = self.branch_ref(&JjRepo::at(worktree), branch);
+ if reference.is_empty() {
+ return Err(GitError::new(
+ "jj workspace reset",
+ format!(
+ "cannot resolve bookmark {branch:?}: the name cannot be expressed as a jj revset"
+ ),
+ GitErrorKind::MergeRefUnresolvable,
+ ));
+ }
+ let out = self.out(
+ worktree,
+ &[
+ "log",
+ "-r",
+ &reference,
+ "--no-graph",
+ "-T",
+ COMMIT_ID_TEMPLATE,
+ ],
+ )?;
+ first_commit_id(&out, &format!("bookmark {branch:?} to a commit"))
+ }
+
+ /// Whether the workspace sits directly on `reference` with a clean working
+ /// copy (Go `parkedOnRef`).
+ ///
+ /// This is the post-condition of `jj rebase -d [`: the working copy is
+ /// `@`, its parent is `@-`, and `@-` must be the reset target. A dirty
+ /// workspace is never "parked" — its parent could match while uncommitted
+ /// changes sit in `@`.
+ fn parked_on_ref(&self, worktree: &Path, reference: &str) -> Result] {
+ if self.is_dirty(worktree)? {
+ return Ok(false);
+ }
+ let out = self.out(
+ worktree,
+ &["log", "-r", "@-", "--no-graph", "-T", COMMIT_ID_TEMPLATE],
+ )?;
+ let parent = out.lines().next().unwrap_or("").trim();
+ Ok(!parent.is_empty() && parent == reference)
+ }
+
+ /// The `@` pinned to the commit the safety check observed (Go `revset`).
+ ///
+ /// Pinned BY COMMIT ID rather than positionally, which is what makes the
+ /// reset safe without a lock: jj always commits, so a concurrent change of
+ /// `@` leaves this revset EMPTY rather than pointing at whatever `@` has
+ /// become. The next command then either does nothing or is caught by the
+ /// post-condition check.
+ fn pinned_revset(expected_head: &str) -> String {
+ format!("@ & commit_id(\"{expected_head}\")")
+ }
+
+ /// Refuses a path that is not a jj workspace (upstream #110, the reason
+ /// jj gets its own fail-closed marker rule at all).
+ ///
+ /// A jj workspace has NO `.git` marker. Any git-shaped fallback therefore
+ /// resolves the path by walking UP — and inside a COLOCATED repository that
+ /// walk lands on the user's own working tree, where a rewrite is
+ /// irreversible data loss. Go hardens every destructive entry point in
+ /// `destructiveBackendForWorktree` for exactly this; this is the same
+ /// guarantee stated in jj's own terms, checked before a single jj command
+ /// runs.
+ fn require_jj_workspace(path: &Path) -> Result<(), GitError> {
+ match crate::vcs::worktree_backend_name(path)? {
+ Some(BACKEND_JJ) => Ok(()),
+ Some(BACKEND_GIT) => Err(GitError::new(
+ "jj workspace",
+ format!(
+ "refusing to run jj in {}: it is a git worktree, not a jj workspace",
+ path.display()
+ ),
+ GitErrorKind::Other,
+ )),
+ Some(other) => Err(GitError::new(
+ "jj workspace",
+ format!(
+ "refusing to run jj in {}: it is a {other} worktree",
+ path.display()
+ ),
+ GitErrorKind::Other,
+ )),
+ None => Err(GitError::new(
+ "jj workspace",
+ format!(
+ "refusing to modify {}: it holds no .git or .jj marker",
+ path.display()
+ ),
+ GitErrorKind::Other,
+ )),
+ }
+ }
+
+ /// The clean-workspace half of the guarded reset (Go `ResetWorktreeToRef`'s
+ /// `else` branch).
+ ///
+ /// `jj rebase -d [ -r ]` is ONE command, so it loads ONE
+ /// snapshot: a concurrent move of `@` empties the revset and the rebase
+ /// becomes a no-op rather than a wrong rewrite. The post-condition is then
+ /// checked explicitly, because a no-op and a success are indistinguishable
+ /// in jj's exit status — and reporting "reset succeeded" for a workspace
+ /// that did not move is the failure this whole protocol exists to prevent.
+ fn reset_clean(
+ &self,
+ worktree: &Path,
+ reset_ref: &str,
+ expected_head: &str,
+ ) -> Result<(), GitError> {
+ let revset = Self::pinned_revset(expected_head);
+ if let Err(e) = self.out(worktree, &["rebase", "-d", reset_ref, "-r", &revset]) {
+ // A rebase onto the commit @ already sits on is a no-op jj may
+ // report as an error. That is only a SUCCESS if the workspace really
+ // is parked on the target and @ never moved.
+ if let Ok(true) = self.parked_on_ref(worktree, reset_ref)
+ && let Ok(head) = self.worktree_head(worktree)
+ && head == expected_head
+ {
+ return Ok(());
+ }
+ return Err(e);
+ }
+ if !self.parked_on_ref(worktree, reset_ref)? {
+ return Err(self::head_moved(expected_head));
+ }
+ Ok(())
+ }
+
+ /// The dirty-workspace half of the guarded reset (Go's abandon path).
+ ///
+ /// `jj abandon` drops the working-copy commit and `jj new [` recreates a
+ /// fresh empty one on the target. Unlike `reset --hard` this stays
+ /// recoverable through `jj op restore` — the abandoned work is out of view,
+ /// not destroyed. Every step is verified before the next runs, so a
+ /// concurrent commit that made the pinned revset non-empty again, or left
+ /// the workspace dirty or unmerged, stops the reset instead of being
+ /// discarded.
+ fn reset_dirty(
+ &self,
+ worktree: &Path,
+ reset_ref: &str,
+ expected_head: &str,
+ ) -> Result<(), GitError> {
+ let revset = Self::pinned_revset(expected_head);
+ self.out(worktree, &["abandon", "-r", &revset])?;
+ // `@` still matches the pinned commit: it moved, or never was what the
+ // safety check measured.
+ if self.revset_non_empty(worktree, &revset) {
+ return Err(head_moved(expected_head));
+ }
+ if self.is_dirty(worktree)? {
+ return Err(head_moved(expected_head));
+ }
+ // The workspace must be merged into the reset target, or `jj new` would
+ // strand unlanded work as the parent of a fresh commit.
+ if !self.is_head_merged_into_ref(worktree, reset_ref)? {
+ return Err(head_moved(expected_head));
+ }
+ self.out(worktree, &["new", reset_ref]).map(|_| ())
+ }
+}
+
+/// The "HEAD moved" refusal, worded once so a log reader can match it.
+fn head_moved(expected_head: &str) -> GitError {
+ GitError::new(
+ "jj workspace reset",
+ format!("worktree HEAD changed since safety check: was {expected_head}"),
+ GitErrorKind::Other,
+ )
+}
+
+/// The revset named by [`head_moved`]'s condition, when the cause is a revset
+/// that cannot be expressed — reported instead of a generic jj failure so the
+/// operator sees the input problem rather than jj's parse error.
+fn unexpressible_ref(what: &str) -> GitError {
+ GitError::new(
+ "jj revset",
+ format!("{what} cannot be expressed as a jj revset and was refused"),
+ GitErrorKind::MergeRefUnresolvable,
+ )
+}
+
+/// A [`GitRepo`] view of a jj workspace.
+struct JjRepo;
+
+impl JjRepo {
+ /// jj commands run in `dir`, which the trait reaches as `common_dir`.
+ ///
+ /// jj has no separate "common dir" — one store is shared by every workspace,
+ /// so any of them can answer a repository-wide question. The caller picks
+ /// which: the MAIN root for operations against the repository as a whole
+ /// (remote listing, workspace creation), or a workspace when the answer is
+ /// the same either way (bookmark probes during a reset).
+ fn at(dir: &Path) -> GitRepo {
+ GitRepo {
+ common_dir: dir.to_path_buf(),
+ worktree: None,
+ }
+ }
+}
+
+impl GitBackend for JjBackend {
+ fn name(&self) -> &'static str {
+ BACKEND_JJ
+ }
+
+ fn repo_root(&self, start: &Path) -> Result] {
+ // jj prints a physical path today, but its output form is not
+ // contractual; canonicalize so every root-resolution route agrees.
+ Ok(canonicalize(&PathBuf::from(
+ self.out(start, &["workspace", "root"])?,
+ )))
+ }
+
+ fn main_repo_root(&self, start: &Path) -> Result {
+ let ws_root = self.repo_root(start)?;
+ main_root_from_workspace_root(&ws_root)
+ }
+
+ fn default_branch(&self, repo: &GitRepo) -> Result {
+ let root = &repo.common_dir;
+ // Remote bookmarks first: a freshly cloned repository's local `main` can
+ // be stale or missing entirely.
+ if self.has_remote(repo, "origin") {
+ for name in DEFAULT_BRANCH_CANDIDATES {
+ if self.revset_non_empty(root, &format!("present({name}@origin)")) {
+ return Ok(name.to_string());
+ }
+ }
+ }
+ for name in DEFAULT_BRANCH_CANDIDATES {
+ if self.revset_non_empty(root, &format!("present({name})")) {
+ return Ok(name.to_string());
+ }
+ }
+ Err(GitError::new(
+ "jj default branch",
+ format!(
+ "cannot determine default bookmark: expected one of {} locally or on origin; try 'jj git fetch' or 'jj bookmark create main'",
+ DEFAULT_BRANCH_CANDIDATES.join(", ")
+ ),
+ GitErrorKind::DefaultBranchUnresolvable,
+ ))
+ }
+
+ fn has_remote(&self, repo: &GitRepo, name: &str) -> bool {
+ self.remotes(repo)
+ .is_some_and(|r| r.iter().any(|(n, _)| n == name))
+ }
+
+ fn remote_url(&self, repo: &GitRepo, name: &str) -> Option {
+ self.remotes(repo)?
+ .into_iter()
+ .find(|(n, _)| n == name)
+ .map(|(_, url)| url)
+ }
+
+ fn fetch(&self, repo: &GitRepo) -> Result<(), GitError> {
+ if !self.has_remote(repo, "origin") {
+ return Ok(());
+ }
+ self.out(&repo.common_dir, &["git", "fetch", "--remote", "origin"])
+ .map(|_| ())
+ .map_err(|e| {
+ // Prune turns this kind into a category-tagged SKIP; every other
+ // jj failure stays "other" so it is never mistaken for one.
+ let kind = if is_origin_access_error(&e.message) {
+ GitErrorKind::OriginUnreachable
+ } else {
+ e.kind
+ };
+ GitError::new(e.command, e.message, kind)
+ })
+ }
+
+ fn worktree_add(&self, repo: &GitRepo, path: &Path, branch: &str) -> Result<(), GitError> {
+ let abs = absolute(path)?;
+ let name = workspace_name_for(&abs);
+ let root = &repo.common_dir;
+
+ // Best effort: forgetting a name that does not exist is a no-op in jj.
+ // It is the prune equivalent for a RE-USED path — jj does not record
+ // workspace directories, so this is the only way a stale registration
+ // at this path can be cleared.
+ let _ = self.out(root, &["workspace", "forget", &name]);
+
+ let reference = self.branch_ref(repo, branch);
+ if reference.is_empty() {
+ return Err(unexpressible_ref(&format!("bookmark {branch:?}")));
+ }
+ self.out(
+ root,
+ &[
+ "workspace",
+ "add",
+ "--name",
+ &name,
+ "--revision",
+ &reference,
+ &abs.to_string_lossy(),
+ ],
+ )?;
+ make_repo_pointer_absolute(&abs)
+ }
+
+ fn worktree_remove(&self, repo: &GitRepo, path: &Path) -> Result<(), GitError> {
+ let abs = absolute(path)?;
+ // Refuse to delete a directory that exists but is not a jj workspace.
+ // Removing a git worktree here would delete its files while leaving the
+ // `.git/worktrees` registration stale. A path that is already gone is
+ // fine and still gets its stale registration forgotten below.
+ if abs.exists() {
+ let jj_dir = abs.join(".jj");
+ if !jj_dir.is_dir() {
+ return Err(GitError::new(
+ "jj workspace remove",
+ format!("refusing to remove {}: not a jj workspace", abs.display()),
+ GitErrorKind::Other,
+ ));
+ }
+ // A main workspace's `.jj/repo` IS the store, not a pointer file.
+ // Forgetting the digest-named registration would no-op and the
+ // recursive delete would destroy the entire repository.
+ if abs.join(".jj").join("repo").is_dir() {
+ return Err(GitError::new(
+ "jj workspace remove",
+ format!(
+ "refusing to remove {}: main jj workspace, not a pooled secondary workspace",
+ abs.display()
+ ),
+ GitErrorKind::Other,
+ ));
+ }
+ }
+ let _ = self.out(
+ &repo.common_dir,
+ &["workspace", "forget", &workspace_name_for(&abs)],
+ );
+ std::fs::remove_dir_all(&abs).map_err(|e| {
+ GitError::new(
+ "jj workspace remove",
+ format!("removing {}: {e}", abs.display()),
+ GitErrorKind::Other,
+ )
+ })
+ }
+
+ fn remove_clean_worktree(&self, repo: &GitRepo, path: &Path) -> Result<(), GitError> {
+ if self.is_dirty(path)? {
+ return Err(GitError::new(
+ "jj workspace remove",
+ format!("workspace at {} has local changes", path.display()),
+ GitErrorKind::Other,
+ ));
+ }
+ self.worktree_remove(repo, path)
+ }
+
+ fn create_branch(&self, worktree: &Path, branch: &str) -> Result<(), GitError> {
+ // jj bookmarks have different ownership semantics — several workspaces
+ // share one store and would all move together — so branch creation is
+ // deliberately Git-only, exactly as in Go's `vcs.CreateBranch`.
+ let _ = branch;
+ let found = crate::vcs::worktree_backend_name(worktree)?;
+ Err(GitError::new(
+ "create branch",
+ format!(
+ "cannot create branch in {}: branch creation requires a git worktree{}",
+ worktree.display(),
+ match found {
+ Some(name) => format!(", found {name}"),
+ None => ", worktree has no .git marker".to_string(),
+ }
+ ),
+ GitErrorKind::Other,
+ ))
+ }
+
+ fn local_branch_exists(&self, repo: &GitRepo, branch: &str) -> bool {
+ if !revset_atom(branch) {
+ return false;
+ }
+ self.revset_non_empty(&repo.common_dir, &format!("present({branch})"))
+ }
+
+ fn is_dirty(&self, worktree: &Path) -> Result {
+ let out = self.out(
+ worktree,
+ &["log", "-r", "@", "--no-graph", "-T", DIRTY_TEMPLATE],
+ )?;
+ Ok(out != "clean")
+ }
+
+ fn reset_worktree(&self, worktree: &Path, branch: &str) -> Result<(), GitError> {
+ Self::require_jj_workspace(worktree)?;
+ let reference = self.resolve_reset_ref(worktree, branch)?;
+ let head = self.worktree_head(worktree)?;
+ self.reset_worktree_to_ref(worktree, &reference, &head, false)
+ }
+
+ fn detach_worktree(&self, worktree: &Path) -> Result<(), GitError> {
+ // jj working copies are anonymous commits and never hold a bookmark the
+ // way a git worktree holds a branch, so there is nothing to release.
+ // The marker check still runs: the trait documents every rewrite
+ // operation as fail-closed, and a future jj version that does attach a
+ // bookmark must inherit that guarantee rather than quietly lose it
+ // behind a no-op.
+ Self::require_jj_workspace(worktree)
+ }
+
+ fn is_head_merged_into_ref(&self, worktree: &Path, reference: &str) -> Result {
+ if !revset_atom(reference) {
+ return Err(unexpressible_ref(&format!("reference {reference:?}")));
+ }
+ // Every PARENT of the working-copy commit is an ancestor of `reference`.
+ // `@` itself is excluded: a clean `@` is empty and a non-empty one is
+ // already reported by `is_dirty`. Ancestry is the only proof used, so a
+ // squash-merged head reads as UNMERGED — which fails safe, because the
+ // destructive callers then refuse to delete it.
+ Ok(self
+ .out(
+ worktree,
+ &[
+ "log",
+ "-r",
+ &format!("@- & ~::({reference})"),
+ "--no-graph",
+ "-T",
+ COMMIT_ID_TEMPLATE,
+ ],
+ )?
+ .is_empty())
+ }
+
+ fn default_branch_merge_ref(&self, repo: &GitRepo) -> Result {
+ let branch = self.default_branch(repo)?;
+ let root = &repo.common_dir;
+ let unavailable = |r: &str| {
+ GitError::new(
+ "jj merge ref",
+ format!("{r} is unavailable"),
+ GitErrorKind::MergeRefUnresolvable,
+ )
+ };
+ if self.has_remote(repo, "origin") {
+ let remote = format!("{branch}@origin");
+ if !self.revset_non_empty(root, &format!("present({remote})")) {
+ return Err(unavailable(&remote));
+ }
+ return Ok(remote);
+ }
+ if !self.revset_non_empty(root, &format!("present({branch})")) {
+ return Err(unavailable(&branch));
+ }
+ Ok(branch)
+ }
+
+ fn branch_ref(&self, repo: &GitRepo, branch: &str) -> String {
+ // The trait has no error channel here, so an inexpressible name returns
+ // EMPTY rather than an unvalidated string. Every consumer refuses an
+ // empty reference before it can reach a jj command — an empty revision
+ // matches nothing, and refusing is the direction that cannot surprise.
+ if !revset_atom(branch) {
+ return String::new();
+ }
+ let root = &repo.common_dir;
+ let remote = format!("{branch}@origin");
+ let has_local = self.revset_non_empty(root, &format!("present({branch})"));
+ let has_remote = self.revset_non_empty(root, &format!("present({remote})"));
+
+ match (has_local, has_remote) {
+ (true, true) => {
+ // Freshest wins, matching the git backend: the strictly-ahead
+ // ref, and on divergence origin.
+ if self.is_ancestor(root, branch, &remote) {
+ remote
+ } else if self.is_ancestor(root, &remote, branch) {
+ branch.to_string()
+ } else {
+ remote
+ }
+ }
+ (true, false) => branch.to_string(),
+ (false, true) => remote,
+ // Neither exists. The local name is returned so the caller reports
+ // "bookmark not found" against the bookmark the user actually named,
+ // instead of a second name they never mentioned.
+ (false, false) => branch.to_string(),
+ }
+ }
+
+ fn common_git_dir(&self, start: &Path) -> Result {
+ // A colocated repository keeps `.git/info/exclude` at its main root. A
+ // non-colocated one has no usable git dir; callers degrade gracefully on
+ // error, since an absent exclude file is normal rather than a failure.
+ let main_root = self.main_repo_root(start)?;
+ let git_dir = main_root.join(".git");
+ if git_dir.is_dir() {
+ Ok(git_dir)
+ } else {
+ Err(GitError::new(
+ "jj common git dir",
+ format!(
+ "jj repository at {} is not colocated: no .git directory",
+ main_root.display()
+ ),
+ GitErrorKind::Other,
+ ))
+ }
+ }
+
+ fn is_worktree_safe_to_reset(
+ &self,
+ worktree: &Path,
+ branch: &str,
+ ) -> Result {
+ Self::require_jj_workspace(worktree)?;
+ // Both ids travel together in the guard, and the caller must hand BOTH
+ // to `reset_worktree_to_ref`. An unresolvable target or an unreadable
+ // `@` is an `Err`, which callers treat as "not safe"; `safe == false` is
+ // a real answer they may weigh.
+ let reset_ref = self.resolve_reset_ref(worktree, branch)?;
+ let head = self.worktree_head(worktree)?;
+ let safe = self.is_head_merged_into_ref(worktree, &reset_ref)?;
+ Ok(ResetGuard {
+ safe,
+ reset_ref,
+ head,
+ })
+ }
+
+ fn reset_worktree_to_ref(
+ &self,
+ worktree: &Path,
+ reset_ref: &str,
+ expected_head: &str,
+ require_clean: bool,
+ ) -> Result<(), GitError> {
+ Self::require_jj_workspace(worktree)?;
+
+ // A sibling workspace may have moved the repository since this one was
+ // last used; recover before reading anything.
+ let _ = self.out(worktree, &["workspace", "update-stale"]);
+
+ // `expected_head` is interpolated into a revset, so it is validated as a
+ // jj commit id before it is used as one. `reset_ref` is validated too —
+ // Go checks only `expectedHead`, and an unvalidated `ref` reaches
+ // `jj rebase -d` / `jj new` where it is a revset, not an opaque id.
+ if !is_commit_id(expected_head) {
+ return Err(GitError::new(
+ "jj workspace reset",
+ "worktree reset requires a resolved working-copy commit",
+ GitErrorKind::Other,
+ ));
+ }
+ if !is_commit_id(reset_ref) {
+ return Err(GitError::new(
+ "jj workspace reset",
+ "worktree reset requires a resolved commit id",
+ GitErrorKind::Other,
+ ));
+ }
+
+ let dirty = self.is_dirty(worktree)?;
+ if !dirty {
+ return self.reset_clean(worktree, reset_ref, expected_head);
+ }
+ if require_clean {
+ // Re-checked here, not trusted from the caller's own check: work
+ // that landed in between must not be discarded.
+ return Err(GitError::new(
+ "jj workspace reset",
+ "worktree became dirty after safety check",
+ GitErrorKind::Other,
+ ));
+ }
+ self.reset_dirty(worktree, reset_ref, expected_head)
+ }
+}
+
+impl JjBackend {
+ /// `jj git remote list` parsed into `(name, url)` pairs.
+ ///
+ /// Parsed lazily by [`JjBackend::has_remote`]/[`JjBackend::remote_url`]
+ /// rather than cached: a remote added mid-process must be visible without a
+ /// backend rebuild, and the listing is one cheap command.
+ fn remotes(&self, repo: &GitRepo) -> Option> {
+ let out = self
+ .out(&repo.common_dir, &["git", "remote", "list"])
+ .ok()?;
+ Some(
+ out.lines()
+ .filter_map(|line| {
+ let mut fields = line.split_whitespace();
+ let name = fields.next()?;
+ let url = fields.next()?;
+ Some((name.to_string(), url.to_string()))
+ })
+ .collect(),
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::Mutex;
+
+ /// A scripted jj. Every invocation is appended to `calls` with the argv
+ /// AFTER `--color never` is stripped, so an assertion reads as the jj
+ /// subcommand a human would type.
+ struct FakeJj {
+ calls: Mutex>>,
+ /// Answers consumed in order, one per invocation.
+ script: Mutex>>,
+ /// The answer used once the script is exhausted — empty stdout, which is
+ /// what jj prints for a revset that matches nothing.
+ default: Mutex>,
+ }
+
+ impl FakeJj {
+ fn new() -> Self {
+ Self {
+ calls: Mutex::new(Vec::new()),
+ script: Mutex::new(Vec::new()),
+ default: Mutex::new(Ok(String::new())),
+ }
+ }
+
+ /// Queues answers for the next invocations. An `Err` becomes a jj
+ /// failure whose message is the simulated stderr.
+ fn then(mut self, answers: &[Result<&str, &str>]) -> Self {
+ // `get_mut` rather than `lock`, so no guard is held across the move
+ // back to the caller.
+ let queued: Vec> = answers
+ .iter()
+ .map(|a| match a {
+ Ok(s) => Ok((*s).to_string()),
+ Err(e) => Err((*e).to_string()),
+ })
+ .collect();
+ self.script.get_mut().unwrap().extend(queued);
+ self
+ }
+
+ /// Sets the answer used once the script is exhausted.
+ fn then_default(mut self, answer: Result<&str, &str>) -> Self {
+ *self.default.get_mut().unwrap() = match answer {
+ Ok(s) => Ok(s.to_string()),
+ Err(e) => Err(e.to_string()),
+ };
+ self
+ }
+
+ fn calls(&self) -> Vec> {
+ self.calls.lock().unwrap().clone()
+ }
+
+ /// Whether any recorded call starts with `prefix`.
+ fn called(&self, prefix: &[&str]) -> bool {
+ self.calls()
+ .iter()
+ .any(|c| c.len() >= prefix.len() && &c[..prefix.len()] == prefix)
+ }
+ }
+
+ impl JjRunner for FakeJj {
+ fn run(&self, _cwd: &Path, args: &[&str]) -> Result {
+ assert_eq!(
+ args.first().copied(),
+ Some("--color"),
+ "every jj invocation must be --color never first"
+ );
+ self.calls
+ .lock()
+ .unwrap()
+ .push(args[2..].iter().map(|s| (*s).to_string()).collect());
+ let mut script = self.script.lock().unwrap();
+ let answer = if script.is_empty() {
+ self.default.lock().unwrap().clone()
+ } else {
+ script.remove(0)
+ };
+ match answer {
+ Ok(s) => Ok(s),
+ Err(stderr) => Err(GitError::new(
+ format!("jj {}", args[2..].join(" ")),
+ stderr,
+ GitErrorKind::Other,
+ )),
+ }
+ }
+ }
+
+ fn backend(fake: Arc) -> JjBackend {
+ JjBackend { runner: fake }
+ }
+
+ /// A directory carrying a `.jj` marker, so the fail-closed marker check
+ /// passes. Real jj is never invoked against it — every test supplies a
+ /// scripted runner.
+ fn jj_dir() -> (tempfile::TempDir, PathBuf) {
+ let dir = tempfile::tempdir().unwrap();
+ let root = dir.path().to_path_buf();
+ std::fs::create_dir(root.join(".jj")).unwrap();
+ (dir, root)
+ }
+
+ /// A main workspace at `/main` holding the store, and a secondary
+ /// workspace at `/wt-1` pointing at it exactly as jj writes it: a
+ /// path RELATIVE to the workspace's own `.jj` directory.
+ ///
+ /// Everything lives inside the tempdir. An earlier draft built the main
+ /// root by walking out of the tempdir with `..`, and two tests then shared
+ /// one directory in the system temp root — so a `.git` created by one was
+ /// visible to the other.
+ fn main_and_workspace() -> (tempfile::TempDir, PathBuf, PathBuf) {
+ let d = tempfile::tempdir().unwrap();
+ let main = d.path().join("main");
+ std::fs::create_dir_all(main.join(".jj").join("repo")).unwrap();
+ let ws = d.path().join("wt-1");
+ std::fs::create_dir_all(ws.join(".jj")).unwrap();
+ std::fs::write(ws.join(".jj").join("repo"), "../../main/.jj/repo").unwrap();
+ (d, main, ws)
+ }
+
+ /// The jj probes that precede resolving a LOCAL-ONLY bookmark `main`:
+ /// `workspace update-stale`, `present(main)`, `present(main@origin)`.
+ ///
+ /// Every operation that resolves a branch goes through `branch_ref`, so
+ /// most scripts in this file open with it. Factored out because counting
+ /// those probes by hand is how a test ends up asserting against the wrong
+ /// invocation.
+ fn local_only_main() -> [Result<&'static str, &'static str>; 3] {
+ [Ok(""), Ok(SHA), Ok("")]
+ }
+
+ /// Forty hex characters: a syntactically valid jj commit id.
+ const SHA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ const SHA_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+
+ // ── workspace naming and store pointers ─────────────────────────────────
+
+ #[test]
+ fn the_workspace_name_is_a_path_keyed_digest() {
+ let name = workspace_name_for(Path::new("/tmp/pool/wt-1"));
+ assert!(name.starts_with("th-"), "got: {name}");
+ assert_eq!(name.len(), 3 + 32, "th- plus 16 bytes of hex");
+ assert!(name[3..].bytes().all(|b| b.is_ascii_hexdigit()));
+ // Stable across calls, and distinct paths never collide — which is what
+ // lets `workspace add` forget a stale registration by name.
+ assert_eq!(name, workspace_name_for(Path::new("/tmp/pool/wt-1")));
+ assert_ne!(name, workspace_name_for(Path::new("/tmp/pool/wt-2")));
+ }
+
+ #[test]
+ fn a_main_workspace_resolves_to_itself() {
+ // `.jj/repo` is a DIRECTORY here: the store lives in this workspace.
+ let (_d, root) = jj_dir();
+ std::fs::create_dir(root.join(".jj").join("repo")).unwrap();
+ assert_eq!(
+ main_root_from_workspace_root(&root).unwrap(),
+ canonicalize(&root)
+ );
+ }
+
+ #[test]
+ fn a_relative_store_pointer_resolves_through_the_workspace() {
+ // Relative, as jj writes it: the pointer breaks when the pool directory
+ // and the repository do not move together, which is the normal case.
+ let (_d, main, ws) = main_and_workspace();
+
+ assert_eq!(
+ main_root_from_workspace_root(&ws).unwrap(),
+ canonicalize(&main),
+ "a relative pointer must resolve to the same root regardless of where it is written"
+ );
+ }
+
+ #[test]
+ fn an_absolute_store_pointer_is_honoured() {
+ let (d, ws) = jj_dir();
+ let main = d.path().join("main");
+ std::fs::create_dir_all(main.join(".jj").join("repo")).unwrap();
+ std::fs::write(
+ ws.join(".jj").join("repo"),
+ main.join(".jj").join("repo").to_string_lossy().as_bytes(),
+ )
+ .unwrap();
+
+ assert_eq!(
+ main_root_from_workspace_root(&ws).unwrap(),
+ canonicalize(&main)
+ );
+ }
+
+ #[test]
+ fn a_workspace_without_a_store_pointer_is_an_error() {
+ let (_d, root) = jj_dir();
+ let err = main_root_from_workspace_root(&root)
+ .expect_err("a .jj directory with no repo pointer is damaged, not a main workspace");
+ assert!(
+ err.message.contains("cannot inspect"),
+ "got: {}",
+ err.message
+ );
+ }
+
+ #[test]
+ fn a_relative_store_pointer_is_rewritten_absolute() {
+ let (_d, main, ws) = main_and_workspace();
+
+ make_repo_pointer_absolute(&ws).unwrap();
+
+ let written = std::fs::read_to_string(ws.join(".jj").join("repo")).unwrap();
+ let written = PathBuf::from(written.trim());
+ assert!(written.is_absolute(), "got: {written:?}");
+ assert_eq!(
+ main_root_from_workspace_root(&ws).unwrap(),
+ canonicalize(&main)
+ );
+ }
+
+ #[test]
+ fn an_absolute_store_pointer_is_left_alone() {
+ let (d, ws) = jj_dir();
+ let main = d.path().join("main");
+ std::fs::create_dir_all(main.join(".jj").join("repo")).unwrap();
+ let pointer = main.join(".jj").join("repo");
+ std::fs::write(
+ ws.join(".jj").join("repo"),
+ pointer.to_string_lossy().as_bytes(),
+ )
+ .unwrap();
+
+ make_repo_pointer_absolute(&ws).unwrap();
+
+ assert_eq!(
+ std::fs::read_to_string(ws.join(".jj").join("repo")).unwrap(),
+ pointer.to_string_lossy()
+ );
+ }
+
+ // ── markerless fail-closed (upstream #110) ───────────────────────────────
+
+ #[test]
+ fn a_markerless_path_is_refused_before_any_jj_command_runs() {
+ // THE point of a jj-specific marker rule: a jj workspace has no `.git`,
+ // so a git-shaped fallback walks UP. Inside a colocated repository that
+ // walk lands on the user's own working tree.
+ let (_d, root) = jj_dir();
+ let slot = root.join("markerless");
+ std::fs::create_dir_all(&slot).unwrap();
+ let fake = Arc::new(FakeJj::new().then_default(Ok("")));
+ let b = backend(fake.clone());
+
+ let err = b
+ .reset_worktree_to_ref(&slot, SHA, SHA, false)
+ .expect_err("a markerless path must be refused");
+ assert!(
+ err.message.contains("no .git or .jj marker"),
+ "got: {}",
+ err.message
+ );
+ assert!(
+ fake.calls().is_empty(),
+ "the refusal must precede every jj call"
+ );
+ }
+
+ #[test]
+ fn a_git_worktree_is_refused_by_the_jj_backend() {
+ // The inverse misroute: git commands in a jj workspace would run in a
+ // non-git tree and report the failure in terms that hide the cause.
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::create_dir(dir.path().join(".git")).unwrap();
+ let b = backend(Arc::new(FakeJj::new().then_default(Ok(""))));
+
+ let err = b
+ .detach_worktree(dir.path())
+ .expect_err("a git worktree is not a jj workspace");
+ assert!(err.message.contains("git worktree"), "got: {}", err.message);
+ }
+
+ #[test]
+ fn a_git_worktree_nested_in_a_jj_workspace_is_still_refused() {
+ // The enclosing `.jj` must NOT rescue a slot that carries its own git
+ // marker: markers are read from the path itself, never by walking up.
+ let (_d, root) = jj_dir();
+ let slot = root.join("slot");
+ std::fs::create_dir_all(slot.join(".git")).unwrap();
+ let b = backend(Arc::new(FakeJj::new().then_default(Ok(""))));
+
+ assert!(b.reset_worktree(&slot, "main").is_err());
+ }
+
+ // ── argument construction ────────────────────────────────────────────────
+
+ #[test]
+ fn every_invocation_is_color_never() {
+ // Without it a jj run from a TTY puts escape sequences into every
+ // commit id and template result this backend parses.
+ let (_d, root) = jj_dir();
+ let fake = Arc::new(FakeJj::new().then_default(Ok("clean")));
+ backend(fake.clone()).is_dirty(&root).unwrap();
+ assert_eq!(
+ fake.calls(),
+ vec![vec![
+ "log".to_string(),
+ "-r".to_string(),
+ "@".to_string(),
+ "--no-graph".to_string(),
+ "-T".to_string(),
+ DIRTY_TEMPLATE.to_string(),
+ ]]
+ );
+ }
+
+ #[test]
+ fn dirtiness_asks_jj_whether_the_working_copy_commit_is_empty() {
+ let (_d, root) = jj_dir();
+ let fake = Arc::new(FakeJj::new().then(&[Ok("clean"), Ok("dirty")]));
+ let b = backend(fake.clone());
+
+ assert!(!b.is_dirty(&root).unwrap());
+ assert!(b.is_dirty(&root).unwrap());
+ assert!(fake.called(&["log", "-r", "@", "--no-graph"]));
+ }
+
+ #[test]
+ fn merge_safety_excludes_the_working_copy_commit_itself() {
+ let (_d, root) = jj_dir();
+ let fake = Arc::new(FakeJj::new().then(&[Ok(""), Ok("c0ffee")]));
+ let b = backend(fake.clone());
+
+ assert!(b.is_head_merged_into_ref(&root, "main@origin").unwrap());
+ assert!(!b.is_head_merged_into_ref(&root, "main@origin").unwrap());
+ assert_eq!(
+ fake.calls()[0][2],
+ "@- & ~::(main@origin)",
+ "every PARENT must be an ancestor of the ref; @ is excluded"
+ );
+ }
+
+ #[test]
+ fn a_remote_listing_is_parsed_by_column() {
+ let (_d, root) = jj_dir();
+ let fake = Arc::new(FakeJj::new().then_default(Ok(
+ "origin git@github.com:o/r.git\nupstream git@github.com:o/u.git",
+ )));
+ let repo = JjRepo::at(&root);
+ let b = backend(fake.clone());
+
+ assert!(b.has_remote(&repo, "origin"));
+ assert!(b.has_remote(&repo, "upstream"));
+ assert!(!b.has_remote(&repo, "fork"));
+ assert_eq!(
+ b.remote_url(&repo, "origin").as_deref(),
+ Some("git@github.com:o/r.git")
+ );
+ assert_eq!(b.remote_url(&repo, "fork"), None);
+ assert!(fake.called(&["git", "remote", "list"]));
+ }
+
+ #[test]
+ fn a_workspace_is_added_by_name_and_revision_then_its_pointer_is_absolute() {
+ let (d, _root) = jj_dir();
+ let main = d.path().join("main");
+ std::fs::create_dir_all(main.join(".jj").join("repo")).unwrap();
+ let target = d.path().join("pool").join("wt-1");
+ std::fs::create_dir_all(target.parent().unwrap()).unwrap();
+ std::fs::create_dir_all(target.join(".jj")).unwrap();
+ std::fs::write(target.join(".jj").join("repo"), "../../main/.jj/repo").unwrap();
+
+ let name = workspace_name_for(&target);
+ let target_arg = target.to_string_lossy().to_string();
+ let fake = Arc::new(FakeJj::new().then(&local_only_main()).then_default(Ok(SHA)));
+ let b = backend(fake.clone());
+ b.worktree_add(&JjRepo::at(&main), &target, "main").unwrap();
+
+ let calls = fake.calls();
+ assert_eq!(
+ calls[0],
+ vec!["workspace", "forget", &name],
+ "the stale same-path registration must be forgotten first"
+ );
+ let add = calls
+ .iter()
+ .find(|c| {
+ c.first().map(String::as_str) == Some("workspace")
+ && c.get(1).map(String::as_str) == Some("add")
+ })
+ .expect("the workspace must have been added");
+ assert_eq!(
+ *add,
+ vec![
+ "workspace",
+ "add",
+ "--name",
+ &name,
+ "--revision",
+ "main",
+ &target_arg
+ ],
+ "the workspace is named by path digest so two paths never collide"
+ );
+ let pointer = std::fs::read_to_string(target.join(".jj").join("repo")).unwrap();
+ assert!(
+ PathBuf::from(pointer.trim()).is_absolute(),
+ "got: {pointer:?}"
+ );
+ }
+
+ // ── revset safety ────────────────────────────────────────────────────────
+
+ #[test]
+ fn a_bookmark_name_that_is_really_a_revset_is_refused() {
+ // A name reaching `present(...)` raw is revset injection: the caller
+ // gets to choose which commits this backend considers "the branch".
+ let (_d, root) = jj_dir();
+ let repo = JjRepo::at(&root);
+ let b = backend(Arc::new(FakeJj::new().then_default(Ok(SHA))));
+
+ for hostile in [
+ r#"foo" | ~root"#,
+ "foo & bar",
+ "foo(bar)",
+ "main\n@",
+ "",
+ "@",
+ "*",
+ "main|root",
+ ] {
+ assert!(
+ !revset_atom(hostile),
+ "{hostile:?} must not be embeddable in a revset"
+ );
+ assert_eq!(
+ b.branch_ref(&repo, hostile),
+ "",
+ "{hostile:?} must yield no reference at all"
+ );
+ assert!(!b.local_branch_exists(&repo, hostile));
+ assert!(b.is_head_merged_into_ref(&root, hostile).is_err());
+ }
+ }
+
+ #[test]
+ fn ordinary_bookmark_names_stay_embeddable() {
+ // Including the remote-tracking form jj itself uses, which is the whole
+ // reason `@` has to stay in the allowlist.
+ for ok in [
+ "main",
+ "main@origin",
+ "release/2.0",
+ "fix_login",
+ "a.b.c",
+ "v1_2-3",
+ ] {
+ assert!(revset_atom(ok), "{ok:?} must be embeddable");
+ }
+ }
+
+ // ── bookmark selection ───────────────────────────────────────────────────
+
+ #[test]
+ fn the_freshest_bookmark_wins_and_origin_wins_a_divergence() {
+ // Present(local) / present(local@origin) / (local & ::(remote)) /
+ // (remote & ::(local)) — one per case, then the divergence default.
+ struct Case {
+ script: Vec>,
+ want: &'static str,
+ why: &'static str,
+ }
+ let cases = [
+ Case {
+ script: vec![Ok(SHA), Ok("")],
+ want: "main",
+ why: "local only",
+ },
+ Case {
+ script: vec![Ok(""), Ok(SHA)],
+ want: "main@origin",
+ why: "origin only",
+ },
+ Case {
+ script: vec![Ok(SHA), Ok(SHA), Ok(SHA)],
+ want: "main@origin",
+ why: "local is an ancestor of origin: origin is ahead",
+ },
+ Case {
+ script: vec![Ok(SHA), Ok(SHA), Ok(""), Ok(SHA)],
+ want: "main",
+ why: "origin is an ancestor of local: local is ahead",
+ },
+ Case {
+ script: vec![Ok(SHA), Ok(SHA), Ok(""), Ok(""), Ok(SHA)],
+ want: "main@origin",
+ why: "diverged: prefer origin",
+ },
+ ];
+ for case in cases {
+ let (_d, root) = jj_dir();
+ let fake = Arc::new(FakeJj::new().then(&case.script).then_default(Ok(SHA)));
+ let got = backend(fake).branch_ref(&JjRepo::at(&root), "main");
+ assert_eq!(got, case.want, "{}", case.why);
+ }
+ }
+
+ #[test]
+ fn a_bookmark_that_exists_nowhere_still_names_what_the_caller_asked_for() {
+ // The trait has no error channel, so the local name is returned and the
+ // caller reports "cannot resolve bookmark main" — not a second name the
+ // user never mentioned.
+ let (_d, root) = jj_dir();
+ let fake = Arc::new(FakeJj::new().then_default(Ok("")));
+ assert_eq!(
+ backend(fake).branch_ref(&JjRepo::at(&root), "trunk"),
+ "trunk"
+ );
+ }
+
+ #[test]
+ fn the_default_bookmark_prefers_origin_and_fails_closed_when_absent() {
+ let (_d, root) = jj_dir();
+ let repo = JjRepo::at(&root);
+
+ // origin present, but none of the candidates is on it: fall through to
+ // the local scan, which finds `trunk`.
+ let fake = Arc::new(FakeJj::new().then(&[
+ Ok("origin git@host:o/r.git"),
+ Ok(""),
+ Ok(""),
+ Ok(""),
+ Ok(""),
+ Ok(""),
+ Ok(SHA),
+ ]));
+ assert_eq!(backend(fake).default_branch(&repo).unwrap(), "trunk");
+
+ // Nothing anywhere: an error naming the candidates, never a guess.
+ let fake = Arc::new(FakeJj::new().then_default(Ok("")));
+ let err = backend(fake)
+ .default_branch(&repo)
+ .expect_err("must fail closed");
+ assert_eq!(err.kind, GitErrorKind::DefaultBranchUnresolvable);
+ assert!(
+ err.message.contains("main, master, trunk"),
+ "got: {}",
+ err.message
+ );
+ }
+
+ #[test]
+ fn the_merge_ref_names_the_remote_bookmark_when_origin_exists() {
+ let (_d, root) = jj_dir();
+ let repo = JjRepo::at(&root);
+ // default_branch: remote list -> present(main@origin) -> "main";
+ // then default_branch_merge_ref: remote list -> present(main@origin).
+ let fake = Arc::new(FakeJj::new().then(&[
+ Ok("origin git@host:o/r.git"),
+ Ok(SHA),
+ Ok("origin git@host:o/r.git"),
+ Ok(SHA),
+ ]));
+ assert_eq!(
+ backend(fake).default_branch_merge_ref(&repo).unwrap(),
+ "main@origin"
+ );
+ }
+
+ #[test]
+ fn a_missing_merge_bookmark_is_refused_rather_than_substituted() {
+ let (_d, root) = jj_dir();
+ let repo = JjRepo::at(&root);
+ // No origin, and `main` resolves as the default bookmark — then vanishes
+ // before the merge-ref check, so the merge ref itself is unavailable.
+ let fake = Arc::new(FakeJj::new().then(&[Ok(""), Ok(SHA)]).then_default(Ok("")));
+ let err = backend(fake)
+ .default_branch_merge_ref(&repo)
+ .expect_err("an unresolvable merge ref must fail closed");
+ assert_eq!(err.kind, GitErrorKind::MergeRefUnresolvable);
+ assert!(
+ err.message.contains("main is unavailable"),
+ "got: {}",
+ err.message
+ );
+ }
+
+ // ── fetch classification ─────────────────────────────────────────────────
+
+ #[test]
+ fn an_unreachable_origin_is_tagged_so_prune_skips_rather_than_deletes() {
+ let (_d, root) = jj_dir();
+ let repo = JjRepo::at(&root);
+ let fake = Arc::new(FakeJj::new().then(&[
+ Ok("origin https://example.invalid/r.git"),
+ Err("External git program failed (exit code 128)\n unable to access 'https://example.invalid/r.git/': Could not resolve host"),
+ ]));
+ let err = backend(fake)
+ .fetch(&repo)
+ .expect_err("an unreachable origin must fail");
+ assert_eq!(err.kind, GitErrorKind::OriginUnreachable);
+ }
+
+ #[test]
+ fn a_fetch_without_origin_is_a_no_op_and_a_local_failure_is_not_tagged() {
+ let (_d, root) = jj_dir();
+ let repo = JjRepo::at(&root);
+ let fake = Arc::new(FakeJj::new().then(&[Ok(""), Err("operation failed")]));
+ let b = backend(fake.clone());
+ b.fetch(&repo).unwrap();
+ assert_eq!(fake.calls().len(), 1, "no origin means no fetch");
+
+ let fake = Arc::new(FakeJj::new().then(&[Ok("origin u"), Err("operation failed")]));
+ let err = backend(fake)
+ .fetch(&repo)
+ .expect_err("a real jj failure must surface");
+ assert_eq!(
+ err.kind,
+ GitErrorKind::Other,
+ "tagging a non-network failure as unreachable would make prune skip a fixable error"
+ );
+ }
+
+ #[test]
+ fn origin_failures_are_classified_by_jj_s_own_vocabulary() {
+ // jj wraps git for transport, and a local-path remote fails in jj's own
+ // words — so the patterns are jj's, not git's.
+ for detail in [
+ "External git program failed",
+ "fatal: unable to access 'https://example.invalid/r.git/'",
+ "Could not resolve host: example.invalid",
+ "Could not find repository at 'file:///nope'",
+ ] {
+ assert!(
+ is_origin_access_error(detail),
+ "{detail:?} must classify as unreachable"
+ );
+ }
+ for detail in ["operation failed", "bookmark main not found", ""] {
+ assert!(
+ !is_origin_access_error(detail),
+ "{detail:?} must not classify as unreachable"
+ );
+ }
+ }
+
+ // ── the guarded-reset pair ───────────────────────────────────────────────
+
+ #[test]
+ fn a_check_returns_the_target_and_the_head_together() {
+ // update-stale, the two bookmark probes, the pinned target, `@`, and the
+ // ancestry check — and the guard carries BOTH ids, because they are
+ // only meaningful together.
+ let (_d, root) = jj_dir();
+ let fake = Arc::new(FakeJj::new().then(&[
+ Ok(""), // workspace update-stale
+ Ok(SHA), // present(main)
+ Ok(""), // present(main@origin) — local only
+ Ok(SHA), // log -r main -> the pinned reset target
+ Ok(SHA_B), // log -r @ -> the head the check observed
+ Ok(""), // @- & ~::(main) -> every parent is an ancestor
+ ]));
+ let guard = backend(fake)
+ .is_worktree_safe_to_reset(&root, "main")
+ .unwrap();
+
+ assert!(guard.safe);
+ assert_eq!(guard.reset_ref, SHA);
+ assert_eq!(guard.head, SHA_B);
+ }
+
+ #[test]
+ fn a_check_on_a_worktree_ahead_of_its_base_is_not_safe() {
+ let (_d, root) = jj_dir();
+ let fake = Arc::new(FakeJj::new().then(&[
+ Ok(""),
+ Ok(SHA),
+ Ok(""),
+ Ok(SHA), // reset target
+ Ok(SHA_B), // head
+ Ok("unmerged-parent"), // @- is not an ancestor of main
+ ]));
+ let guard = backend(fake)
+ .is_worktree_safe_to_reset(&root, "main")
+ .unwrap();
+ assert!(!guard.safe, "unlanded work must never read as safe");
+ // The pair is still returned: a caller may weigh other evidence.
+ assert_eq!(guard.reset_ref, SHA);
+ assert_eq!(guard.head, SHA_B);
+ }
+
+ #[test]
+ fn a_check_fails_closed_when_the_bookmark_cannot_be_resolved() {
+ let (_d, root) = jj_dir();
+ let fake = Arc::new(FakeJj::new().then(&[Ok(""), Ok(""), Ok("")]));
+ let err = backend(fake)
+ .is_worktree_safe_to_reset(&root, "main")
+ .expect_err("an unresolvable bookmark is not safe, it is unknown");
+ assert!(
+ err.message.contains("cannot resolve"),
+ "got: {}",
+ err.message
+ );
+ }
+
+ #[test]
+ fn a_reset_refuses_an_argument_that_is_not_a_jj_commit_id() {
+ // Both ids are interpolated into revsets; a truncated one is a silent
+ // no-match, and a name is revset injection.
+ let (_d, root) = jj_dir();
+ let b = backend(Arc::new(FakeJj::new().then_default(Ok("clean"))));
+ for bad in ["main", "HEAD", "", "NOTAHEXSHA", &SHA[..39], "AAAA"] {
+ assert!(
+ b.reset_worktree_to_ref(&root, SHA, bad, false).is_err(),
+ "expected_head {bad:?} must be refused"
+ );
+ assert!(
+ b.reset_worktree_to_ref(&root, bad, SHA, false).is_err(),
+ "reset_ref {bad:?} must be refused"
+ );
+ }
+ }
+
+ // ── reset: clean workspace ───────────────────────────────────────────────
+
+ #[test]
+ fn a_clean_workspace_is_rebased_and_the_postcondition_is_verified() {
+ // jj always commits, so the pinned revset is the lock substitute: a
+ // rebase that did NOT move @ must be caught, not reported as success.
+ let (_d, root) = jj_dir();
+ let fake = Arc::new(FakeJj::new().then(&[
+ Ok(""), // workspace update-stale
+ Ok("clean"), // is_dirty
+ Ok(""), // jj rebase -d [ -r @ & commit_id(head)
+ Ok("clean"), // parked_on_ref -> is_dirty
+ Ok(SHA), // parked_on_ref -> log -r @-
+ ]));
+ backend(fake.clone())
+ .reset_worktree_to_ref(&root, SHA, SHA_B, true)
+ .unwrap();
+
+ assert!(
+ fake.called(&[
+ "rebase",
+ "-d",
+ SHA,
+ "-r",
+ &format!("@ & commit_id(\"{SHA_B}\")")
+ ]),
+ "the rebase must pin @ by the commit the check observed: {:?}",
+ fake.calls()
+ );
+ }
+
+ #[test]
+ fn a_rebase_that_left_the_workspace_where_it_was_is_refused() {
+ // The single most important outcome in this file: jj exits 0 for a
+ // no-op rebase, so reporting success here would discard nothing and
+ // reset nothing while every caller believes the slot is clean.
+ let (_d, root) = jj_dir();
+ let fake = Arc::new(FakeJj::new().then(&[
+ Ok(""),
+ Ok("clean"),
+ Ok(""), // rebase "succeeded"
+ Ok("clean"), // parked_on_ref -> is_dirty
+ Ok(SHA_B), // @- is still the OLD commit, not the reset target
+ ]));
+ let err = backend(fake)
+ .reset_worktree_to_ref(&root, SHA, SHA_B, true)
+ .expect_err("a workspace that did not move must be refused");
+ assert!(
+ err.message.contains("HEAD changed since safety check"),
+ "got: {}",
+ err.message
+ );
+ }
+
+ #[test]
+ fn a_rebase_error_on_an_already_parked_workspace_is_a_success() {
+ // `jj rebase` onto the commit @ already sits on can fail as a no-op.
+ // That is only a success when @ really is parked on the target AND was
+ // never moved by anything else.
+ let (_d, root) = jj_dir();
+ let parked = Arc::new(FakeJj::new().then(&[
+ Ok(""),
+ Ok("clean"),
+ Err("This commit is already rebased"),
+ Ok("clean"),
+ Ok(SHA),
+ Ok(SHA_B),
+ ]));
+ backend(parked.clone())
+ .reset_worktree_to_ref(&root, SHA, SHA_B, true)
+ .unwrap();
+ assert!(parked.called(&["log", "-r", "@", "--no-graph"]));
+
+ // Same jj error, but @ moved in the meantime: a real failure.
+ let moved = Arc::new(FakeJj::new().then(&[
+ Ok(""),
+ Ok("clean"),
+ Err("This commit is already rebased"),
+ Ok("clean"),
+ Ok(SHA),
+ Ok("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
+ ]));
+ assert!(
+ backend(moved)
+ .reset_worktree_to_ref(&root, SHA, SHA_B, true)
+ .is_err(),
+ "a rebase error is only survivable while @ is provably unchanged"
+ );
+ }
+
+ // ── reset: dirty workspace ───────────────────────────────────────────────
+
+ #[test]
+ fn a_dirty_workspace_is_refused_when_the_caller_required_clean() {
+ let (_d, root) = jj_dir();
+ let fake = Arc::new(FakeJj::new().then(&[Ok(""), Ok("dirty")]));
+ let err = backend(fake.clone())
+ .reset_worktree_to_ref(&root, SHA, SHA_B, true)
+ .expect_err("uncommitted work must not be discarded");
+ assert!(err.message.contains("became dirty"), "got: {}", err.message);
+ assert!(
+ !fake.called(&["abandon"]),
+ "the refusal must precede the abandon"
+ );
+ assert!(!fake.called(&["rebase"]));
+ }
+
+ #[test]
+ fn a_dirty_workspace_is_abandoned_and_reparked_only_after_every_check() {
+ let (_d, root) = jj_dir();
+ let fake = Arc::new(FakeJj::new().then(&[
+ Ok(""), // update-stale
+ Ok("dirty"), // is_dirty
+ Ok(""), // jj abandon -r @ & commit_id(head)
+ Ok(""), // the pinned revset is now empty
+ Ok("clean"), // still clean
+ Ok(""), // merged into the reset target
+ Ok(""), // jj new ][
+ ]));
+ let b = backend(fake.clone());
+ b.reset_worktree_to_ref(&root, SHA, SHA_B, false).unwrap();
+
+ let calls = fake.calls();
+ assert_eq!(
+ calls[2],
+ vec!["abandon", "-r", &format!("@ & commit_id(\"{SHA_B}\")")]
+ );
+ assert_eq!(
+ calls.last().unwrap(),
+ &vec!["new".to_string(), SHA.to_string()]
+ );
+ }
+
+ #[test]
+ fn an_abandon_that_did_not_take_is_refused_before_the_workspace_is_rebuilt() {
+ // `@` still matching the pinned commit means it moved, or never was what
+ // the safety check measured. Rebuilding on top would strand the wrong
+ // history as the parent of a fresh commit.
+ for (label, script) in [
+ (
+ "the pinned revset is still non-empty",
+ vec![Ok(""), Ok("dirty"), Ok(""), Ok(SHA)],
+ ),
+ (
+ "the workspace is still dirty",
+ vec![Ok(""), Ok("dirty"), Ok(""), Ok(""), Ok("dirty")],
+ ),
+ (
+ "the workspace is unmerged into the target",
+ vec![Ok(""), Ok("dirty"), Ok(""), Ok(""), Ok("clean"), Ok(SHA)],
+ ),
+ ] {
+ let (_d, root) = jj_dir();
+ let fake = Arc::new(FakeJj::new().then(&script));
+ let err = backend(fake.clone())
+ .reset_worktree_to_ref(&root, SHA, SHA_B, false)
+ .unwrap_err();
+ assert!(
+ err.message.contains("HEAD changed since safety check"),
+ "{label}: got {}",
+ err.message
+ );
+ assert!(
+ !fake.called(&["new"]),
+ "{label}: must not rebuild the workspace"
+ );
+ }
+ }
+
+ #[test]
+ fn reset_worktree_resolves_the_bookmark_once_and_hands_the_pair_over() {
+ let (_d, root) = jj_dir();
+ let fake = Arc::new(FakeJj::new().then(&[
+ Ok(""), // resolve_reset_ref: update-stale
+ Ok(SHA), // present(main)
+ Ok(""), // present(main@origin)
+ Ok(SHA), // log -r main -> the pinned target
+ Ok(SHA_B), // log -r @ -> head
+ Ok(""), // reset_worktree_to_ref: update-stale
+ Ok("clean"), // is_dirty
+ Ok(""), // rebase
+ Ok("clean"), // parked check
+ Ok(SHA), // @- == the target
+ ]));
+ backend(fake.clone()).reset_worktree(&root, "main").unwrap();
+
+ // The bookmark was pinned to a commit id, so the rebase targets an id —
+ // never the mutable bookmark name.
+ assert!(fake.called(&["rebase", "-d", SHA]));
+ assert!(
+ !fake.called(&["rebase", "-d", "main"]),
+ "a reset must never target the mutable bookmark itself"
+ );
+ }
+
+ // ── refusal surfaces ─────────────────────────────────────────────────────
+
+ #[test]
+ fn removal_refuses_a_directory_that_is_not_a_jj_workspace() {
+ // Removing a git worktree here would delete its files while leaving the
+ // registration behind.
+ let (_d, root) = jj_dir();
+ let slot = root.join("gitslot");
+ std::fs::create_dir_all(slot.join(".git")).unwrap();
+ std::fs::write(slot.join("data.txt"), "keep me").unwrap();
+
+ let fake = Arc::new(FakeJj::new().then_default(Ok("")));
+ let err = backend(fake.clone())
+ .worktree_remove(&JjRepo::at(&root), &slot)
+ .expect_err("a git worktree must not be deleted through the jj backend");
+ assert!(
+ err.message.contains("not a jj workspace"),
+ "got: {}",
+ err.message
+ );
+ assert!(
+ slot.join("data.txt").exists(),
+ "the refusal must precede any deletion"
+ );
+ assert!(fake.calls().is_empty());
+ }
+
+ #[test]
+ fn removal_refuses_a_main_workspace_which_holds_the_repository_store() {
+ // `.jj/repo` is a DIRECTORY in the main workspace: forgetting the
+ // digest-named registration would no-op and the delete would destroy
+ // the entire repository.
+ let (_d, root) = jj_dir();
+ std::fs::create_dir(root.join(".jj").join("repo")).unwrap();
+ std::fs::write(root.join("README.md"), "the repository").unwrap();
+
+ let fake = Arc::new(FakeJj::new().then_default(Ok("")));
+ let err = backend(fake.clone())
+ .worktree_remove(&JjRepo::at(&root), &root)
+ .expect_err("the main workspace must be refused");
+ assert!(
+ err.message.contains("main jj workspace"),
+ "got: {}",
+ err.message
+ );
+ assert!(root.join("README.md").exists());
+ assert!(fake.calls().is_empty());
+ }
+
+ #[test]
+ fn a_secondary_workspace_is_forgotten_by_name_and_removed() {
+ let (_d, root) = jj_dir();
+ let slot = root.join("wt-1");
+ std::fs::create_dir_all(slot.join(".jj")).unwrap();
+ std::fs::write(slot.join(".jj").join("repo"), "../../main/.jj/repo").unwrap();
+
+ let fake = Arc::new(FakeJj::new().then_default(Ok("")));
+ backend(fake.clone())
+ .worktree_remove(&JjRepo::at(&root), &slot)
+ .unwrap();
+
+ assert_eq!(
+ fake.calls()[0],
+ vec!["workspace", "forget", &workspace_name_for(&slot)]
+ );
+ assert!(!slot.exists());
+ }
+
+ #[test]
+ fn a_dirty_workspace_is_not_removed_by_the_clean_removal() {
+ let (_d, root) = jj_dir();
+ let slot = root.join("wt-1");
+ std::fs::create_dir_all(slot.join(".jj")).unwrap();
+
+ let fake = Arc::new(FakeJj::new().then(&[Ok("dirty")]).then_default(Ok("")));
+ let err = backend(fake.clone())
+ .remove_clean_worktree(&JjRepo::at(&root), &slot)
+ .expect_err("a dirty workspace must be kept");
+ assert!(
+ err.message.contains("has local changes"),
+ "got: {}",
+ err.message
+ );
+ assert!(slot.exists());
+ assert!(!fake.called(&["workspace", "forget"]));
+ }
+
+ #[test]
+ fn branch_creation_is_refused_and_says_which_marker_was_found() {
+ let (_d, root) = jj_dir();
+ let b = backend(Arc::new(FakeJj::new().then_default(Ok(""))));
+ let err = b
+ .create_branch(&root, "feature")
+ .expect_err("jj bookmarks are not git branches");
+ assert!(
+ err.message.contains("only supported by")
+ || err.message.contains("requires a git worktree"),
+ "got: {}",
+ err.message
+ );
+ assert!(
+ err.message.contains("jj"),
+ "the operator must learn the slot's flavor: got: {}",
+ err.message
+ );
+ }
+
+ #[test]
+ fn the_common_git_dir_is_the_main_roots_git_directory_when_colocated() {
+ let (_d, main, ws) = main_and_workspace();
+ std::fs::create_dir(main.join(".git")).unwrap();
+
+ // `jj workspace root` answers the workspace's own path; the store
+ // pointer then walks up to the MAIN root, which is where
+ // repo-local .git/info/exclude lives.
+ let root = ws.to_string_lossy().to_string();
+ let fake = Arc::new(FakeJj::new().then(&[Ok(root.as_str())]));
+ assert_eq!(
+ backend(fake).common_git_dir(&ws).unwrap(),
+ canonicalize(&main).join(".git"),
+ "repo-local .git/info/exclude lives at the MAIN root, not the workspace's"
+ );
+ }
+
+ #[test]
+ fn a_non_colocated_repository_reports_no_usable_git_dir() {
+ let (_d, _main, ws) = main_and_workspace();
+
+ let root = ws.to_string_lossy().to_string();
+ let fake = Arc::new(FakeJj::new().then(&[Ok(root.as_str())]));
+ let err = backend(fake)
+ .common_git_dir(&ws)
+ .expect_err("a non-colocated jj repo has no .git to point at");
+ assert!(
+ err.message.contains("not colocated"),
+ "got: {}",
+ err.message
+ );
+ }
+
+ // ── no jj binary ─────────────────────────────────────────────────────────
+
+ #[test]
+ fn with_no_jj_installed_every_command_fails_with_the_real_cause() {
+ // The backend registers REGARDLESS of whether jj is installed, so a
+ // `.jj` marker resolves to jj rather than to the seam's "no backend is
+ // registered for jj" — which would send the operator hunting for a
+ // configuration problem that does not exist.
+ let b = JjBackend::discover();
+ if find_jj_binary().is_some() {
+ return; // jj IS installed here; the fake-path test below still ran.
+ }
+ assert_eq!(
+ b.name(),
+ BACKEND_JJ,
+ "identity is what dispatch looks the backend up by"
+ );
+
+ let (_d, root) = jj_dir();
+ let err = b
+ .is_dirty(&root)
+ .expect_err("jj is not installed, so this cannot succeed");
+ assert_eq!(err.kind, GitErrorKind::NotFound);
+ assert!(
+ err.message.contains("jj binary not found"),
+ "got: {}",
+ err.message
+ );
+ }
+
+ #[test]
+ fn a_git_marker_is_never_honoured_by_the_jj_backend() {
+ // Belt and braces over the markerless test: the constant is referenced
+ // here so a rename cannot silently break the refusal above.
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::create_dir(dir.path().join(".git")).unwrap();
+ assert_eq!(
+ crate::vcs::worktree_backend_name(dir.path()).unwrap(),
+ Some(BACKEND_GIT)
+ );
+ assert!(
+ backend(Arc::new(FakeJj::new()))
+ .detach_worktree(dir.path())
+ .is_err(),
+ "a slot marked git must never be answered by the jj backend"
+ );
+ }
+}
diff --git a/crates/treehouse-core/src/vcs/mod.rs b/crates/treehouse-core/src/vcs/mod.rs
new file mode 100644
index 0000000..960f309
--- /dev/null
+++ b/crates/treehouse-core/src/vcs/mod.rs
@@ -0,0 +1,1844 @@
+//! The version-control seam: which backend answers for a path, and the
+//! guarded-reset protocol that protects a pooled worktree.
+//!
+//! Two responsibilities live here, both ported from Go `internal/vcs`
+//! (`vcs.go`). Neither belongs on [`crate::git::GitBackend`] itself, because
+//! both are about *choosing* a backend rather than *being* one.
+//!
+//! # Backend identity and dispatch
+//!
+//! A pooled worktree's own marker — a `.git` entry or a `.jj` directory —
+//! decides which backend handles it. That is the whole point of the seam: the
+//! configured backend must never answer for a slot of the other flavor, and —
+//! for the operations that REWRITE a checkout — a path carrying no marker at
+//! all must be REFUSED rather than answered by whatever repository encloses
+//! it. In the supported in-project pool layout (`root = "."`, which nests
+//! `.treehouse` under the repo) that enclosing repository is the user's own
+//! working tree, and `reset --hard` + `clean -fd` there is irreversible data
+//! loss. Go wraps every destructive entry point in
+//! `destructiveBackendForWorktree` (`vcs.go:513-525`) for exactly this reason.
+//!
+//! [`backend_for_worktree`] is the non-destructive read: a markerless path
+//! falls back to the default backend, because reading a status is harmless.
+//! [`destructive_backend_for_worktree`] is the write path: a markerless path
+//! is an error, never a guess.
+//!
+//! # The registry
+//!
+//! [`BackendRegistry`] maps a backend name to a handle. It is extensible
+//! WITHOUT editing [`crate::git::GitBackend`]: the jj backend is added by
+//! registering a handle, not by adding a variant to any enum this port owns.
+//! [`global_registry`] is the process-wide instance, and it registers jj
+//! itself — see its docs for why that is not the same as opting into it.
+//!
+//! # The guarded-reset pair
+//!
+//! [`is_worktree_safe_to_reset`] and [`reset_worktree_to_ref`] are Go's
+//! two-call protocol (gitvcs.go:934-1010, pool.go:511-521). They are split
+//! because a reset that does not re-verify is WORSE than no reset at all:
+//! callers believe the safety check and the reset shared one target, so a
+//! concurrent commit landing in between is silently discarded. The check
+//! returns the resolved commit and the HEAD it observed; the reset re-reads
+//! HEAD under git's own `HEAD.lock` and refuses if it moved.
+
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, OnceLock, RwLock};
+
+use crate::git::{GitBackend, GitError, GitErrorKind, GitRepo, ShellGitBackend};
+
+/// Backend name for git (Go `vcs.gitBackend`).
+pub const BACKEND_GIT: &str = "git";
+/// Backend name for jj (Go `vcs.jjBackend`).
+pub const BACKEND_JJ: &str = "jj";
+
+pub mod jj;
+
+// ─── Marker inspection ─────────────────────────────────────────────────────────
+
+/// Reports whether `path` itself exists, WITHOUT following symlinks (Go
+/// `markerPresent`).
+///
+/// A dangling symlink is PRESENT: the directory entry is on disk, and its
+/// unresolvable target is a read failure for the caller to surface — never an
+/// absent marker. Collapsing the two would let a slot whose `.git` points at a
+/// deleted worktree be classified as "no marker", i.e. damaged, and then
+/// handled by whatever repository encloses the pool.
+fn marker_present(path: &Path) -> Result] {
+ match std::fs::symlink_metadata(path) {
+ Ok(_) => Ok(true),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
+ Err(e) => Err(GitError::new(
+ "lstat worktree marker",
+ format!("reading {}: {e}", path.display()),
+ GitErrorKind::Other,
+ )),
+ }
+}
+
+/// The backend a worktree's own marker names (Go `WorktreeBackendNameChecked`,
+/// vcs.go:444-480).
+///
+/// - `.git` present → [`BACKEND_GIT`]. The entry is followed once so a DANGLING
+/// `.git` is a read ERROR here rather than three confusing git failures later.
+/// - `.jj` present and a directory → [`BACKEND_JJ`]. A `.jj` FILE is not a
+/// workspace marker and falls through.
+/// - neither → `Ok(None)`. A genuine "no marker" answer, distinct from an error.
+///
+/// The error cases are load-bearing and are why this returns `Result>`
+/// rather than `Option<_>`: a caller that cannot tell "absent" from
+/// "unreadable" will eventually treat an unreadable slot as safe to touch.
+pub fn worktree_backend_name(path: &Path) -> Result , GitError> {
+ let git_marker = path.join(".git");
+ if marker_present(&git_marker)? {
+ std::fs::metadata(&git_marker).map_err(|e| {
+ GitError::new(
+ "stat .git marker",
+ format!("resolving .git marker in {}: {e}", path.display()),
+ GitErrorKind::Other,
+ )
+ })?;
+ return Ok(Some(BACKEND_GIT));
+ }
+
+ let jj_marker = path.join(".jj");
+ if marker_present(&jj_marker)? {
+ let meta = std::fs::metadata(&jj_marker).map_err(|e| {
+ GitError::new(
+ "stat .jj marker",
+ format!("resolving .jj marker in {}: {e}", path.display()),
+ GitErrorKind::Other,
+ )
+ })?;
+ if meta.is_dir() {
+ return Ok(Some(BACKEND_JJ));
+ }
+ }
+
+ Ok(None)
+}
+
+/// Refuses a path that carries no backend marker, before any destructive work
+/// starts (the precondition [`destructive_backend_for_worktree`] enforces, for
+/// callers that already hold a backend).
+pub(crate) fn require_destructive_marker(path: &Path) -> Result<&'static str, GitError> {
+ match worktree_backend_name(path)? {
+ Some(name) => Ok(name),
+ None => Err(GitError::new(
+ "resolve worktree backend",
+ format!(
+ "refusing to modify {}: it holds no .git or .jj marker",
+ path.display()
+ ),
+ GitErrorKind::Other,
+ )),
+ }
+}
+
+// ─── Registry ─────────────────────────────────────────────────────────────────
+
+/// Name → backend handles, defaulting to git.
+///
+/// Registration is the ONLY way a new backend joins the seam. Nothing here is
+/// a `match` over an enum, so the jj backend can ship without touching this
+/// file, the trait, or any dispatch site.
+///
+/// Cloning is not provided (backends are `dyn`), so lookups return an
+/// [`Arc`] the caller can hold across the operation it is dispatching.
+#[derive(Default)]
+pub struct BackendRegistry {
+ backends: RwLock>>,
+}
+
+impl std::fmt::Debug for BackendRegistry {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("BackendRegistry")
+ .field("names", &self.names())
+ .finish()
+ }
+}
+
+impl BackendRegistry {
+ /// An empty registry. [`Self::default_git`] seeds git on first use, so a bare
+ /// registry already behaves correctly for a git-only deployment; building
+ /// one directly is for tests that assert dispatch itself.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Registers (or replaces) the backend answering for `name`.
+ ///
+ /// Replacement is allowed because registration happens at startup, before
+ /// any dispatch, and a later registrant legitimately wins — that is how a
+ /// test injects a stub. It is NOT safe to call concurrently with dispatch
+ /// expecting in-flight work to change backend; an operation already
+ /// dispatched keeps the handle it was given.
+ ///
+ /// The name is `&'static str` on purpose: backends are registered once and
+ /// live for the process, so the registry never has to own or free a name.
+ /// It must match what `GitBackend::name` returns for that backend, or the
+ /// registry will resolve markers to a backend that calls itself something
+ /// else.
+ pub fn register(&self, name: &'static str, backend: Arc) {
+ self.backends
+ .write()
+ .expect("backend registry lock poisoned")
+ .insert(name, backend);
+ }
+
+ /// The backend registered for `name`, if any.
+ pub fn get(&self, name: &str) -> Option> {
+ self.backends
+ .read()
+ .expect("backend registry lock poisoned")
+ .get(name)
+ .cloned()
+ }
+
+ /// Every registered name, sorted.
+ pub fn names(&self) -> Vec<&'static str> {
+ self.backends
+ .read()
+ .expect("backend registry lock poisoned")
+ .keys()
+ .copied()
+ .collect()
+ }
+
+ /// The git backend, constructing and caching it on first use.
+ ///
+ /// Git is the default everywhere, exactly as in Go: the jj backend is
+ /// strictly opt-in, so a repository without one keeps git's behavior and a
+ /// shell-wide opt-in can never break a plain git repository. Construction
+ /// is deferred rather than done at module load so importing this module on
+ /// a machine without git is not itself an error.
+ ///
+ /// Returns `None` only when git is genuinely absent — there is no
+ /// substitute, and silently returning some other backend would be a
+ /// misroute.
+ pub fn default_git(&self) -> Option> {
+ if let Some(git) = self.get(BACKEND_GIT) {
+ return Some(git);
+ }
+ let discovered = ShellGitBackend::discover().ok()?;
+ let handle: Arc = Arc::new(discovered);
+ // Re-check under the write lock: a concurrent first lookup may have
+ // won the race, and clobbering its handle would swap the backend
+ // mid-dispatch. Whoever got here second keeps theirs.
+ let mut backends = self
+ .backends
+ .write()
+ .expect("backend registry lock poisoned");
+ match backends.get(BACKEND_GIT) {
+ Some(existing) => Some(existing.clone()),
+ None => {
+ backends.insert(BACKEND_GIT, handle.clone());
+ Some(handle)
+ }
+ }
+ }
+
+ /// Resolves `name`, falling back to git when the marker named none.
+ ///
+ /// A `git` marker is special: it means "not built yet", not "unavailable",
+ /// because git is constructed on demand by [`Self::default_git`]. Any other
+ /// name must already be registered — an unregistered one is an error, never
+ /// a silent fallback, so a `.jj` workspace is never answered by git.
+ fn resolve_or_default(
+ &self,
+ name: Option<&'static str>,
+ ) -> Result, GitError> {
+ let no_git = || {
+ GitError::new(
+ "resolve worktree backend",
+ "no git backend available (git binary not found on PATH)",
+ GitErrorKind::NotFound,
+ )
+ };
+ match name {
+ None | Some(BACKEND_GIT) => self.default_git().ok_or_else(no_git),
+ Some(name) => self.get(name).ok_or_else(|| {
+ GitError::new(
+ "resolve worktree backend",
+ format!("no backend is registered for {name:?}"),
+ GitErrorKind::Other,
+ )
+ }),
+ }
+ }
+}
+
+/// The process-wide registry (Go's package-level `gitBackend` / `jjBackend`).
+///
+/// Built lazily on first use, so a caller that never touches the seam pays
+/// nothing. The jj backend is registered HERE, by the registry's own
+/// initializer, rather than by a call site somewhere in the CLI: a backend
+/// registered after a pool has already dispatched leaves that pool answering
+/// with git, and "register jj early enough" is exactly the kind of ordering
+/// requirement a caller will eventually get wrong.
+///
+/// Registering jj is NOT opting into it. jj stays opt-in where worktrees are
+/// CREATED — that choice is [`configured_backend_name`], driven by
+/// configuration. Registration only makes a `.jj` marker answerable, which is
+/// Go's `slotMarkerBackend` behaviour: a slot is dispatched by its own marker
+/// regardless of what the repository currently selects. So a repository without
+/// a `.jj` directory is completely unaffected, and a colocated one still
+/// resolves to git (a `.git` marker is checked first).
+///
+/// Git is NOT registered here. It is constructed on demand by
+/// [`BackendRegistry::default_git`], so importing this module on a machine
+/// without git is not itself an error.
+///
+/// This function is the single entry point other agents should use; building a
+/// private registry is only correct in tests that assert dispatch itself.
+pub fn global_registry() -> &'static BackendRegistry {
+ static REGISTRY: OnceLock = OnceLock::new();
+ REGISTRY.get_or_init(|| {
+ let registry = BackendRegistry::new();
+ // Infallible by construction — see `JjBackend::discover`. A machine
+ // without jj still gets a registered backend so a `.jj` marker fails
+ // with "jj binary not found" rather than "no backend is registered".
+ registry.register(BACKEND_JJ, Arc::new(jj::JjBackend::discover()));
+ registry
+ })
+}
+
+/// The nearest ancestor of `path` holding a VCS marker (Go `findMarkerRoot`),
+/// and WHICH markers it holds.
+///
+/// Both are reported because a COLOCATED repository (`.jj` and `.git`
+/// together) means something different from a `.jj`-only one, and the two are
+/// selected differently: a colocated repository stays on git worktrees unless
+/// the user explicitly opts in, while a `.jj`-only tree has no git worktree to
+/// stay on.
+///
+/// A marker that exists but cannot be read counts as ABSENT here, unlike
+/// [`worktree_backend_name`]. This function answers "which backend would this
+/// path be configured for", and Go's `findMarkerRoot` reads it the same way
+/// with `os.Stat`. The destructive paths do not go through here; they go
+/// through [`worktree_backend_name`], where an unreadable marker is an error.
+/// `None` means the walk reached the filesystem root without finding one.
+pub fn marker_root(path: &Path) -> Option {
+ let mut dir = if path.is_absolute() {
+ path.to_path_buf()
+ } else {
+ std::env::current_dir().ok()?.join(path)
+ };
+ loop {
+ let has_jj = std::fs::metadata(dir.join(".jj")).is_ok_and(|m| m.is_dir());
+ let has_git = std::fs::metadata(dir.join(".git")).is_ok();
+ if has_jj || has_git {
+ return Some(MarkerRoot {
+ root: dir,
+ has_git,
+ has_jj,
+ });
+ }
+ match dir.parent() {
+ Some(parent) if parent != dir => dir = parent.to_path_buf(),
+ _ => return None,
+ }
+ }
+}
+
+/// Where a repository's markers are, and which ones (Go's `findMarkerRoot`
+/// triple).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MarkerRoot {
+ /// The directory holding the markers.
+ pub root: PathBuf,
+ /// A `.git` entry is present there.
+ pub has_git: bool,
+ /// A `.jj` DIRECTORY is present there. A `.jj` file is not a workspace.
+ pub has_jj: bool,
+}
+
+/// The backend name the CONFIGURATION selects for `path` (Go
+/// `backendFor(...).Name()`, exposed for the CLI's `--branch` refusal).
+///
+/// `override_name` is the configuration layer's answer — `TREEHOUSE_VCS`, the
+/// repository's `treehouse.toml` `vcs` key, or the user-level config, already
+/// resolved and normalized to `"git"`/`"jj"`, or `None` for no opt-in. Reading
+/// those files belongs to the config layer, which depends on this one; this
+/// function is pure so it holds no global state and needs no lock.
+///
+/// Two selection rules, both from Go:
+///
+/// - **An explicit `"git"` always wins**, opt-in or not.
+/// - **An explicit `"jj"` only takes effect where a `.jj` directory actually
+/// exists.** Everywhere else the answer stays git, which is what makes a
+/// shell-wide `TREEHOUSE_VCS=jj` harmless in a plain git repository.
+/// - With no opt-in, a `.jj`-only tree also consults the MAIN repository root
+/// its `.jj/repo` pointer names: a pooled jj workspace is a `.jj`-only
+/// checkout that cannot carry an untracked `treehouse.toml`, so the opt-in
+/// lives at the main root. That hop is pure file inspection, so it works
+/// before any backend is selected — and the decision still comes from
+/// configuration, never from the marker.
+///
+/// A path outside any repository answers [`BACKEND_GIT`], so errors surface
+/// exactly as they always did rather than as a new "no marker" failure.
+///
+/// NOTE that this is the CONFIGURED backend — which backend would CREATE a
+/// worktree. A worktree that already exists is answered by its own marker
+/// instead; see [`backend_for_worktree`] and
+/// [`destructive_backend_for_worktree`]. The two deliberately disagree for a
+/// colocated repository, and that is the mechanism behind `TestJJColocatedWithoutOptInKeepsGitWorktrees`.
+pub fn configured_backend_name(
+ path: &Path,
+ override_name: Option<&str>,
+) -> Result<&'static str, GitError> {
+ // An unrecognized value is IGNORED, not an error: a typo in a config file
+ // must not break every command in the repository.
+ let git_forced = override_name == Some(BACKEND_GIT);
+ let jj_forced = override_name == Some(BACKEND_JJ);
+
+ let Some(found) = marker_root(path) else {
+ return Ok(BACKEND_GIT);
+ };
+ if git_forced {
+ return Ok(BACKEND_GIT);
+ }
+ if jj_forced {
+ return Ok(if found.has_jj {
+ BACKEND_JJ
+ } else {
+ BACKEND_GIT
+ });
+ }
+ // A `.jj`-only tree: its own checkout cannot hold the opt-in, so the main
+ // repository root gets the same `override_name` Go would have re-read for
+ // itself. `main_root_from_workspace_root` is pure file inspection, so this
+ // never needs a jj binary.
+ if found.has_jj
+ && !found.has_git
+ && let Ok(main_root) = jj::main_root_from_workspace_root(&found.root)
+ && main_root != found.root
+ && override_name_is_jj_at(&main_root, override_name)
+ {
+ return Ok(BACKEND_JJ);
+ }
+ Ok(BACKEND_GIT)
+}
+
+/// Whether the opt-in for `root` selects jj.
+///
+/// Go re-reads the configuration files at each root, because a repository's
+/// `treehouse.toml` and the user-level config are different files. This port
+/// takes the already-resolved override as a single value, which matches Go
+/// exactly for the `TREEHOUSE_VCS` and user-config cases and is the conservative
+/// reading for the repository-file case at a SECOND root: no extra opt-in is
+/// invented there, so a workspace nested under an opted-in main repository is
+/// jj only when the caller resolved the override for that main root.
+fn override_name_is_jj_at(root: &Path, override_name: Option<&str>) -> bool {
+ match override_name {
+ Some(BACKEND_JJ) => std::fs::metadata(root.join(".jj")).is_ok_and(|m| m.is_dir()),
+ _ => false,
+ }
+}
+
+// ─── Dispatch ─────────────────────────────────────────────────────────────────
+
+/// The backend that owns `path` for READ operations (Go `backendForWorktree`).
+///
+/// A marker names the backend outright; a markerless path falls back to the
+/// default (git), so reading a status outside any repository surfaces exactly
+/// the error it always did.
+///
+/// A marker naming an UNREGISTERED backend is an error rather than a fallback:
+/// answering a `.jj` workspace with git would run git commands inside a
+/// non-git tree and report the failure in terms that hide the real cause.
+pub fn backend_for_worktree(path: &Path) -> Result, GitError> {
+ global_registry().resolve_or_default(worktree_backend_name(path)?)
+}
+
+/// The backend that owns `path` for operations that REWRITE its checkout
+/// (Go `destructiveBackendForWorktree`, vcs.go:513-525).
+///
+/// Unlike [`backend_for_worktree`] this REFUSES a markerless path. The
+/// fallback there is a convenience for reads; here it would mean resetting the
+/// repository ENCLOSING the pool — the user's own working tree, in the
+/// supported in-project layout. Callers guard markerless slots first; this
+/// refusal is defense in depth, and it is the reason pool slots are the only
+/// callers of these operations.
+pub fn destructive_backend_for_worktree(path: &Path) -> Result, GitError> {
+ let name = require_destructive_marker(path)?;
+ global_registry().resolve_or_default(Some(name))
+}
+
+/// The refusal a SELECTED backend earns by being unable to serve `path`.
+///
+/// A raw failure from inside the seam answers a question the user did not ask:
+/// it names the command that happened to run, not the choice that sent it
+/// there. Someone who selected jj in a `.jj`-only repository cannot tell from
+/// `fatal: not a git repository` that jj was the backend they picked — the
+/// fatal came from git, which the selection never chose.
+///
+/// So the message leads with the SELECTION, quotes the underlying cause, and
+/// closes with the remedy. It never proposes falling back to another backend:
+/// answering a jj selection with git is the misroute this module exists to
+/// prevent ([`BackendRegistry::resolve_or_default`],
+/// [`destructive_backend_for_worktree`]), and offering it here would make the
+/// same confusion reachable from the error path instead of only from a bug.
+fn backend_cannot_serve(selected: &str, path: &Path, cause: &GitError) -> GitError {
+ let remedy = if selected == BACKEND_JJ {
+ "make jj available (it must be on PATH, or set JJ_BIN), or drop the jj opt-in \
+ (TREEHOUSE_VCS, or `vcs = \"jj\"` in treehouse.toml) to use git"
+ } else {
+ "run this inside a git repository, or select a backend that serves it with the \
+ `vcs` key in treehouse.toml"
+ };
+ GitError::new(
+ "resolve repository root",
+ format!(
+ "the {selected} backend is selected for {} but cannot serve it: {}. \
+ Treehouse will not fall back to another backend here, so nothing was \
+ created or changed. To fix it, {remedy}.",
+ path.display(),
+ cause.message,
+ ),
+ cause.kind,
+ )
+}
+
+impl BackendRegistry {
+ /// The repository root as the CONFIGURED backend for `path` sees it (Go
+ /// `FindRepoRootFrom` behind `BackendNameFor`).
+ ///
+ /// The dispatch twin of [`configured_backend_name`]: that function answers
+ /// "which backend?", this one asks that backend and reports its answer. Both
+ /// are needed, because a caller that resolved the root with git directly —
+ /// as the CLI's repository-context loader does — cannot serve a repository
+ /// git does not own, and reports git's fatal as though git had been chosen.
+ ///
+ /// Fail CLOSED on every failure, including resolution: an unregistered or
+ /// unavailable backend is reported through [`backend_cannot_serve`], never
+ /// answered by whichever repository encloses `path`. Falls back to no
+ /// backend at all.
+ pub fn repo_root_for(
+ &self,
+ path: &Path,
+ override_name: Option<&str>,
+ ) -> Result {
+ let selected = configured_backend_name(path, override_name)?;
+ let backend = self
+ .resolve_or_default(Some(selected))
+ .map_err(|e| backend_cannot_serve(selected, path, &e))?;
+ backend
+ .repo_root(path)
+ .map_err(|e| backend_cannot_serve(selected, path, &e))
+ }
+}
+
+/// [`BackendRegistry::repo_root_for`] against the process-wide registry.
+///
+/// This is the entry point a repository-context loader should call instead of
+/// asking the git backend directly: it is what makes a jj selection actually
+/// reach jj for root resolution, and what turns an unserviceable repository
+/// into a named refusal rather than a raw fatal.
+pub fn configured_repo_root(path: &Path, override_name: Option<&str>) -> Result {
+ global_registry().repo_root_for(path, override_name)
+}
+
+// ─── The guarded-reset pair ───────────────────────────────────────────────────
+
+/// What a safety check proved, and what a reset must therefore be given.
+///
+/// The two SHAs travel together or not at all. `reset_ref` is the immutable
+/// commit the reset targets; `head` is the HEAD the ancestry check was run
+/// against. Passing a `head` from a different check is exactly the bug the
+/// pair exists to make impossible, so both are captured together here rather
+/// than re-derived at reset time.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ResetGuard {
+ /// Whether HEAD is reachable from `reset_ref` — i.e. the reset discards
+ /// nothing. `false` is a legitimate answer the caller may weigh; an `Err`
+ /// from the check is NOT one.
+ pub safe: bool,
+ /// The resolved commit to reset to.
+ pub reset_ref: String,
+ /// The worktree HEAD recorded at check time.
+ pub head: String,
+}
+
+/// Whether `s` is a full lowercase hex object id (Go `isCommitID`).
+///
+/// Both the check and the reset validate their inputs with this. A ref name
+/// smuggled into `reset_ref` would let `git read-tree --reset -u` move the
+/// worktree to whatever the caller named — the caller of the reset is the
+/// safety check, so the reset must not trust it to have been careful.
+fn is_commit_id(s: &str) -> bool {
+ !s.is_empty()
+ && s.len() <= 64
+ && s.bytes()
+ .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
+}
+
+/// The `command` half of a [`GitError`] raised by a git invocation.
+///
+/// [`GitError`]'s `Display` supplies the tool name itself
+/// (`#[error("git {command}: {message}")]`), so `command` carries the ARGUMENT
+/// LIST alone. Spelling the program name into `command` as well is what
+/// rendered `git git rev-parse --show-toplevel: fatal: …` — the second "git"
+/// came from the display, the first from the caller naming the same program
+/// twice.
+///
+/// One definition for every caller that labels a git failure, so the rule has
+/// one place to regress; `crate::pool`'s own git helpers share it.
+///
+/// Every call site passes a non-empty argument list, so the empty join (which
+/// would render as a bare `git : …`) cannot arise here.
+pub(crate) fn git_label(args: &[&str]) -> String {
+ args.join(" ")
+}
+
+/// Runs `git ` in `worktree`, returning trimmed stdout.
+fn git_out(git_bin: &Path, worktree: &Path, args: &[&str]) -> Result {
+ let output = std::process::Command::new(git_bin)
+ .args(args)
+ .current_dir(worktree)
+ .output()
+ .map_err(|e| GitError::new(git_label(args), e.to_string(), GitErrorKind::Other))?;
+ if !output.status.success() {
+ return Err(GitError::new(
+ git_label(args),
+ String::from_utf8_lossy(&output.stderr).trim().to_string(),
+ GitErrorKind::Other,
+ ));
+ }
+ Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
+}
+
+/// Runs `git ` in `worktree`, discarding stdout.
+fn git_run(git_bin: &Path, worktree: &Path, args: &[&str]) -> Result<(), GitError> {
+ git_out(git_bin, worktree, args).map(|_| ())
+}
+
+/// `git rev-parse --verify [^{commit}` — the immutable commit `ref` names.
+fn ref_commit(git_bin: &Path, worktree: &Path, reference: &str) -> Result] {
+ let spec = format!("{reference}^{{commit}}");
+ let sha = git_out(
+ git_bin,
+ worktree,
+ &["rev-parse", "--verify", "--quiet", spec.as_str()],
+ )?;
+ if !is_commit_id(&sha) {
+ return Err(GitError::new(
+ git_label(&["rev-parse", "--verify", &spec]),
+ format!("{reference} did not resolve to a commit id"),
+ GitErrorKind::MergeRefUnresolvable,
+ ));
+ }
+ Ok(sha)
+}
+
+/// `git rev-parse --verify HEAD^{commit}` (Go `worktreeHead`).
+fn worktree_head(git_bin: &Path, worktree: &Path) -> Result {
+ ref_commit(git_bin, worktree, "HEAD")
+}
+
+/// The absolute path of a per-worktree git file (Go `gitPath`).
+///
+/// For a linked worktree this resolves INSIDE `/.git/worktrees/`,
+/// which is the point: a markerless path would resolve the enclosing
+/// repository's git dir instead, so this is only ever called after
+/// [`require_destructive_marker`] has passed.
+fn git_path(git_bin: &Path, worktree: &Path, name: &str) -> Result {
+ if let Ok(out) = git_out(
+ git_bin,
+ worktree,
+ &["rev-parse", "--path-format=absolute", "--git-path", name],
+ ) {
+ return Ok(PathBuf::from(out));
+ }
+ // Older git without --path-format=absolute: compose the absolute git dir
+ // with the relative path git reports.
+ let git_dir = git_out(git_bin, worktree, &["rev-parse", "--absolute-git-dir"])?;
+ let rel = git_out(git_bin, worktree, &["rev-parse", "--git-path", name])?;
+ Ok(PathBuf::from(git_dir).join(rel))
+}
+
+/// Reports whether `worktree` can be reset to `branch` without discarding
+/// committed work, and captures the (reset target, HEAD) pair the reset must
+/// reuse (Go `gitvcs.IsWorktreeSafeToReset`).
+///
+/// Fails CLOSED: an unresolvable ref, an unreadable HEAD, or a git failure is
+/// an `Err`, and callers treat that as "not safe". `safe == false` is a real
+/// answer the caller may weigh against other evidence; `Err` means the question
+/// could not be answered at all.
+///
+/// `git_bin` is passed in rather than discovered here so the guard can never
+/// act on a different git than the one that owns the worktree — a backend impl
+/// passes its own binary.
+pub fn is_worktree_safe_to_reset(
+ git_bin: &Path,
+ git: &dyn GitBackend,
+ worktree: &Path,
+ branch: &str,
+) -> Result {
+ // The marker precondition, so a check never succeeds for a path whose
+ // "HEAD" belongs to some enclosing repository.
+ require_destructive_marker(worktree)?;
+
+ // resolveResetRef (gitvcs.go:1058-1065): the branch's ref, resolved ONCE to
+ // a commit. Pinning the SHA is what makes the reset target immutable: a ref
+ // that moved between check and reset would be reset to something that was
+ // never verified.
+ let repo_root = git
+ .repo_root(worktree)
+ .unwrap_or_else(|_| worktree.to_path_buf());
+ let reference = git.branch_ref(
+ &GitRepo {
+ common_dir: repo_root,
+ worktree: None,
+ },
+ branch,
+ );
+ let reset_ref = ref_commit(git_bin, worktree, &reference)?;
+ let head = worktree_head(git_bin, worktree)?;
+ let safe = git.is_head_merged_into_ref(worktree, &reset_ref)?;
+
+ Ok(ResetGuard {
+ safe,
+ reset_ref,
+ head,
+ })
+}
+
+/// Resets `worktree` to an ALREADY RESOLVED commit, re-verifying first (Go
+/// `gitvcs.ResetWorktreeToRef`).
+///
+/// `expected_head` is the HEAD [`is_worktree_safe_to_reset`] recorded. The
+/// re-read and the destructive update both run while holding git's own
+/// `HEAD.lock` (created `O_CREAT|O_EXCL`), so a concurrent commit, checkout,
+/// merge, or rebase cannot create that lock and cannot slip a new commit in
+/// after the comparison. When `require_clean` is set, dirtiness is re-checked
+/// under that same lock before the tree is touched, so uncommitted work that
+/// landed after the caller's own dirty check is not overwritten.
+///
+/// REFUSES — before touching anything — if the marker is gone, if either
+/// argument is not a commit id, if HEAD moved, or if `require_clean` and the
+/// tree is dirty. A plain reset without that re-read is worse than no reset:
+/// callers believe the safety check and the reset shared one target, so a
+/// commit landing in the gap is silently discarded while every guard reports
+/// success.
+pub fn reset_worktree_to_ref(
+ git_bin: &Path,
+ git: &dyn GitBackend,
+ worktree: &Path,
+ reset_ref: &str,
+ expected_head: &str,
+ require_clean: bool,
+) -> Result<(), GitError> {
+ require_destructive_marker(worktree)?;
+
+ if !is_commit_id(expected_head) || !is_commit_id(reset_ref) {
+ return Err(GitError::new(
+ "worktree reset",
+ "worktree reset requires resolved commit IDs",
+ GitErrorKind::Other,
+ ));
+ }
+
+ let head_path = git_path(git_bin, worktree, "HEAD")?;
+ let mut lock_name = head_path.clone().into_os_string();
+ lock_name.push(".lock");
+ let lock_path = PathBuf::from(lock_name);
+
+ // O_CREAT|O_EXCL: git holds this lock for anything that changes HEAD, so
+ // failing to create it means another git process is mid-operation and we
+ // must not race it.
+ let lock = std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(&lock_path)
+ .map_err(|e| {
+ GitError::new(
+ "lock worktree HEAD",
+ format!("cannot lock worktree HEAD: {e}"),
+ GitErrorKind::Other,
+ )
+ })?;
+
+ // Every early return below must take the lock file with it: a leaked
+ // HEAD.lock wedges every later git command in this worktree. Go holds the
+ // same defer.
+ let mut release = HeadLock {
+ lock_file: lock,
+ lock_path: &lock_path,
+ committed: false,
+ };
+
+ let head = worktree_head(git_bin, worktree)?;
+ if head != expected_head {
+ return Err(GitError::new(
+ "worktree reset",
+ format!("worktree HEAD changed since safety check: was {expected_head}, now {head}"),
+ GitErrorKind::Other,
+ ));
+ }
+
+ if require_clean {
+ match git.is_dirty(worktree) {
+ Ok(true) => {
+ return Err(GitError::new(
+ "worktree reset",
+ "worktree became dirty after safety check",
+ GitErrorKind::Other,
+ ));
+ }
+ Ok(false) => {}
+ Err(e) => return Err(e),
+ }
+ }
+
+ // read-tree/clean update the working tree without needing HEAD.lock, so
+ // they are safe to run under it. HEAD itself is committed last, by
+ // renaming the lock file onto HEAD — the same protocol git uses.
+ git_run(
+ git_bin,
+ worktree,
+ &["read-tree", "--reset", "-u", reset_ref],
+ )?;
+ git_run(git_bin, worktree, &["clean", "-fd"])?;
+
+ {
+ use std::io::Write;
+ let mut file = &release.lock_file;
+ writeln!(file, "{reset_ref}")
+ .map_err(|e| GitError::new("worktree reset", e.to_string(), GitErrorKind::Other))?;
+ file.sync_all()
+ .map_err(|e| GitError::new("worktree reset", e.to_string(), GitErrorKind::Other))?;
+ }
+
+ install_head(&lock_path, &head_path).map_err(|e| {
+ GitError::new(
+ "worktree reset",
+ format!("installing worktree HEAD: {e}"),
+ GitErrorKind::Other,
+ )
+ })?;
+ release.committed = true;
+ Ok(())
+}
+
+/// Renames `lock_path` onto `head_path` (Go `replaceFile`).
+///
+/// Plain `fs::rename` is correct only where rename replaces an existing
+/// destination. On Windows it does NOT: `HEAD` is always present, so the
+/// rename would fail and every reset would leave the worktree half-updated —
+/// tree rewritten, HEAD still pointing at the old commit. `MoveFileExW` with
+/// `REPLACE_EXISTING | WRITE_THROUGH` is the same call Go makes, and
+/// `windows-sys` is already a Windows-only dependency of this crate.
+#[cfg(not(windows))]
+fn install_head(lock_path: &Path, head_path: &Path) -> std::io::Result<()> {
+ std::fs::rename(lock_path, head_path)
+}
+
+#[cfg(windows)]
+fn install_head(lock_path: &Path, head_path: &Path) -> std::io::Result<()> {
+ use std::os::windows::ffi::OsStrExt;
+ use windows_sys::Win32::Storage::FileSystem::{
+ MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
+ };
+
+ let wide = |p: &Path| {
+ p.as_os_str()
+ .encode_wide()
+ .chain(Some(0))
+ .collect::>()
+ };
+ let (from, to) = (wide(lock_path), wide(head_path));
+ let ok = unsafe {
+ 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(())
+}
+
+/// Small RAII holder so the lock file is removed on every early return.
+struct HeadLock<'a> {
+ lock_file: std::fs::File,
+ lock_path: &'a Path,
+ /// Set once the lock file has been renamed onto HEAD, at which point
+ /// removing `lock_path` would delete nothing but must not be attempted.
+ committed: bool,
+}
+
+impl Drop for HeadLock<'_> {
+ fn drop(&mut self) {
+ if !self.committed {
+ let _ = std::fs::remove_file(self.lock_path);
+ }
+ }
+}
+
+// ─── Seed inventory validation ───────────────────────────────────────────────
+
+/// Validates a trusted seed inventory before anything is deleted on its word.
+///
+/// Go `removeSeededPaths` (gitvcs.go:952-966) applies the same rules, and they
+/// are the only thing standing between a state file and an arbitrary path
+/// deletion. Each entry must be a clean, relative, single-slash path whose
+/// first segment is neither `.git` nor `.jj`: `../x` and `a/../../x` escape the
+/// worktree, an absolute path names somewhere else entirely, a backslash is a
+/// separator on Windows, and a NUL truncates at the syscall boundary.
+///
+/// An EMPTY inventory is refused too. Go's rule is that a nil inventory does
+/// not authorize ignored-file deletion; for this port a reset that arrives
+/// with no recorded inventory but still asks to delete seeded paths is a
+/// caller that lost its bookkeeping, and honoring it would convert a lost
+/// record into silent data loss. An empty list is meaningless by definition.
+pub fn validate_seed_inventory(seeded: &[String]) -> Result<(), GitError> {
+ if seeded.is_empty() {
+ return Err(GitError::new(
+ "validate seeded paths",
+ "empty seed inventory does not authorize ignored-file deletion",
+ GitErrorKind::Other,
+ ));
+ }
+ for name in seeded {
+ let components: Vec<&str> = name.split('/').collect();
+ // The inventory is git's path language, NOT a host path: git spells
+ // every separator as '/' on every platform. So "absolute" here means
+ // "starts with /" — the platform-independent `path.IsAbs` Go uses
+ // (gitvcs.go:959). `Path::is_absolute()` is the WRONG test: on Windows
+ // it is `has_root() && prefix().is_some()`, so a rooted-but-prefixless
+ // name like "/etc/passwd" is not absolute, passes validation here, and
+ // then `worktree.join("/etc/passwd")` drops everything after the
+ // drive prefix and resolves to C:\etc\passwd — outside the worktree.
+ // That turned a gate on deleting ignored files inside the worktree
+ // into one that could name files outside it.
+ let clean = !name.is_empty()
+ && !name.contains('\\')
+ && !name.contains('\0')
+ && !name.starts_with('/')
+ && !components.iter().any(|c| *c == "." || *c == "..")
+ && !components.is_empty();
+ if !clean {
+ return Err(invalid_seed_path(name));
+ }
+ let first = components[0];
+ if first.eq_ignore_ascii_case(".git") || first.eq_ignore_ascii_case(".jj") {
+ return Err(invalid_seed_path(name));
+ }
+ }
+ Ok(())
+}
+
+fn invalid_seed_path(name: &str) -> GitError {
+ GitError::new(
+ "validate seeded paths",
+ format!("invalid seeded path {name:?}"),
+ GitErrorKind::Other,
+ )
+}
+
+/// The standard "this backend does not do that" refusal (Go has no equivalent:
+/// every method is implemented by both backends).
+///
+/// One message for all of them so an operator reading a log can tell "treehouse
+/// asked for a capability the selected backend does not have" apart from "the
+/// capability failed" — the two have completely different remedies.
+///
+/// Takes the backend NAME rather than `&dyn GitBackend` because it is called
+/// from default trait methods, where `Self: ?Sized` forbids the coercion.
+pub(crate) fn unsupported(backend_name: &str, operation: &str) -> GitError {
+ GitError::new(
+ operation,
+ format!("the {backend_name} backend does not support {operation}"),
+ GitErrorKind::Other,
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::process::Command;
+
+ /// A real repo with one commit, so the guarded-reset tests exercise the
+ /// same plumbing a pool slot does.
+ fn repo() -> (tempfile::TempDir, PathBuf) {
+ let dir = tempfile::tempdir().unwrap();
+ let root = dir.path().to_path_buf();
+ let run = |args: &[&str]| {
+ let out = Command::new("git")
+ .args(args)
+ .current_dir(&root)
+ .output()
+ .unwrap();
+ assert!(out.status.success(), "git {args:?}: {out:?}");
+ };
+ run(&["init", "-q", "-b", "main"]);
+ run(&["config", "user.email", "t@example.com"]);
+ run(&["config", "user.name", "t"]);
+ // Keep tracked-file byte assertions independent of the host's checkout
+ // conversion: GitHub's Windows runners set core.autocrlf=true, which
+ // would write CRLF into the worktree and break the "restores tracked
+ // content" assertions. Git's default elsewhere is no conversion.
+ run(&["config", "core.autocrlf", "false"]);
+ std::fs::write(root.join("a.txt"), "one\n").unwrap();
+ run(&["add", "-A"]);
+ run(&["commit", "-q", "-m", "one"]);
+ (dir, root)
+ }
+
+ fn git_backend() -> ShellGitBackend {
+ ShellGitBackend::discover().expect("git must be installed")
+ }
+
+ /// A path reduced to the one spelling a backend and `canonicalize` agree
+ /// on, so two routes naming the same directory compare equal.
+ ///
+ /// `repo_root` is "whatever the backend reports", and on Windows the two
+ /// backends spell a path differently by construction: `git rev-parse
+ /// --show-toplevel` prints `C:/repo`, while `std::fs::canonicalize`
+ /// answers the extended-length `\\?\C:\repo`. Both name one directory, so
+ /// it is the COMPARISON that gets normalized here — not the product, whose
+ /// git route is deliberately the backend's own output (Go `gitvcs.go:30`).
+ fn comparable_path(path: &Path) -> String {
+ path.to_string_lossy()
+ .replace("\\\\?\\", "")
+ .replace('\\', "/")
+ }
+
+ // ── marker inspection ────────────────────────────────────────────────────
+
+ #[test]
+ fn a_git_worktree_resolves_to_git() {
+ let (_d, root) = repo();
+ assert_eq!(worktree_backend_name(&root).unwrap(), Some(BACKEND_GIT));
+ }
+
+ #[test]
+ fn a_jj_workspace_resolves_to_jj() {
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::create_dir(dir.path().join(".jj")).unwrap();
+ assert_eq!(worktree_backend_name(dir.path()).unwrap(), Some(BACKEND_JJ));
+ }
+
+ #[test]
+ fn a_jj_file_is_not_a_workspace_marker() {
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::write(dir.path().join(".jj"), "not a workspace").unwrap();
+ assert_eq!(worktree_backend_name(dir.path()).unwrap(), None);
+ }
+
+ #[test]
+ fn a_markerless_path_is_absent_not_an_error() {
+ let dir = tempfile::tempdir().unwrap();
+ assert_eq!(worktree_backend_name(dir.path()).unwrap(), None);
+ }
+
+ #[test]
+ fn a_dangling_git_marker_is_a_read_failure_not_an_absent_marker() {
+ // The distinction the whole safety argument rests on: an unresolvable
+ // marker must never read as "no marker", or the slot is classified
+ // damaged instead of unreadable and handled by a different code path.
+ let dir = tempfile::tempdir().unwrap();
+ let target = dir.path().join("gone");
+ let link = dir.path().join(".git");
+ #[cfg(unix)]
+ std::os::unix::fs::symlink(&target, &link).unwrap();
+ #[cfg(windows)]
+ std::os::windows::fs::symlink_file(&target, &link).unwrap();
+
+ let err =
+ worktree_backend_name(dir.path()).expect_err("dangling marker must be a read error");
+ assert!(
+ err.message.contains("resolving .git marker"),
+ "got: {}",
+ err.message
+ );
+ }
+
+ #[test]
+ fn a_dangling_jj_marker_is_a_read_failure_too() {
+ let dir = tempfile::tempdir().unwrap();
+ let target = dir.path().join("gone");
+ #[cfg(unix)]
+ std::os::unix::fs::symlink(&target, dir.path().join(".jj")).unwrap();
+ #[cfg(windows)]
+ std::os::windows::fs::symlink_file(&target, dir.path().join(".jj")).unwrap();
+
+ assert!(worktree_backend_name(dir.path()).is_err());
+ }
+
+ // ── destructive dispatch ─────────────────────────────────────────────────
+
+ #[test]
+ fn a_git_worktree_dispatches_to_the_registered_git_backend() {
+ let (_d, root) = repo();
+ let b = destructive_backend_for_worktree(&root).expect("git slot must resolve");
+ assert_eq!(b.name(), BACKEND_GIT);
+ }
+
+ #[test]
+ fn destructive_dispatch_refuses_a_markerless_path() {
+ // The behavior this whole seam exists for: a markerless path nested
+ // inside a real repo is the in-project pool layout, where git would
+ // walk UP and reset the user's own working tree.
+ let (_d, root) = repo();
+ let slot = root.join("slot");
+ std::fs::create_dir_all(&slot).unwrap();
+
+ let err = destructive_backend_for_worktree(&slot)
+ .err()
+ .expect("a markerless slot must be refused, never answered by the enclosing repo");
+ assert!(
+ err.message.contains("no .git or .jj marker"),
+ "got: {}",
+ err.message
+ );
+ }
+
+ #[test]
+ fn read_dispatch_falls_back_to_git_for_a_markerless_path() {
+ // Reads are harmless to guess, so this one falls back like Go's
+ // backendForWorktree does.
+ let dir = tempfile::tempdir().unwrap();
+ let b = backend_for_worktree(dir.path()).expect("reads fall back to git");
+ assert_eq!(b.name(), BACKEND_GIT);
+ }
+
+ #[test]
+ fn dispatch_refuses_a_marker_naming_an_unregistered_backend() {
+ // A `.jj` workspace answered by git would run git commands in a non-git
+ // tree and report the failure in misleading terms.
+ //
+ // Probed on a PRIVATE, EMPTY registry rather than the global one. It
+ // used to assert that jj was unregistered, which made it a test of jj's
+ // REGISTRATION STATUS rather than of the safety rule it exists for —
+ // and that rule (a name with no handle is refused, never guessed) is
+ // what must survive. The global registry now seeds jj, so the same
+ // assertion there would be asserting the opposite of what it says.
+ let registry = BackendRegistry::new();
+ let err = registry
+ .resolve_or_default(Some(BACKEND_JJ))
+ .err()
+ .expect("a name with no registered backend must be refused");
+ assert!(
+ err.message.contains("no backend is registered"),
+ "got: {}",
+ err.message
+ );
+
+ // And the same rule through the public entry point: the global registry
+ // DOES answer a `.jj` marker, with jj.
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::create_dir(dir.path().join(".jj")).unwrap();
+ assert_eq!(backend_for_worktree(dir.path()).unwrap().name(), BACKEND_JJ);
+ assert_eq!(
+ destructive_backend_for_worktree(dir.path()).unwrap().name(),
+ BACKEND_JJ
+ );
+ }
+
+ #[test]
+ fn registering_jj_does_not_disturb_a_git_repository() {
+ // The whole opt-in argument for jj rests on this: seeding the backend
+ // into the global registry must leave every git path exactly as it was.
+ let (_d, root) = repo();
+ assert_eq!(backend_for_worktree(&root).unwrap().name(), BACKEND_GIT);
+ assert_eq!(
+ destructive_backend_for_worktree(&root).unwrap().name(),
+ BACKEND_GIT
+ );
+ // A colocated repository stays on git worktrees — `.git` is checked
+ // before `.jj`, so the colocated case cannot silently flip flavor.
+ std::fs::create_dir_all(root.join(".jj")).unwrap();
+ assert_eq!(worktree_backend_name(&root).unwrap(), Some(BACKEND_GIT));
+ assert_eq!(backend_for_worktree(&root).unwrap().name(), BACKEND_GIT);
+ }
+
+ #[test]
+ fn the_registry_is_extensible_without_touching_the_trait() {
+ let registry = BackendRegistry::new();
+ registry.register(BACKEND_GIT, Arc::new(git_backend()));
+ assert_eq!(registry.names(), vec![BACKEND_GIT]);
+ assert!(registry.get("nope").is_none());
+ assert!(registry.get(BACKEND_GIT).is_some());
+ }
+
+ #[test]
+ fn the_traits_default_dispatch_reaches_the_registry() {
+ // Every backend inherits registry dispatch for free, which is what
+ // lets the jj backend register without any call site changing. If this
+ // stops working, `resolve_for_worktree` silently starts refusing
+ // everything and the seam stops being a seam.
+ let (_d, root) = repo();
+ let git = git_backend();
+ assert_eq!(git.name(), BACKEND_GIT, "ShellGitBackend is git by default");
+
+ let resolved = git
+ .resolve_for_worktree(&root)
+ .expect("a git worktree must resolve through the registry");
+ assert_eq!(resolved.name(), BACKEND_GIT);
+
+ let slot = root.join("no-marker");
+ std::fs::create_dir_all(&slot).unwrap();
+ assert!(
+ git.resolve_for_worktree(&slot).is_err(),
+ "the default must refuse a markerless path, not answer it"
+ );
+ }
+
+ #[test]
+ fn an_unimplemented_capability_refuses_by_name() {
+ // `common_git_dir` and `seed_worktree` probed this while the seeding
+ // agent still had them outstanding. They are implemented now, so the
+ // refusal path is probed with the guarded-reset pair — the other half
+ // of the "declared now, defaults to a refusal" block, and still
+ // unwired on purpose: the pool calls the free functions in this module
+ // and does not dispatch through the trait yet (see the note in
+ // `pool.rs`). If an agent overrides these and this starts failing,
+ // that agent should move the probe, not delete it — the default
+ // refusal is a safety surface, not dead code.
+ let (_d, root) = repo();
+ let git = git_backend();
+ for err in [
+ git.is_worktree_safe_to_reset(&root, "main").err(),
+ git.reset_worktree_to_ref(&root, "deadbeef", "cafebabe", false)
+ .err(),
+ ] {
+ let err = err.expect("a backend that does not implement this must refuse");
+ assert!(
+ err.message.contains("does not support"),
+ "got: {}",
+ err.message
+ );
+ }
+ }
+
+ #[test]
+ fn an_empty_seed_inventory_is_refused_before_the_capability_check() {
+ // The refusal must name the lost bookkeeping, not "unsupported" —
+ // they are different bugs with different fixes.
+ let (_d, root) = repo();
+ let git = git_backend();
+ let err = git
+ .reset_worktree_with_seeded_paths(&root, "main", &[])
+ .expect_err("an empty inventory must refuse");
+ assert!(
+ err.message.contains("seed inventory"),
+ "got: {}",
+ err.message
+ );
+ }
+
+ // ── configured selection (the CLI's `--branch` refusal) ─────────────────
+
+ /// A colocated repository: `.git` and `.jj` together at one root.
+ fn colocated() -> (tempfile::TempDir, PathBuf) {
+ let dir = tempfile::tempdir().unwrap();
+ let root = dir.path().to_path_buf();
+ std::fs::create_dir(root.join(".git")).unwrap();
+ std::fs::create_dir(root.join(".jj")).unwrap();
+ (dir, root)
+ }
+
+ /// A `.jj`-only main workspace with a secondary workspace pointing at it,
+ /// laid out the way jj writes the pointer.
+ fn jj_only_main() -> (tempfile::TempDir, PathBuf, PathBuf) {
+ let dir = tempfile::tempdir().unwrap();
+ let main = dir.path().join("main");
+ std::fs::create_dir_all(main.join(".jj").join("repo")).unwrap();
+ let ws = dir.path().join("wt-1");
+ std::fs::create_dir_all(ws.join(".jj")).unwrap();
+ std::fs::write(ws.join(".jj").join("repo"), "../../main/.jj/repo").unwrap();
+ (dir, main, ws)
+ }
+
+ #[test]
+ fn no_marker_anywhere_up_the_tree_means_no_marker_root() {
+ // tempfile::tempdir lives under /var -> /private/var on macOS, and
+ // neither chain has a marker, so the walk reaches the filesystem root.
+ let dir = tempfile::tempdir().unwrap();
+ assert_eq!(marker_root(dir.path()), None);
+ }
+
+ #[test]
+ fn the_marker_root_is_the_nearest_ancestor_holding_a_marker() {
+ let (_d, root) = colocated();
+ let nested = root.join("a").join("b");
+ std::fs::create_dir_all(&nested).unwrap();
+
+ assert_eq!(
+ marker_root(&nested),
+ Some(MarkerRoot {
+ root: root.clone(),
+ has_git: true,
+ has_jj: true,
+ })
+ );
+ }
+
+ #[test]
+ fn a_jj_only_tree_is_distinguished_from_a_colocated_one() {
+ let (_d, _main, ws) = jj_only_main();
+ let found = marker_root(&ws).expect("a jj workspace is a marker root");
+ assert!(found.has_jj);
+ assert!(
+ !found.has_git,
+ "a pooled jj workspace is .jj-only — it has no .git at all"
+ );
+ }
+
+ #[test]
+ fn an_explicit_git_opt_in_wins_even_where_a_jj_directory_exists() {
+ let (_d, root) = colocated();
+ assert_eq!(
+ configured_backend_name(&root, Some(BACKEND_GIT)).unwrap(),
+ BACKEND_GIT
+ );
+ }
+
+ #[test]
+ fn a_jj_opt_in_only_takes_effect_where_a_jj_directory_exists() {
+ // The rule that makes a shell-wide `TREEHOUSE_VCS=jj` harmless: in a
+ // plain git repository the opt-in is silently ignored.
+ let (_d, root) = repo();
+ assert_eq!(
+ configured_backend_name(&root, Some(BACKEND_JJ)).unwrap(),
+ BACKEND_GIT
+ );
+
+ let (_d, colocated_root) = colocated();
+ assert_eq!(
+ configured_backend_name(&colocated_root, Some(BACKEND_JJ)).unwrap(),
+ BACKEND_JJ
+ );
+ }
+
+ #[test]
+ fn a_colocated_repository_stays_on_git_without_an_explicit_opt_in() {
+ let (_d, root) = colocated();
+ assert_eq!(
+ configured_backend_name(&root, None).unwrap(),
+ BACKEND_GIT,
+ "having a .jj directory is not consent; only configuration selects a backend"
+ );
+ }
+
+ #[test]
+ fn a_workspace_inherits_the_opt_in_from_the_main_repository_root() {
+ // A pooled jj workspace is a `.jj`-only checkout that cannot carry an
+ // untracked treehouse.toml, so the opt-in is read at the main root the
+ // `.jj/repo` pointer names.
+ let (_d, _main, ws) = jj_only_main();
+ assert_eq!(
+ configured_backend_name(&ws, Some(BACKEND_JJ)).unwrap(),
+ BACKEND_JJ
+ );
+ }
+
+ #[test]
+ fn a_path_outside_any_repository_answers_git_so_errors_surface_unchanged() {
+ let dir = tempfile::tempdir().unwrap();
+ assert_eq!(
+ configured_backend_name(dir.path(), None).unwrap(),
+ BACKEND_GIT
+ );
+ assert_eq!(
+ configured_backend_name(dir.path(), Some(BACKEND_JJ)).unwrap(),
+ BACKEND_GIT
+ );
+ }
+
+ #[test]
+ fn a_misconfigured_opt_in_is_ignored_rather_than_fatal() {
+ // A typo must not break every command in the repository.
+ let (_d, root) = colocated();
+ for junk in ["Jujutsu", "JJ", " jj", "jjj", "subversion"] {
+ assert_eq!(
+ configured_backend_name(&root, Some(junk)).unwrap(),
+ BACKEND_GIT,
+ "{junk:?} must be ignored, not honoured and not fatal"
+ );
+ }
+ }
+
+ // ── the guarded-reset pair ───────────────────────────────────────────────
+
+ #[test]
+ fn a_check_then_reset_on_a_clean_worktree_succeeds() {
+ // The shape pool reuse actually runs: prove, then reset under
+ // `require_clean` with the tree still clean.
+ let (_d, root) = repo();
+ let git = git_backend();
+ let bin = git.git_bin().to_path_buf();
+
+ let guard = is_worktree_safe_to_reset(&bin, &git, &root, "main").unwrap();
+ assert!(guard.safe, "a worktree at main is safe to reset to main");
+ assert!(is_commit_id(&guard.reset_ref) && is_commit_id(&guard.head));
+
+ reset_worktree_to_ref(&bin, &git, &root, &guard.reset_ref, &guard.head, true).unwrap();
+ assert!(!git.is_dirty(&root).unwrap());
+ }
+
+ #[test]
+ fn a_reset_restores_the_checkout_and_sweeps_untracked_files() {
+ // `require_clean=false` is the path Go's
+ // `ResetWorktreeWithSeededPaths` takes: the caller resolved HEAD itself
+ // and has already decided what to discard.
+ let (_d, root) = repo();
+ let wt = root.join("wt");
+ let git = git_backend();
+ let bin = git.git_bin().to_path_buf();
+
+ let added = Command::new(bin.as_os_str())
+ .args(["worktree", "add", "--detach"])
+ .arg(&wt)
+ .arg("main")
+ .current_dir(&root)
+ .output()
+ .unwrap();
+ assert!(added.status.success(), "{added:?}");
+
+ let guard = is_worktree_safe_to_reset(&bin, &git, &wt, "main").unwrap();
+ std::fs::write(wt.join("a.txt"), "clobbered\n").unwrap();
+ std::fs::write(wt.join("junk.txt"), "untracked\n").unwrap();
+
+ reset_worktree_to_ref(&bin, &git, &wt, &guard.reset_ref, &guard.head, false).unwrap();
+
+ assert_eq!(
+ std::fs::read_to_string(wt.join("a.txt")).unwrap(),
+ "one\n",
+ "read-tree --reset -u must restore tracked content"
+ );
+ assert!(
+ !wt.join("junk.txt").exists(),
+ "clean -fd must remove the untracked file"
+ );
+ assert!(!git.is_dirty(&wt).unwrap());
+ }
+
+ #[test]
+ fn a_check_on_a_worktree_ahead_of_base_is_not_safe() {
+ let (_d, root) = repo();
+ let git = git_backend();
+ let bin = git.git_bin().to_path_buf();
+ let run = |args: &[&str]| {
+ assert!(
+ Command::new(bin.as_os_str())
+ .args(args)
+ .current_dir(&root)
+ .output()
+ .unwrap()
+ .status
+ .success(),
+ "git {args:?}"
+ );
+ };
+ run(&["checkout", "-q", "-b", "ahead"]);
+ std::fs::write(root.join("b.txt"), "two\n").unwrap();
+ run(&["add", "-A"]);
+ run(&["commit", "-q", "-m", "two"]);
+
+ let guard = is_worktree_safe_to_reset(&bin, &git, &root, "main").unwrap();
+ assert!(!guard.safe, "unlanded work must never read as safe");
+ // The pair is still returned: a caller may weigh other evidence.
+ assert!(is_commit_id(&guard.reset_ref) && is_commit_id(&guard.head));
+ }
+
+ #[test]
+ fn a_reset_refuses_when_head_moved_since_the_check() {
+ // The single most important test in this file. A reset that does not
+ // re-verify is worse than none: every caller would report success
+ // while discarding a commit that landed in the gap.
+ let (_d, root) = repo();
+ let git = git_backend();
+ let bin = git.git_bin().to_path_buf();
+ let run = |args: &[&str]| {
+ assert!(
+ Command::new(bin.as_os_str())
+ .args(args)
+ .current_dir(&root)
+ .output()
+ .unwrap()
+ .status
+ .success(),
+ "git {args:?}"
+ );
+ };
+
+ let guard = is_worktree_safe_to_reset(&bin, &git, &root, "main").unwrap();
+
+ // Land a commit the check never saw.
+ run(&["checkout", "-q", "-b", "sneaky"]);
+ std::fs::write(root.join("b.txt"), "two\n").unwrap();
+ run(&["add", "-A"]);
+ run(&["commit", "-q", "-m", "two"]);
+
+ let err = reset_worktree_to_ref(&bin, &git, &root, &guard.reset_ref, &guard.head, true)
+ .expect_err("a moved HEAD must refuse");
+ assert!(
+ err.message.contains("HEAD changed since safety check"),
+ "got: {}",
+ err.message
+ );
+
+ // And the commit is still there — the refusal, not a lost commit.
+ assert!(root.join("b.txt").exists());
+ }
+
+ #[test]
+ fn a_reset_refuses_a_tree_that_became_dirty_after_the_check() {
+ let (_d, root) = repo();
+ let git = git_backend();
+ let bin = git.git_bin().to_path_buf();
+
+ let guard = is_worktree_safe_to_reset(&bin, &git, &root, "main").unwrap();
+ std::fs::write(root.join("a.txt"), "uncommitted work\n").unwrap();
+
+ let err = reset_worktree_to_ref(&bin, &git, &root, &guard.reset_ref, &guard.head, true)
+ .expect_err("dirty-after-check must refuse");
+ assert!(err.message.contains("became dirty"), "got: {}", err.message);
+ assert_eq!(
+ std::fs::read_to_string(root.join("a.txt")).unwrap(),
+ "uncommitted work\n",
+ "the refusal must not have written anything"
+ );
+ }
+
+ #[test]
+ fn a_reset_refuses_an_argument_that_is_not_a_commit_id() {
+ // A ref name smuggled into reset_ref would let read-tree move the
+ // worktree to something the safety check never verified.
+ let (_d, root) = repo();
+ let git = git_backend();
+ let bin = git.git_bin().to_path_buf();
+ let sha = "0000000000000000000000000000000000000000";
+
+ for bad in ["main", "refs/heads/main", "HEAD", "", "NOTAHEXSHA"] {
+ assert!(
+ reset_worktree_to_ref(&bin, &git, &root, bad, sha, true).is_err(),
+ "reset_ref {bad:?} must be refused"
+ );
+ assert!(
+ reset_worktree_to_ref(&bin, &git, &root, sha, bad, true).is_err(),
+ "expected_head {bad:?} must be refused"
+ );
+ }
+ }
+
+ #[test]
+ fn a_reset_refuses_a_markerless_path() {
+ // Inside a real repo, so the only thing standing between this and the
+ // user's working tree is the marker precondition.
+ let (_d, root) = repo();
+ let git = git_backend();
+ let bin = git.git_bin().to_path_buf();
+ let nested = root.join("not-a-worktree");
+ std::fs::create_dir_all(&nested).unwrap();
+
+ let err = reset_worktree_to_ref(
+ &bin,
+ &git,
+ &nested,
+ "0000000000000000000000000000000000000000",
+ "0000000000000000000000000000000000000000",
+ true,
+ )
+ .expect_err("a markerless path must be refused");
+ assert!(
+ err.message.contains("no .git or .jj marker"),
+ "got: {}",
+ err.message
+ );
+ }
+
+ #[test]
+ fn a_refused_reset_leaves_no_head_lock_behind() {
+ // A leaked HEAD.lock wedges every later git command in the worktree,
+ // so the early-return path has to clean up.
+ let (_d, root) = repo();
+ let git = git_backend();
+ let bin = git.git_bin().to_path_buf();
+
+ let guard = is_worktree_safe_to_reset(&bin, &git, &root, "main").unwrap();
+ let _ = reset_worktree_to_ref(&bin, &git, &root, &guard.reset_ref, &guard.head, true);
+
+ let head = git_path(&bin, &root, "HEAD").unwrap();
+ let mut lock = head.into_os_string();
+ lock.push(".lock");
+ assert!(!Path::new(&lock).exists(), "HEAD.lock leaked");
+ }
+
+ // ── seed inventory ───────────────────────────────────────────────────────
+
+ #[test]
+ fn an_empty_seed_inventory_authorizes_no_deletion() {
+ assert!(validate_seed_inventory(&[]).is_err());
+ }
+
+ #[test]
+ fn traversal_and_metadata_paths_are_refused() {
+ for bad in [
+ "../escape",
+ "a/../../escape",
+ "/etc/passwd",
+ ".git/config",
+ ".jj/repo/store/git_target",
+ "./relative",
+ "a/./b",
+ "back\\slash",
+ "",
+ ] {
+ assert!(
+ validate_seed_inventory(&[bad.to_string()]).is_err(),
+ "{bad:?} must be refused"
+ );
+ }
+ }
+
+ #[test]
+ fn ordinary_seed_paths_are_accepted() {
+ assert!(
+ validate_seed_inventory(&[".env".to_string(), ".vscode/settings.json".to_string()])
+ .is_ok()
+ );
+ }
+
+ // ── backend selection for repository-root resolution ─────────────────────
+
+ /// A jj that records its argv and answers `workspace root` with `root`, or
+ /// fails every command when `failure` is set.
+ struct RecordingJj {
+ root: PathBuf,
+ failure: Option<&'static str>,
+ calls: std::sync::Mutex>>,
+ }
+
+ impl RecordingJj {
+ fn serving(root: PathBuf) -> Arc {
+ Arc::new(Self {
+ root,
+ failure: None,
+ calls: std::sync::Mutex::new(Vec::new()),
+ })
+ }
+
+ fn failing(root: PathBuf) -> Arc {
+ Arc::new(Self {
+ root,
+ failure: Some("there is no jj workspace at that path"),
+ calls: std::sync::Mutex::new(Vec::new()),
+ })
+ }
+ }
+
+ impl jj::JjRunner for RecordingJj {
+ fn run(&self, cwd: &Path, args: &[&str]) -> Result {
+ self.calls
+ .lock()
+ .unwrap()
+ .push(args.iter().map(|s| s.to_string()).collect());
+ if let Some(message) = self.failure {
+ return Err(GitError::new("jj", message, GitErrorKind::NotFound));
+ }
+ match args {
+ ["--color", "never", "workspace", "root"] => {
+ Ok(self.root.to_string_lossy().into_owned())
+ }
+ _ => Err(GitError::new(
+ "jj",
+ format!("unexpected argv {args:?} in {}", cwd.display()),
+ GitErrorKind::Other,
+ )),
+ }
+ }
+ }
+
+ /// A `.jj`-only tree: a workspace with no colocated git repository.
+ fn jj_only_tree() -> tempfile::TempDir {
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::create_dir(dir.path().join(".jj")).unwrap();
+ dir
+ }
+
+ #[test]
+ fn a_jj_selection_reaches_jj_and_never_git() {
+ let tree = jj_only_tree();
+ let runner = RecordingJj::serving(tree.path().to_path_buf());
+ let registry = BackendRegistry::new();
+ registry.register(
+ BACKEND_JJ,
+ Arc::new(jj::JjBackend::with_runner(runner.clone())),
+ );
+
+ let root = registry
+ .repo_root_for(tree.path(), Some(BACKEND_JJ))
+ .expect("jj must serve a .jj-only tree it was selected for");
+
+ assert_eq!(root, std::fs::canonicalize(tree.path()).unwrap());
+ // The whole point: dispatch reached jj. A git-rooted loader would have
+ // shelled out to git and reported its fatal instead of answering.
+ assert!(
+ runner
+ .calls
+ .lock()
+ .unwrap()
+ .iter()
+ .any(|c| c.ends_with(&["workspace".to_string(), "root".to_string()])),
+ "jj must have been asked for the workspace root, calls: {:?}",
+ runner.calls.lock().unwrap()
+ );
+ }
+
+ #[test]
+ fn an_unserviceable_repository_is_refused_by_name_with_a_remedy() {
+ let tree = jj_only_tree();
+ let registry = BackendRegistry::new();
+ // jj registered, but jj is what cannot serve the repository — the shape
+ // of "jj is selected and jj is not available here".
+ registry.register(
+ BACKEND_JJ,
+ Arc::new(jj::JjBackend::with_runner(RecordingJj::failing(
+ tree.path().to_path_buf(),
+ ))),
+ );
+
+ let err = registry
+ .repo_root_for(tree.path(), Some(BACKEND_JJ))
+ .expect_err("a repository jj cannot serve must be refused");
+
+ let rendered = err.to_string();
+ assert!(
+ rendered.contains(BACKEND_JJ),
+ "must name the backend: {rendered}"
+ );
+ assert!(
+ rendered.contains("will not fall back"),
+ "must fail closed: {rendered}"
+ );
+ // The cause is quoted, not swallowed: the operator still sees what jj
+ // actually said, and this string is jj's, not ours.
+ assert!(
+ rendered.contains("there is no jj workspace at that path"),
+ "must quote the cause: {rendered}"
+ );
+ // And the remedy is OURS — it names JJ_BIN, which the cause above does
+ // not, so this can only have come from `backend_cannot_serve`.
+ assert!(
+ rendered.contains("JJ_BIN"),
+ "must give a remedy: {rendered}"
+ );
+ // The kind travels with the refusal, so callers classifying on it see
+ // "the backend was missing", not a generic failure.
+ assert_eq!(err.kind, GitErrorKind::NotFound);
+ }
+
+ #[test]
+ fn a_missing_jj_backend_is_named_rather_than_reported_as_unregistered() {
+ let tree = jj_only_tree();
+ let registry = BackendRegistry::new();
+ // NO jj registered. The refusal must still name jj and offer the
+ // remedy — "no backend is registered for jj" sends the operator
+ // looking for a configuration problem that does not exist.
+ let err = registry
+ .repo_root_for(tree.path(), Some(BACKEND_JJ))
+ .expect_err("an unregistered jj must be refused");
+
+ let rendered = err.to_string();
+ assert!(
+ rendered.contains(BACKEND_JJ),
+ "must name the backend: {rendered}"
+ );
+ assert!(
+ rendered.contains("will not fall back"),
+ "must fail closed: {rendered}"
+ );
+ assert!(
+ rendered.contains("JJ_BIN"),
+ "must give a remedy: {rendered}"
+ );
+ }
+
+ #[test]
+ fn a_git_selection_still_resolves_an_ordinary_repository() {
+ // The non-jj path must be untouched: `repo_root_for` is a new entry
+ // point, and adopting it must not change what a git repository does.
+ let (_d, root) = repo();
+ let resolved = global_registry()
+ .repo_root_for(&root, None)
+ .expect("git must serve its own repository");
+ // Compared, not asserted byte-equal: `canonicalize` answers the Windows
+ // verbatim form and git answers its own forward-slash spelling. Both
+ // name the repo root, and the test's claim is about WHICH root, not how
+ // it is written.
+ assert_eq!(
+ comparable_path(&resolved),
+ comparable_path(&std::fs::canonicalize(&root).unwrap())
+ );
+ }
+
+ #[test]
+ fn a_jj_selection_outside_a_jj_tree_still_answers_git() {
+ // `configured_backend_name` only honours a jj opt-in where a `.jj`
+ // directory exists. That rule is what makes a shell-wide
+ // `TREEHOUSE_VCS=jj` harmless, and it must survive the new entry point.
+ let (_d, root) = repo();
+ let registry = BackendRegistry::new();
+ registry.register(
+ BACKEND_JJ,
+ Arc::new(jj::JjBackend::with_runner(RecordingJj::serving(
+ root.clone(),
+ ))),
+ );
+ assert_eq!(
+ comparable_path(®istry.repo_root_for(&root, Some(BACKEND_JJ)).unwrap()),
+ comparable_path(&std::fs::canonicalize(&root).unwrap()),
+ "a git tree with a stray jj opt-in must still be served by git"
+ );
+ }
+
+ // ── error labels ─────────────────────────────────────────────────────────
+
+ #[test]
+ fn a_git_error_label_names_the_program_exactly_once() {
+ // Regression: the label used to render `git git rev-parse
+ // --show-toplevel: …`, because the caller spelled the program name into
+ // the `command` field and `GitError`'s Display spells it again.
+ let rendered = GitError::new(
+ git_label(&["rev-parse", "--show-toplevel"]),
+ "fatal: not a git repository",
+ GitErrorKind::Other,
+ )
+ .to_string();
+ assert_eq!(
+ rendered,
+ "git rev-parse --show-toplevel: fatal: not a git repository"
+ );
+ }
+
+ #[test]
+ fn a_real_git_failure_through_this_seam_labels_the_program_once() {
+ // The label test above proves the string helper; this one proves the
+ // helper is what the seam actually reports, by running the real git
+ // binary and letting it fail. A `.git` FILE (rather than a directory)
+ // satisfies the marker precondition and then fails every git command —
+ // the same shape as a damaged checkout.
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::write(dir.path().join(".git"), "gitdir: /gone\n").unwrap();
+
+ let git = git_backend();
+ let err = is_worktree_safe_to_reset(git.git_bin(), &git, dir.path(), "main")
+ .expect_err("a non-repository carrying a .git marker must fail the check");
+
+ let rendered = err.to_string();
+ assert!(
+ rendered.starts_with("git rev-parse "),
+ "label must name git exactly once: {rendered}"
+ );
+ assert!(
+ !rendered.starts_with("git git "),
+ "the program name must not be doubled: {rendered}"
+ );
+ }
+}
diff --git a/crates/treehouse/Cargo.toml b/crates/treehouse/Cargo.toml
index 10b98c6..824ccc2 100644
--- a/crates/treehouse/Cargo.toml
+++ b/crates/treehouse/Cargo.toml
@@ -13,7 +13,19 @@ path = "src/main.rs"
[features]
default = []
-# Enable the TOON encoder (git dep, off by default so offline builds work).
+# The TOON encoder (git dep, off by default so offline builds work).
+#
+# OFF BY DEFAULT but REQUIRED IN SHIPPED BUILDS. `format.rs:render_toon` and
+# `main.rs:render_payload` fall back to serde_json without it, so a default
+# build answers `--format toon` with JSON — which is what the README and the
+# release notes promise against. `.github/workflows/release.yml` therefore
+# passes `--features toon` to both `cross build` and `cargo build`.
+#
+# Do NOT "fix" this by promoting it to `default`: that makes every contributor's
+# `cargo build` require a clone of Dicklesworthstone/toon_rust, and the pinned
+# git rev turns a cold checkout into a network dependency for the whole
+# workspace — including `cargo test --workspace`. The release build asks for the
+# feature explicitly instead, and the README's `cargo install` line does too.
toon = ["treehouse-core/toon", "dep:tru"]
[dependencies]
diff --git a/crates/treehouse/src/cli.rs b/crates/treehouse/src/cli.rs
index 86e7537..f695dc6 100644
--- a/crates/treehouse/src/cli.rs
+++ b/crates/treehouse/src/cli.rs
@@ -4,7 +4,7 @@
//! formatter. The Go CLI contract (plan Appendix B) is reproduced: exit codes,
//! stdout/stderr routing, and human message strings (byte-exact where Go-tested).
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
use clap::{Args, Parser, Subcommand, ValueEnum};
use treehouse_core::git::GitBackend;
@@ -76,7 +76,15 @@ pub enum Command {
Update,
}
-#[derive(Debug, Args)]
+/// Acquisition options for `treehouse get`.
+///
+/// `Default` is derived so a bare `treehouse` (no subcommand) can be expressed
+/// as `GetArgs::default()`. A hand-written literal here would have to be
+/// updated every time a flag is added, and the compiler would NOT catch the
+/// omission at the point where it matters — a new flag would simply be absent
+/// from the default `get`, which is the same silent drop that once cost
+/// `get --lease` its `AcquireOptions`.
+#[derive(Debug, Args, Default)]
pub struct GetArgs {
/// Durably lease instead of opening a subshell; path-only on stdout.
#[arg(long)]
@@ -112,6 +120,16 @@ pub struct GetArgs {
/// Defaults to $TREEHOUSE_WORKTREE_PATH.
#[arg(long, value_name = "TEMPLATE")]
pub worktree_path: Option,
+ /// Replace the committed `.worktreeinclude` with this file for THIS
+ /// acquisition (Go `IncludeManifest`). An empty file seeds nothing; it
+ /// does NOT fall back to the committed manifest.
+ #[arg(long, value_name = "FILE")]
+ pub include_file: Option,
+ /// Share tracked file data in fresh Git slots on macOS/APFS: `off` or
+ /// `fresh`. Overrides $TREEHOUSE_APFS_SHARING. Never fails an acquisition
+ /// — a slot that cannot be shared keeps its plain copy.
+ #[arg(long, value_name = "MODE")]
+ pub apfs_sharing: Option,
}
#[derive(Debug, Args)]
@@ -273,6 +291,11 @@ impl GetArgs {
/// 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.
+ ///
+ /// `--include-file` and `--apfs-sharing` are resolved HERE rather than in
+ /// `main.rs` for the same reason `--base` is: this is the one place both
+ /// acquisition modes read, so a new option cannot reach `get` while being
+ /// silently dropped by `get --lease`.
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.
@@ -295,11 +318,57 @@ impl GetArgs {
new_branch: self.branch.clone(),
unique_leaf,
worktree_path,
+ include_manifest: self.read_include_manifest()?,
+ apfs_sharing: self.resolve_apfs_sharing()?,
..Default::default()
})
}
+
+ /// The `--include-file` manifest bytes, read ONCE and before acquisition
+ /// can reset the checkout holding it.
+ ///
+ /// Go reads it in `getRunE` for the same reason (cmd/get.go:120-127): the
+ /// file usually lives in the repository being cut from, and an acquisition
+ /// may reset or move that checkout. Reading it lazily, after the pool has
+ /// already started rewriting worktrees, turns a race into a truncated
+ /// manifest.
+ ///
+ /// `Some(vec![])` is a valid, meaningful value — "replace the committed
+ /// `.worktreeinclude` with nothing" — and must reach the core as an empty
+ /// manifest rather than as `None`, which would select the committed one.
+ fn read_include_manifest(&self) -> anyhow::Result>> {
+ let Some(path) = self.include_file.as_deref() else {
+ return Ok(None);
+ };
+ let bytes = std::fs::read(path)
+ .map_err(|e| anyhow::anyhow!("failed to read include file {path:?}: {e}"))?;
+ Ok(Some(bytes))
+ }
+
+ /// Resolves `--apfs-sharing` through `flag > env > off` (Go
+ /// `config.ResolveAPFSSharing`, config.go:77-95).
+ ///
+ /// The config tier is absent for the same reason it is absent for
+ /// `unique_leaf`: `TreehouseConfig` is not this crate's type. The chain
+ /// therefore stops at the environment, and a `apfs_sharing = "fresh"` in
+ /// `treehouse.toml` is picked up by the core rather than here.
+ fn resolve_apfs_sharing(&self) -> anyhow::Result {
+ let value = self
+ .apfs_sharing
+ .clone()
+ .or_else(|| non_empty_env(APFS_SHARING_VAR));
+ match value.as_deref().map(str::trim) {
+ None | Some("") | Some("off") => Ok(false),
+ Some("fresh") => Ok(true),
+ Some(other) => anyhow::bail!("invalid apfs_sharing {other:?}: use off or fresh"),
+ }
+ }
}
+/// Env var that opts into APFS sharing without the flag (Go
+/// `config.APFSSharingEnvVar`, config.go:75).
+pub const APFS_SHARING_VAR: &str = "TREEHOUSE_APFS_SHARING";
+
/// 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.
@@ -368,6 +437,125 @@ pub fn open_pool_with_root(
)?)
}
+// ─── Backend selection ───────────────────────────────────────────────────────
+
+/// The environment variable that selects the VCS backend (Go `vcsOverride`,
+/// vcs.go:196-207 — the highest-precedence tier).
+pub const TREEHOUSE_VCS_VAR: &str = "TREEHOUSE_VCS";
+
+/// Whether `value` names a backend this port knows, matching Go's
+/// `normalizeVCSName` (vcs.go:219-224).
+///
+/// An unrecognised value normalises to "nothing", which leaves the repository
+/// on git — deliberately not an error, because Go treats a typo in this
+/// variable as "no override" and a repository that selects jj through the
+/// config tiers must not be dragged onto git by a stray `TREEHOUSE_VCS=jjj`.
+fn normalize_vcs(value: &str) -> Option<&'static str> {
+ match value.trim().to_ascii_lowercase().as_str() {
+ "git" => Some("git"),
+ "jj" => Some("jj"),
+ _ => None,
+ }
+}
+
+/// Refuses `--branch` against the jj backend (Go `cmd/get.go:148-150`).
+///
+/// `--branch` CREATES a branch. Inside a jj workspace the branch is not a git
+/// ref at all, so carrying the flag through would either fail somewhere deep
+/// in acquisition or — worse — report success for an operation that never
+/// happened. Go refuses up front, and so does this.
+///
+/// The refusal is driven by the backend the repository **selects**, not by the
+/// mere presence of a `.jj` directory. Go's `backendFor` (vcs.go:157-166)
+/// deliberately falls back to git for a jj-marked tree that was never opted in,
+/// so a git repository that merely sits next to a `.jj` directory keeps
+/// `--branch`; refusing there would break a working invocation.
+///
+/// Only the `TREEHOUSE_VCS` tier is consulted. Go's two lower tiers — `vcs` in
+/// `treehouse.toml` and in `~/.config/treehouse/config.toml` — need a `vcs`
+/// field on `TreehouseConfig`, which this port does not have; until it does, a
+/// repository that opts into jj through config rather than the environment is
+/// not caught here. That gap is inert while the jj backend itself is
+/// unimplemented, because a `.jj` path leads to "no backend is registered for
+/// \"jj\"" rather than to a misbehaving acquisition — but it must be closed
+/// before the jj backend ships.
+pub fn require_git_backend_for_branch(
+ repo_root: &Path,
+ branch: Option<&str>,
+) -> anyhow::Result<()> {
+ let Some(branch) = branch.filter(|b| !b.is_empty()) else {
+ return Ok(());
+ };
+ let override_name = normalize_vcs(&std::env::var(TREEHOUSE_VCS_VAR).unwrap_or_default());
+ if override_name != Some(treehouse_core::vcs::BACKEND_JJ) {
+ return Ok(());
+ }
+ // The backend the repository SELECTS (Go `BackendNameFor`, vcs.go:669) —
+ // not the slot flavour, and not the mere presence of a `.jj` directory.
+ // `configured_backend_name` is the seam's port of exactly that function, so
+ // the two questions stay apart the way Go keeps them apart: in a colocated
+ // repository `.git` wins here while [`worktree_backend_name`] would answer
+ // for the slot, and jj colocates with git by design (`jj git init`), so the
+ // colocated layout is the NORMAL one.
+ //
+ // A marker that cannot be READ is propagated as an error, not reported as
+ // "git". This port separates "no marker" from "unreadable marker" precisely
+ // so a damaged checkout is never quietly classified; collapsing the two
+ // here would let `--branch {branch}` proceed in a repository whose backend
+ // could not be identified, which is the exact case the check exists for.
+ let selected =
+ treehouse_core::vcs::configured_backend_name(repo_root, override_name).map_err(|e| {
+ anyhow::anyhow!("resolving the vcs backend for {}: {e}", repo_root.display())
+ })?;
+
+ // Selection is not the same question as readability, and this gate must not
+ // collapse them. `configured_backend_name` answers "which backend would
+ // CREATE a worktree here", and it follows Go's `findMarkerRoot`, which
+ // stats with `os.Stat` and therefore counts a marker it cannot read as
+ // ABSENT — right for selection, where the alternative is inventing a
+ // backend. It is wrong for a REFUSAL: a `.jj` pointing at a deleted
+ // workspace reads as "no jj here", and `--branch` would walk straight into
+ // the checkout this check exists to protect.
+ jj_marker_readable(repo_root)?;
+
+ if selected == treehouse_core::vcs::BACKEND_JJ {
+ anyhow::bail!(
+ "--branch is only supported by the git backend \
+ (cannot create {branch:?} in a jj workspace)"
+ );
+ }
+ Ok(())
+}
+
+/// Fails when `path` holds a `.jj` marker that exists but cannot be READ.
+///
+/// This is a guard on a REFUSAL, not a backend selector — deciding WHICH
+/// backend a repository uses is [`treehouse_core::vcs::configured_backend_name`]
+/// alone, and duplicating that here is what this function exists to avoid. What
+/// it adds is only the distinction the seam draws for slot flavour
+/// ([`treehouse_core::vcs::worktree_backend_name`], which reports an unreadable
+/// marker as an error) but which the selection path deliberately does not: an
+/// unreadable marker is a damaged checkout, never evidence of absence.
+///
+/// The check is scoped to `.jj` specifically because that is the marker this
+/// refusal is about. Asking [`treehouse_core::vcs::worktree_backend_name`]
+/// instead would be useless here — it answers on `.git` first and returns
+/// without ever looking at `.jj`, so in a COLOCATED repository (the normal jj
+/// layout, `jj git init`) a damaged `.jj` would sail straight through.
+fn jj_marker_readable(path: &Path) -> anyhow::Result<()> {
+ let marker = path.join(".jj");
+ // `symlink_metadata` does not follow, so a DANGLING symlink counts as
+ // present — its unresolvable target is damage to report, not absence.
+ match std::fs::symlink_metadata(&marker) {
+ Ok(_) => {}
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
+ Err(e) => anyhow::bail!("reading .jj marker in {}: {e}", path.display()),
+ }
+ std::fs::metadata(&marker)
+ .map_err(|e| anyhow::anyhow!("resolving .jj marker in {}: {e}", path.display()))?;
+ Ok(())
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -461,4 +649,300 @@ mod tests {
assert!(!o.skip_fetch);
assert!(o.worktree_path.is_none());
}
+
+ // ─── seeding and sharing flags ───────────────────────────────────────────
+
+ /// `--include-file` must reach the core as BYTES, and an empty file must
+ /// reach it as `Some([])` — not as `None`.
+ ///
+ /// `None` means "use the committed `.worktreeinclude`". Collapsing an empty
+ /// file into it would make `--include-file /dev/null` seed MORE than the
+ /// caller asked to exclude, which is the opposite of what the flag says.
+ #[test]
+ fn an_empty_include_file_is_a_selection_of_nothing() {
+ let dir = tempfile::tempdir().unwrap();
+ let manifest = dir.path().join("m.txt");
+ std::fs::write(&manifest, b"*.env\n").unwrap();
+ let empty = dir.path().join("empty.txt");
+ std::fs::write(&empty, b"").unwrap();
+
+ let a = parse_get(&[
+ "treehouse",
+ "get",
+ "--include-file",
+ manifest.to_str().unwrap(),
+ ]);
+ assert_eq!(
+ a.acquire_options().unwrap().include_manifest,
+ Some(b"*.env\n".to_vec())
+ );
+
+ let a = parse_get(&[
+ "treehouse",
+ "get",
+ "--include-file",
+ empty.to_str().unwrap(),
+ ]);
+ assert_eq!(
+ a.acquire_options().unwrap().include_manifest,
+ Some(Vec::new()),
+ "an empty manifest selects nothing; it must not fall back to the committed one"
+ );
+ }
+
+ /// No flag means "use the committed manifest", which is a DIFFERENT value
+ /// from an empty manifest and must stay distinguishable all the way down.
+ #[test]
+ fn no_include_file_leaves_the_manifest_unset() {
+ assert_eq!(
+ parse_get(&["treehouse", "get"])
+ .acquire_options()
+ .unwrap()
+ .include_manifest,
+ None
+ );
+ }
+
+ /// The manifest is read during option building, so an unreadable path fails
+ /// before acquisition can reset the checkout holding it.
+ #[test]
+ fn an_unreadable_include_file_is_an_error_not_a_silent_empty_manifest() {
+ let dir = tempfile::tempdir().unwrap();
+ let a = parse_get(&[
+ "treehouse",
+ "get",
+ "--include-file",
+ dir.path().join("nope.txt").to_str().unwrap(),
+ ]);
+ let err = a
+ .acquire_options()
+ .expect_err("a missing manifest must fail");
+ assert!(
+ err.to_string().contains("failed to read include file"),
+ "got: {err}"
+ );
+ }
+
+ /// `off`/`fresh` are the only accepted values, and only `fresh` opts in.
+ /// Anything else is a typo the user needs told about, not a silent default.
+ #[test]
+ fn apfs_sharing_takes_only_off_or_fresh() {
+ let a = parse_get(&["treehouse", "get", "--apfs-sharing", "fresh"]);
+ assert!(a.acquire_options().unwrap().apfs_sharing);
+
+ let a = parse_get(&["treehouse", "get", "--apfs-sharing", "off"]);
+ assert!(!a.acquire_options().unwrap().apfs_sharing);
+
+ let a = parse_get(&["treehouse", "get", "--apfs-sharing", "sometimes"]);
+ let err = a
+ .acquire_options()
+ .expect_err("a bogus mode must be refused");
+ assert!(err.to_string().contains("use off or fresh"), "got: {err}");
+
+ assert!(
+ !parse_get(&["treehouse", "get"])
+ .acquire_options()
+ .unwrap()
+ .apfs_sharing,
+ "sharing must be off unless someone asked for it"
+ );
+ }
+
+ /// Both new flags must survive into the lease path. `cmd_get` builds one
+ /// `AcquireOptions` through this one method for both modes, so a flag
+ /// resolved here cannot reach `get` while being dropped by `get --lease`.
+ /// The lease/TTL pair is attached by `cmd_get` afterwards, which is why this
+ /// asserts on the fields this method owns.
+ #[test]
+ fn the_seeding_and_sharing_flags_reach_the_lease_acquisition() {
+ let dir = tempfile::tempdir().unwrap();
+ let manifest = dir.path().join("m.txt");
+ std::fs::write(&manifest, b"*.env\n").unwrap();
+
+ let a = parse_get(&[
+ "treehouse",
+ "get",
+ "--lease",
+ "--lease-holder",
+ "ci",
+ "--include-file",
+ manifest.to_str().unwrap(),
+ "--apfs-sharing",
+ "fresh",
+ ]);
+ let o = a.acquire_options().expect("options must build");
+ assert!(a.lease, "the mode under test is the lease path");
+ assert_eq!(o.include_manifest, Some(b"*.env\n".to_vec()));
+ assert!(o.apfs_sharing);
+ }
+
+ // ─── M-020: `--branch` against the jj backend ───────────────────────────
+
+ /// A directory that looks like a jj workspace to the marker reader.
+ fn jj_workspace() -> tempfile::TempDir {
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::create_dir(dir.path().join(".jj")).unwrap();
+ dir
+ }
+
+ /// A jj workspace in its NORMAL shape: a `.jj` directory COLOCATED with a
+ /// git repository, which is what `jj git init` produces.
+ ///
+ /// This is the case a naive marker read gets wrong. `worktree_backend_name`
+ /// is git-first, so it answers `git` here and never looks at `.jj` — a
+ /// jj-only directory passes trivially and hides the bug. Every colocated
+ /// assertion below exists because that distinction was found by running the
+ /// real binary, not by reading the code.
+ fn colocated_jj_repo() -> tempfile::TempDir {
+ let dir = tempfile::tempdir().unwrap();
+ let run = |args: &[&str]| {
+ let out = std::process::Command::new("git")
+ .args(args)
+ .current_dir(dir.path())
+ .output()
+ .unwrap();
+ assert!(out.status.success(), "git {args:?}: {out:?}");
+ };
+ run(&["init", "-q", "-b", "main"]);
+ run(&["config", "user.email", "t@example.com"]);
+ run(&["config", "user.name", "t"]);
+ std::fs::create_dir(dir.path().join(".jj")).unwrap();
+ dir
+ }
+
+ /// The case that actually broke: a real, colocated jj workspace with the
+ /// opt-in set must refuse. Reading the slot flavour instead of the selected
+ /// backend reports `git` for this layout and lets the flag through.
+ #[test]
+ fn branch_is_refused_in_a_colocated_jj_workspace() {
+ let dir = colocated_jj_repo();
+ let _env = VcsEnv::set("jj");
+ let err = require_git_backend_for_branch(dir.path(), Some("feat/x"))
+ .expect_err("a colocated jj workspace is still a jj workspace");
+ assert!(
+ err.to_string()
+ .contains("only supported by the git backend"),
+ "got: {err}"
+ );
+ }
+
+ /// The refusal itself, with the opt-in present in the environment.
+ ///
+ /// `TREEHOUSE_VCS` is process-global, and cargo runs every test in this
+ /// binary as a THREAD in one process — so setting it is not a private act.
+ /// Two tests that each set it raced, and the loser's assertion was decided
+ /// by the winner's value: `a_jj_marker_alone_does_not_refuse_branch` set it
+ /// to `""` and intermittently observed a `jj` another test had just set,
+ /// failing an assertion that is true. The guard alone cannot fix that,
+ /// because restoring a prior value is no help when another thread is
+ /// reading it at that instant.
+ ///
+ /// The lock is what makes the guard sufficient: every test that TOUCHES the
+ /// variable holds it for its whole body, so the set and the call it governs
+ /// are one atomic region against every other such test. Poisoning is
+ /// ignored deliberately — a panic inside one of these should report that one
+ /// failure, not cascade into "VCS env lock poisoned" across the rest.
+ static VCS_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+ struct VcsEnv {
+ prior: Option,
+ // Held for the guard's lifetime; released on drop.
+ _lock: std::sync::MutexGuard<'static, ()>,
+ }
+
+ impl VcsEnv {
+ fn set(value: &str) -> VcsEnv {
+ let lock = VCS_ENV_LOCK
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ let prior = std::env::var(TREEHOUSE_VCS_VAR).ok();
+ // SAFETY: the lock makes this the only thread reading or writing the
+ // variable for as long as the guard lives.
+ unsafe { std::env::set_var(TREEHOUSE_VCS_VAR, value) };
+ VcsEnv { prior, _lock: lock }
+ }
+ }
+
+ impl Drop for VcsEnv {
+ fn drop(&mut self) {
+ match &self.prior {
+ Some(v) => unsafe { std::env::set_var(TREEHOUSE_VCS_VAR, v) },
+ None => unsafe { std::env::remove_var(TREEHOUSE_VCS_VAR) },
+ }
+ }
+ }
+
+ #[test]
+ fn branch_is_refused_in_a_jj_workspace_that_selected_jj() {
+ let dir = jj_workspace();
+ let _env = VcsEnv::set("jj");
+ let err = require_git_backend_for_branch(dir.path(), Some("feat/x"))
+ .expect_err("a git branch cannot be created in a jj workspace");
+ assert!(
+ err.to_string()
+ .contains("only supported by the git backend"),
+ "got: {err}"
+ );
+ }
+
+ /// The opt-in matters: a `.jj` directory alone never selects jj, so a git
+ /// repository that merely sits beside one keeps `--branch`. Refusing here
+ /// would break an invocation that works today.
+ #[test]
+ fn a_jj_marker_alone_does_not_refuse_branch() {
+ let dir = jj_workspace();
+ let _env = VcsEnv::set("");
+ assert!(require_git_backend_for_branch(dir.path(), Some("feat/x")).is_ok());
+ }
+
+ /// jj selected, but the tree is a git repository. The opt-in without a
+ /// marker falls back to git (Go vcs.go:150-154), so `--branch` stands.
+ #[test]
+ fn jj_selected_without_a_jj_marker_still_allows_branch() {
+ let dir = tempfile::tempdir().unwrap();
+ let _env = VcsEnv::set("jj");
+ assert!(require_git_backend_for_branch(dir.path(), Some("feat/x")).is_ok());
+ }
+
+ /// With no `--branch` there is nothing to refuse, whatever the backend.
+ #[test]
+ fn no_branch_means_no_refusal() {
+ let dir = jj_workspace();
+ let _env = VcsEnv::set("jj");
+ for branch in [None, Some("")] {
+ assert!(require_git_backend_for_branch(dir.path(), branch).is_ok());
+ }
+ }
+
+ /// A damaged marker must surface as an error, never as "not jj". The seam
+ /// separates unreadable from absent so a broken checkout is not silently
+ /// classified, and the refusal must not undo that.
+ #[test]
+ fn an_unreadable_jj_marker_is_an_error_not_a_yes() {
+ let dir = tempfile::tempdir().unwrap();
+ let target = dir.path().join("gone");
+ let link = dir.path().join(".jj");
+ #[cfg(unix)]
+ std::os::unix::fs::symlink(&target, &link).unwrap();
+ #[cfg(windows)]
+ std::os::windows::fs::symlink_file(&target, &link).unwrap();
+
+ let _env = VcsEnv::set("jj");
+ let err = require_git_backend_for_branch(dir.path(), Some("feat/x"))
+ .expect_err("an unreadable marker must not read as 'not jj'");
+ assert!(err.to_string().contains(".jj marker"), "got: {err}");
+ }
+
+ /// The backend name normaliser is Go's: only `git` and `jj` mean anything,
+ /// and a typo is silence rather than a fallback that could drag a jj
+ /// repository onto git.
+ #[test]
+ fn an_unknown_vcs_value_normalises_to_nothing() {
+ assert_eq!(normalize_vcs("git"), Some("git"));
+ assert_eq!(normalize_vcs("JJ"), Some("jj"));
+ assert_eq!(normalize_vcs(" jj "), Some("jj"));
+ for bogus in ["", "jjj", "git2", "j"] {
+ assert_eq!(normalize_vcs(bogus), None, "{bogus:?} must be unknown");
+ }
+ }
}
diff --git a/crates/treehouse/src/main.rs b/crates/treehouse/src/main.rs
index d57b5ed..e030f7e 100644
--- a/crates/treehouse/src/main.rs
+++ b/crates/treehouse/src/main.rs
@@ -26,31 +26,68 @@ use treehouse_core::result::SweepScope;
/// 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.
+/// **This is the default.** Go shipped exit 3 as a v3.0.0 BREAKING CHANGE
+/// because a caller reading exit 0 as "the slot was released" was reading a
+/// bug; this port had held exit 0 behind `TREEHOUSE_EXIT_STRICT=1` while the
+/// contract was still settling. It now matches Go, and the environment variable
+/// is the migration path in the other direction: `TREEHOUSE_EXIT_STRICT=0`
+/// restores exit 0 for scripts running under `set -e`, for one minor release.
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).
+/// A worktree left exactly as it was found, carrying the message for EACH exit
+/// status it can produce.
+///
+/// Both messages name the remedy and say the slot is still held; they differ
+/// only in how blunt they are. The exit-3 wording is Go's and is now the
+/// default. The exit-0 wording is what this port printed before the flip and
+/// survives solely behind `TREEHOUSE_EXIT_STRICT=0`, so the escape hatch
+/// produces byte-identical output to the release a script was written against.
#[derive(Debug)]
struct NotReturned {
- strict: String,
- default: String,
+ /// Printed with [`EXIT_NOT_RETURNED`]. The default path.
+ not_returned: String,
+ /// Printed with exit 0, reachable only via `TREEHOUSE_EXIT_STRICT=0`.
+ legacy: String,
}
impl std::fmt::Display for NotReturned {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- f.write_str(&self.strict)
+ f.write_str(&self.not_returned)
}
}
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")
+/// Whether the user has opted OUT of exit 3 via `TREEHOUSE_EXIT_STRICT=0`.
+///
+/// This used to be an opt-IN and is now an opt-OUT, so the polarity of an
+/// unrecognised value inverts. Unset means exit 3. Only a value Go's
+/// `strconv.ParseBool` reads as FALSE (`0`, `f`, `false`, `off`, `n`, `no`)
+/// restores exit 0 — matching the `env_flag` reader in `cli.rs` so one spelling
+/// works everywhere in the binary.
+///
+/// An unparseable value is deliberately NOT the opt-out. `TREEHOUSE_EXIT_STRICT=1`
+/// from the previous release is not false, and neither is a typo: both keep the
+/// exit-3 default. Treating garbage as "user wants the old behaviour" would
+/// hand the escape hatch to anyone who fat-fingers the variable name, which is
+/// the one direction that can silently under-report a slot that was never
+/// released.
+fn exit_legacy_zero() -> bool {
+ match std::env::var("TREEHOUSE_EXIT_STRICT") {
+ Ok(v) => parses_as_false(&v),
+ Err(_) => false,
+ }
+}
+
+/// Whether `value` is one of the spellings Go's `strconv.ParseBool` reads as
+/// FALSE. Split out from [`exit_legacy_zero`] so it can be asserted directly:
+/// the env read is process-global, and a test that mutated it would race every
+/// other test in this binary.
+fn parses_as_false(value: &str) -> bool {
+ matches!(
+ value.trim().to_ascii_lowercase().as_str(),
+ "0" | "f" | "false" | "off" | "n" | "no"
+ )
}
fn main() {
@@ -106,15 +143,15 @@ fn main() {
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);
+ // An unreturned worktree is not a failure, but it IS a status the
+ // caller must see: exit 3 is the default, matching Go, and only
+ // `TREEHOUSE_EXIT_STRICT=0` restores this port's original exit 0.
+ if exit_legacy_zero() {
+ eprintln!("{}", not_returned.legacy);
+ return;
}
- eprintln!("{}", not_returned.default);
- return;
+ eprintln!("{}", not_returned.not_returned);
+ std::process::exit(EXIT_NOT_RETURNED);
}
eprintln!("{e:#}");
std::process::exit(1);
@@ -278,20 +315,7 @@ fn open_pool_for_cli(cli: &Cli) -> Result {
fn run(cli: Cli) -> Result<()> {
// --update-check is intercepted before clap in main (handled above).
match &cli.command {
- None => cmd_get(
- &cli,
- &cli::GetArgs {
- lease: false,
- lease_holder: None,
- ttl: None,
- json: false,
- base: None,
- no_fetch: false,
- branch: None,
- unique_leaf: false,
- worktree_path: None,
- },
- ),
+ None => cmd_get(&cli, &cli::GetArgs::default()),
Some(Command::Get(args)) => cmd_get(&cli, args),
Some(Command::Enter(args)) => cmd_enter(&cli, args),
Some(Command::Return(args)) => cmd_return(&cli, args),
@@ -314,7 +338,14 @@ fn cmd_get(cli: &Cli, args: &cli::GetArgs) -> Result<()> {
let format = resolve_format(cli, args.json, args.lease)?;
let _ = format;
- let pool = open_pool_for_cli(cli)?;
+ // Resolved once and reused for both the backend check and the pool, so the
+ // repository cannot change identity between the two. The jj refusal runs
+ // BEFORE the lease/interactive split: Go places it before `getLeaseRunE`
+ // (cmd/get.go:147-150) so `--lease` cannot become a way to slip a git
+ // branch request past it.
+ let ctx = cli::resolve_repo_ctx()?;
+ cli::require_git_backend_for_branch(&ctx.repo_root, args.branch.as_deref())?;
+ let pool = cli::open_pool_with_root(&ctx, cli.root.as_deref())?;
if args.lease {
// Lease mode: path-only on stdout, or JSON/TOON object.
@@ -413,11 +444,11 @@ fn cmd_get(cli: &Cli, args: &cli::GetArgs) -> Result<()> {
match confirm("Clean worktree and return to pool? [Y/n]")? {
Confirm::No | Confirm::Unanswered => {
return Err(NotReturned {
- strict: format!(
+ not_returned: 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:
+ legacy:
"Worktree left dirty. Use 'treehouse return --force' to clean it later."
.to_string(),
}
@@ -652,11 +683,11 @@ fn cmd_return_all(cli: &Cli, args: &cli::ReturnArgs) -> Result<()> {
}
if !aborted.is_empty() {
return Err(NotReturned {
- strict: format!(
+ not_returned: 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(),
+ legacy: "Aborted.".to_string(),
}
.into());
}
@@ -1015,8 +1046,10 @@ fn release_one(
///
/// `unanswered` and a decline are kept distinct because they demand different
/// responses, but BOTH mean the slot stays held — and a caller reading exit 0
-/// as "the slot was released" was reading a bug (Go's v3.0.0 rationale). The
-/// strict message names which one it was.
+/// as "the slot was released" was reading a bug (Go's v3.0.0 rationale, and the
+/// reason this port now exits 3 by default). The exit-3 message names which one
+/// it was; the exit-0 opt-out message never did, and stays that way so
+/// `TREEHOUSE_EXIT_STRICT=0` reproduces the pre-flip output exactly.
fn not_returned_abort(path: &str, answer: Confirm) -> anyhow::Error {
let reason = match answer {
Confirm::Unanswered => {
@@ -1025,11 +1058,11 @@ fn not_returned_abort(path: &str, answer: Confirm) -> anyhow::Error {
_ => "cleaning declined, so its uncommitted changes remain",
};
NotReturned {
- strict: format!(
+ not_returned: format!(
"🌳 worktree not returned: {reason}; prune will not reclaim this slot. Use treehouse return --force {} to clean and return it",
quote_path(Path::new(path))
),
- default: "Aborted.".to_string(),
+ legacy: "Aborted.".to_string(),
}
.into()
}
@@ -2305,39 +2338,105 @@ mod tests {
}
}
- // ─── M-019: the unreturned signal is opt-in, never silent ───────────────
+ // ─── M-019: the unreturned signal is never silent ─────────────────────────
#[test]
- fn an_abort_carries_both_the_strict_and_the_backward_compatible_message() {
+ fn an_abort_carries_both_the_exit_three_and_the_backward_compatible_message() {
let e = not_returned_abort("/pool/1/repo", Confirm::Unanswered);
let text = format!("{e:#}");
assert!(
text.contains("stdin reached EOF"),
- "strict mode must say the confirmation was unanswerable: {text}"
+ "the default exit-3 message must say the confirmation was unanswerable: {text}"
);
assert!(text.contains("prune will not reclaim"), "{text}");
+ // The remedy must be pasteable into the user's own shell, so it has to
+ // use THAT platform's quoting — cmd.exe groups on double quotes.
+ let remedy = if cfg!(windows) {
+ "treehouse return --force \"/pool/1/repo\""
+ } else {
+ "treehouse return --force '/pool/1/repo'"
+ };
assert!(
- text.contains("treehouse return --force '/pool/1/repo'"),
+ text.contains(remedy),
"the remedy must be pasteable: {text}"
);
let n = e.downcast_ref::().unwrap();
assert_eq!(
- n.default, "Aborted.",
- "the default keeps this port's exit-0 contract"
+ n.legacy, "Aborted.",
+ "the opt-out path keeps this port's pre-flip exit-0 wording"
);
let declined = not_returned_abort("/pool/1/repo", Confirm::No);
assert!(format!("{declined:#}").contains("cleaning declined"));
}
+ /// The flip itself. Exit 3 is the DEFAULT and `TREEHOUSE_EXIT_STRICT=0` is
+ /// the escape hatch, so an unset variable and a leftover `=1` from the
+ /// opt-in era must BOTH land on the strict path — only an explicit falsy
+ /// value may restore exit 0.
+ ///
+ /// Env vars are process-global and tests share a process, so this asserts
+ /// the pure reader rather than mutating the environment: setting it here
+ /// would race every other test that shells out to `main`'s subprocesses.
+ #[test]
+ fn legacy_zero_is_opted_in_only_by_an_explicit_false() {
+ for value in ["0", "false", "off", "n", "no", "f", " FALSE ", "off "] {
+ assert!(parses_as_false(value), "{value:?} must restore exit 0");
+ }
+ // The dangerous cases: a stale opt-in, a typo, and the empty string a
+ // shell exports when a variable is declared but never assigned.
+ for value in ["1", "true", "on", "yes", "y", "", " ", "maybe", "2"] {
+ assert!(
+ !parses_as_false(value),
+ "{value:?} must keep the exit-3 default"
+ );
+ }
+ }
+
+ /// The three construction sites must all populate the exit-3 message, so a
+ /// new call site cannot accidentally ship Go's wording only to the opt-out
+ /// (or vice versa).
+ #[test]
+ fn every_not_returned_site_populates_both_messages() {
+ for n in [
+ not_returned_abort("/pool/1/repo", Confirm::Unanswered),
+ not_returned_abort("/pool/1/repo", Confirm::No),
+ ] {
+ let n = n.downcast_ref::().unwrap();
+ assert!(
+ !n.not_returned.is_empty(),
+ "the exit-3 message must never be blank"
+ );
+ assert_eq!(n.legacy, "Aborted.", "the opt-out wording is fixed");
+ }
+ }
+
#[test]
fn quote_path_neutralises_shell_metacharacters() {
- assert_eq!(quote_path(Path::new("/pool/1/repo")), "'/pool/1/repo'");
- assert_eq!(
- quote_path(Path::new("/it's here")),
- r"'/it'\''s here'",
- "an embedded quote must not terminate the quoted string"
- );
+ // `quote_path` is platform-branched (main.rs:1815): POSIX shells
+ // single-quote, cmd.exe double-quotes. Assert each platform's real
+ // form rather than pinning the POSIX one everywhere.
+ if cfg!(windows) {
+ assert_eq!(quote_path(Path::new("/pool/1/repo")), "\"/pool/1/repo\"");
+ assert_eq!(
+ quote_path(Path::new("/it's here")),
+ "\"/it's here\"",
+ "cmd.exe has no escape inside double quotes, but `'` is not \
+ special there — the doubled `\"` is the escape it does honour"
+ );
+ assert_eq!(
+ quote_path(Path::new("/say \"hi\"")),
+ "\"/say \"\"hi\"\"\"",
+ "an embedded quote must be doubled so it cannot close the group"
+ );
+ } else {
+ assert_eq!(quote_path(Path::new("/pool/1/repo")), "'/pool/1/repo'");
+ assert_eq!(
+ quote_path(Path::new("/it's here")),
+ r"'/it'\''s here'",
+ "an embedded quote must not terminate the quoted string"
+ );
+ }
}
// ─── M-023: `--root` and its Go precedence ──────────────────────────────
diff --git a/crates/treehouse/tests/e2e/common.rs b/crates/treehouse/tests/e2e/common.rs
index e346b36..a59ea61 100644
--- a/crates/treehouse/tests/e2e/common.rs
+++ b/crates/treehouse/tests/e2e/common.rs
@@ -28,6 +28,49 @@ pub fn treehouse_bin() -> PathBuf {
exe
}
+/// Spawn attempts tolerated while a staged binary reports ETXTBSY.
+///
+/// 200 x 10 ms = 2 s: far longer than a write teardown, far shorter than a CI
+/// job's patience.
+const ETXTBSY_RETRIES: u32 = 200;
+const ETXTBSY_BACKOFF: std::time::Duration = std::time::Duration::from_millis(10);
+
+/// `cmd.output()`, retrying only while the spawn fails with ETXTBSY.
+///
+/// On Linux `execve(2)` refuses a file that is still open for writing anywhere
+/// on the box, so a suite that stages a binary by copying it and immediately
+/// executes that copy can lose the race against the copy's own write teardown.
+/// Only the suites that stage are exposed: `update_e2e.rs::install_target`
+/// writes into a temp dir and execs the result, whereas `e2e.rs` and
+/// `bulk_ops_e2e.rs` exec `target/debug/treehouse` straight off disk and never
+/// come near this path. Nothing about the treehouse binary is wrong in that
+/// failure, so the retry belongs to the harness and nowhere else -- the product
+/// installs by renaming (`updater.rs::atomic_replace`), which has no such
+/// window.
+///
+/// Every other error returns on the first attempt, so a genuinely missing or
+/// non-executable binary still fails immediately and still names itself.
+/// `ErrorKind::ExecutableFileBusy` is stable since Rust 1.83, under this
+/// workspace's 1.88 MSRV, and needs no new dependency.
+pub fn output_tolerating_etxtbsy(cmd: &mut Command) -> std::io::Result