mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
perf(tests): stop leaking a temp dir per test, and measure where the suite's CPU goes (#3604)
Measured where the test suite's CPU actually goes, fixed what was doing
real extra work, and added `task profile-tests` so the measurement is
repeatable.
## Baseline
`cargo nextest run --features shell-integration-tests` on an 18-core
M-series machine: 4,570 tests, ~95s wall, **988 CPU-seconds (332 user +
655 sys)**.
Two thirds is kernel time, so the cost is process creation and
filesystem churn rather than computation. The integration binary is 86%
of summed self-time: 2,184 tests at ~0.6s each, each spawning `wt` (a 65
MB debug binary, ~11ms CPU per spawn against 2.6ms for a trivial
process) and `git` against a fresh fixture copy. It sits in a broad
middle, not a few outliers: 73% of self-time is in tests taking 0.25 to
2.0s.
## One leaked temp directory per test
`isolated_test_cwd()` held a `TempDir` in a `LazyLock`. Statics don't
run destructors at process exit and nextest runs one process per test,
so every test leaked an empty directory into the system temp root.
Measured with `TMPDIR` pointed at a fresh directory: **704 per
integration-suite run**. This machine had accumulated **454,907 entries,
353,268 of them empty strays older than a day**.
Stale entries are cheap to ignore but expensive to enumerate, and
`git::recover::recover_from_path` reads every ancestor directory of a
deleted CWD:
| temp root | `test_recover_from_path_returns_none_for_unrelated_path` |
|---|---|
| 454k entries | 14.2s (34.4s on a quieter run) |
| empty | 0.27s |
One fixed directory replaces it. Leaks per run: 704 to 0, verified
across full suite runs, and the directory is still empty after ~9,000
test executions.
I also checked whether a crowded temp root slows ordinary temp
operations. It does not: create, populate and delete of a fixture-sized
tree ran at 21ms/iter in a 454k-entry parent against 40 to 60ms in an
empty one. The leak's cost is concentrated entirely in code that
enumerates.
## Fixture temp dirs no longer sit in the shared temp root
The fixtures created their temp directories directly in the system temp
dir, among however many entries the machine had put there.
`test_temp_root()` (`$TMPDIR/wt`) roots them one level in, and
`test_tempdir()` replaces `TempDir::new()` across every `TestRepo`
constructor, the mock-command helper, the `temp_home` fixture and the
recovery tests. That test again: **14.2s to 0.06s**.
Two constraints worth knowing, both found by trying the more aggressive
version first:
- **A cache-dir root fails.** `~/Library/Caches` is under `/Users/`,
which a conditional `includeIf "gitdir:/Users/"` matches, so 16 picker
tests fail their commits under `commit.gpgsign` — they drive git through
`Repository::run_command`, the production API with no isolated config.
`step_promote` above was not a one-off: the suite was hermetic against
host git config only because macOS puts `$TMPDIR` outside `/Users/`.
That is the hole the next section closes — at the layer that covers
in-process git, not by choosing where temp files live.
- **The root's name is load-bearing.** A unix socket path cannot exceed
`sun_path`, 104 bytes on macOS. The canonicalized per-user `$TMPDIR` is
56, and `test_copy_ignored_skips_non_regular_files` binds a listener 89
bytes in. `worktrunk-tests` (16 bytes with its slash) overflowed by two;
`wt` costs 3 of the 14 spare.
The ~240 tests that call `tempfile` directly still use the system temp
dir. They are transient and a clean run leaks nothing, so converting
them is tidiness rather than a fix.
## A test that passed by accident of TMPDIR location
`step_promote::test_promote_bare_repo_with_worktrees` drove git through
bare `Cmd::new("git")` instead of `configure_git_env`, so the host's
config applied. A conditional `includeIf "gitdir:/Users/"` enabling
`commit.gpgsign` fails its commit, but only when `TMPDIR` sits inside
the matched tree. macOS puts `TMPDIR` under `/var/folders`, so it
passed; pointing `TMPDIR` anywhere under the home directory failed it.
## The suite was not actually isolated from the developer's git config
Chasing the temp-root change turned up a real hole. `TestRepo` exposes
the production `Repository` type, and `Repository::run_command` builds a
plain `Cmd::new("git")` with no `GIT_CONFIG_GLOBAL`, so it inherits the
test process's own environment. Separately, a bare `wt_command()` had no
`GIT_CONFIG_GLOBAL` at all and fell through to `~/.gitconfig`. 280
`Repository::at/current/discover` constructions across 31 test files and
156 direct `run_command*` calls in test code sat on that path.
Signing was only the symptom that surfaced. Confirmed leaking from a
real developer config: `commit.gpgsign`, `core.fsmonitor` (spawns a
daemon per fixture repo), `worktree.guessremote` (changes `git worktree
add`, directly under test), `help.autocorrect = prompt` (a mistyped git
command blocks). Structurally: `core.hooksPath`, `credential.helper`,
`filter.*` clean/smudge and `diff.external` all execute arbitrary
programs; `url.*.insteadOf` rewrites remotes; `merge.conflictstyle`,
`diff.context`, `rebase.autostash`, `fetch.prune`, `push.default` all
change what the code under test observes. That set is unbounded, which
is why hardening each fixture's local config was rejected — a denylist
can't cover it, and local config cannot unset an inherited `[include]`
or `credential.helper` at all.
The floor is one constant, `shell_exec::HERMETIC_TEST_GIT_ENV` — the
deny pair pointing `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` at a path
that does not exist, plus the settings the suite needs through
`GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_n` / `GIT_CONFIG_VALUE_n` — applied
to every child at its spawn site. There is no git-config file anywhere
in the repo. For the spawn sites the harness owns (`git_test_env`,
`configure_git_cmd`, `isolate_subprocess_env` for `wt` children,
`pty_env_vars` for `env_clear`ed PTY children) that's ordinary per-child
env. For the git that *production* code spawns while a test drives it
in-process, the test never holds the command — so the harness flips an
atomic latch (`shell_exec::enable_hermetic_test_env`, called from the
fixture constructors), and `Cmd`, the choke point every production spawn
passes through, applies the floor to each child while the latch is set.
Setting env on a child is safe; it was setting the test process's *own*
env that wasn't (`set_var` races the other test threads), and the latch
dissolves the need for it — no `unsafe`, no pre-`main` constructor, no
cargo `[env]`. Because the latch lives in the binary, every runner
agrees by construction: `cargo test`, nextest, `cargo llvm-cov`, `cargo
bench`, an IDE, a debugger, a directly executed
`target/debug/deps/integration-*`. `.config/nextest.toml` was tested and
rejected, and is now ruled out standing: nextest 0.9.132 has no `[env]`
key, and a `$NEXTEST_ENV` setup script would miss the three non-nextest
runners CI uses (`cargo llvm-cov`, the Nix `cargo test` derivation,
`cargo bench`). A runner-specific knob doesn't fail loudly when another
runner misses it — it yields a different result, usually in the coverage
job whose numbers gate a merge. `tests/CLAUDE.md` → One Result Per Test,
Whatever Runs It records the rule; `.config/nextest.toml` points at it
from the place someone would be tempted.
Acceptance test, since the suite passed before only by accident of
`$TMPDIR` sitting outside `/Users/`: with `test_temp_root()` temporarily
pointed under `$HOME` so a conditional `includeIf "gitdir:/Users/"`
fires, the picker tests go from **20 of 38 failing to 38 of 38
passing**.
**The cost:** the latch is a test-serving switch compiled into
`shell_exec` — one static, one relaxed load per spawn, marked
`TODO(hermetic-env)` with the structural alternative (threading an
explicit env value through `Repository`). `cargo run -- <cmd>` is
untouched: nothing in production latches it, so a developer's own
invocations keep their aliases, credential helper, and identity. The one
production git spawn that bypasses `Cmd` (the fsmonitor daemon launch)
re-applies the floor by hand. (Two earlier shapes were tried and
replaced: cargo's `[env]`, which taxed every `cargo run` and vanished
whenever a test binary ran outside cargo, and a pre-`main` constructor
crate, which every test target had to link and which put the floor on
developers' `cargo bench`-adjacent runs too.)
## In-process tests were reading the developer's worktrunk config too
`config_path()`'s third priority is the real
`~/.config/worktrunk/config.toml`, and a lib-crate test cannot set the
second for itself — `set_var` is `unsafe` and this crate forbids
`unsafe`. So a test reaching priority 3 got the developer's own config.
A `panic!` build of the guard named the live callers immediately:
`git::repository::tests::prewarm_*` read it on every run, and
`set_skip_shell_integration_prompt` /
`set_skip_commit_generation_prompt` reach the same resolver to
**write**.
Priority 3 is now absent under `#[cfg(test)]`, which is compiled out of
the real binary — so unlike the git floor above, this one costs `cargo
run` nothing. It returns `None` rather than panicking like
`approvals_path()`: that guards a mutation target where silent absence
would let a test believe it saved something, whereas this is a lookup
whose absent state is already handled — `require_config_path()` turns it
into an error, so a write still fails loudly while a best-effort read
preloads nothing.
The guard covers lib-crate tests only; `src/commands/` and `src/output/`
link the lib in non-test mode. Nothing there exercises the fall-through
today (968 bin-crate tests create no config under a scratch `$HOME`), so
that is a requirement on new tests, recorded in `tests/CLAUDE.md`.
`system_config_path()` stays unguarded on purpose — machine-wide file,
and `config::deprecation`'s `PendingDefault` rules need the lookup.
## Merging main's parallel fix
#3620 attacked the same in-process hole from the other side, writing
`LOCAL_TEST_CONFIG` into each fixture's own `.git/config`. The two
compose rather than compete and both are kept: the floor denies the
host's config to every git, and the local config supplies what a
hermetic in-process git still needs — an identity, which the floor
deliberately withholds because it has per-command homes already
(`git_test_env`, `LOCAL_TEST_CONFIG`) and `useConfigOnly` fails loudly
if a path misses both. main's structure (`TestConfigPaths`,
`TestRepo::bare`) is kept as-is.
Two docstrings were true on each side and false together:
`test_gitconfig_path` restated the gitconfig inline, and
`LOCAL_TEST_CONFIG` said in-process git reads the developer's
`~/.gitconfig`, which is what the floor prevents. `test_gitconfig_path`
also held its `TempDir` in a `LazyLock`, the leak this PR removes. Both
are moot now: the function is gone with the files.
## Why there is no gitconfig file
The isolation went through two file-based shapes before this one, and
neither earned its keep. Denial never needed a file, because
`GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` *are* the denial; the file
existed only to *set* things, and `GIT_CONFIG_COUNT` is git's
environment spelling of `-c`. Deleting both files also deletes the
`[include]` that kept them from drifting, the per-process write that
broke `test (windows)` on a shared path, and the config-path argument
threaded through 22 call sites.
The floor that remains is the deny pair plus two settings.
`user.useConfigOnly` is a backstop: denial alone leaves git *guessing*
an identity from the OS username and hostname rather than failing, which
is the one way a hermetic suite could still author a commit as the
developer. Nothing exercises it, and that is the reason to keep it.
`rerere.enabled = false` is *set* rather than left unset, so the suite's
rerere state cannot depend on what a fixture happens to carry.
`commit.gpgsign`, `advice.mergeConflict` and `advice.resolveConflict`
are gone: denial leaves git on its own default for the first, and the
snapshot layer strips the gutter-prefixed `hint:` lines the other two
quieted (they vary across git versions), so nothing depends on
suppressing them at the source. The long-dead
`tests/fixtures/template-repo/` fixture went with them.
Once the latch made denial universal, the redundant copies went too:
`git_test_env` no longer restates the deny pair per command (the floor
is denial's only writer, so its value is uniform across every
transport), `LOCAL_TEST_CONFIG` dropped its `commit.gpgsign` (denial
guarantees the default), the platform-dependent `NULL_DEVICE` constant
is deleted, and the `.env.GIT_CONFIG_GLOBAL` snapshot redaction is gone
— the recorded value is one cross-platform constant, so there is nothing
volatile to redact.
I removed `rerere.enabled` first, on a local measurement that was wrong,
and CI failed on all three platforms. The standard fixture is built once
into `target/debug/wt-test-fixtures/` and copied per test, and that
cached copy held an `rr-cache` directory left by a rebase run while the
floor still enabled rerere. Git turns rerere on by itself whenever
`rr-cache` exists, so every local test kept the behavior the change had
just removed, while CI built the fixture fresh and lost it.
`tests/CLAUDE.md` now records the trap: clear the fixture cache before
trusting a local measurement of a git-config change.
**Why the floor can't live in a fixture:** every other test variable is
set on a *child* — `git_test_env` on a git command,
`configure_cli_command` on a `wt` subprocess. In-process git is not a
child the test configures: `Repository::run_command` is production code
building a plain `Cmd::new("git")`, and the test never holds that
command, so there is no place to set env on it — while setting the test
process's own environment is the one thing a test can't do safely
(`set_var` races the other test threads). The identity did move to the
fixtures and the per-command env this way; the denial reaches
production's children through the latch at `Cmd`, the choke point they
all pass through.
Two things fall out of `-c` semantics, both pinned:
- **It outranks a repository's own config**, where a global file would
yield to it. So `init.defaultBranch` cannot live in the floor:
`default_branch.rs` sets that key in a repo to prove `wt` reads it, and
an entry would silently win. The three harness `git init` calls that
relied on the floor now name their branch, as the other two already did.
- **A PTY child is `env_clear`ed**, so it inherits nothing and used to
get the floor through the file. It now gets the family from
`configure_pty_command`, the choke point every PTY spawn routes through,
and again from `pty_env_vars`, whose vector declares a PTY `wt` child's
complete environment. Each copy is pinned by its own test, because the
settings only quiet advice and refuse a guessed identity, so no PTY
assertion would catch their loss.
Four tests wrote their own gitconfig to get `init.defaultBranch` plus an
identity; the harness supplies both, so those writes are gone too. Net
45 lines lighter, and no snapshot changed.
## Measurement
`task profile-tests` builds first, then runs the suite under bash's
`time` keyword (task's own interpreter, mvdan/sh, parses `time` but
hardcodes `user`/`sys` to zero): CPU totals on the console, every
per-test duration in the default profile's `junit.xml`. It began three
sizes larger — a scratch-`TMPDIR` leak check that dragged a `sun_path`
byte budget into the Taskfile, a `/usr/bin/time` dependency GNU-less
Linux lacks, and a `perf` nextest profile whose console slow-listing
restated what junit already carries — and each piece fell to the same
question, whether the measuring goal needed it. Method and how to read
the numbers: `tests/CLAUDE.md` under Profiling the Suite.
## Found, measured, not changed
**The gate keeps a duplicate cargo artifact set.** `RUSTFLAGS='-D
warnings'` on the insta step is part of cargo's fingerprint, so it forks
all 343 crates into a second artifact set: 261 CPU-seconds to prime plus
a duplicate of `target/debug/deps` (`target/debug` here is 35 GB, in a
52 GB `target/`). I removed it and then put it back: the clippy step
that would cover it runs on ubuntu only, while the cross-platform matrix
runs `wt hook pre-merge --yes insta`, so this RUSTFLAGS is the only
thing denying warnings on macOS and Windows. `[lints.rust] warnings =
"deny"` would keep that coverage everywhere without forking the graph
(verified locally: fails on a planted unused variable, recompiles only
worktrunk and wt-perf, feature-check commands still pass), but it also
makes plain local builds fail on warnings and newly exposes `cargo msrv
verify` and the minimal-versions job. That is a workflow call. The
duplication is a one-time cost per artifact set rather than per-edit, so
it is second-order next to the ~600 CPU-seconds each suite run costs.
**`recover_from_path` enumerates every ancestor up to `/`.** At each
ancestor it reads the directory and stats `.git` in every child.
Bounding the child scan to the first *existing* ancestor would preserve
both documented layouts (sibling and nested) and both of that module's
regression tests, but it would break a custom `worktree-path` layout
where the repo is a child of a higher ancestor, so it needs a decision
about which layouts recovery must support.
**Nothing in the gate is the biggest cost; concurrency is.** The five
`[[pre-merge]]` keys are one table, so they run concurrently, and each
is a cargo command that wants the whole machine. They serialize on
cargo's build-directory lock (`Blocking waiting for file lock on build
directory` appears in every run) while test execution overlaps another
step's build. The `lockfile` comment says it "must be first", which
concurrent execution does not provide. Separately, agent worktrees run
whole gates at once: during this work a second worktree ran its own `wt
hook pre-merge` alongside mine, load average hit **154 on 18 cores**,
and the same suite took 147.7s instead of 92.9s.
## Tried and rejected
`[profile.dev] debug = "line-tables-only"` first measured 14% less CPU,
but per-spawn CPU was unchanged, which did not fit the proposed
mechanism. Re-measuring both configurations on a quiet machine gave
602.6s (baseline) against 606.5s (line-tables). The original delta was
contention from a sibling worktree running its own suite.
macOS Gatekeeper (`syspolicyd`) looked like a candidate at 230% CPU, but
300 spawns of the freshly built `wt` cost it 0s, and 0.2s after a
relink, against 0.4s during a 10s idle baseline.
## Verification
`cargo run -- hook pre-merge --yes` green, 4,611 tests passed, run
against a freshly built fixture cache after the switch to the latch. The
latch was verified to be the only source of the variables: the invoking
shell carries no `GIT_CONFIG_*`, and the meta-tests
(`in_process_git_reads_only_the_hermetic_config` asserting the *origin*
of every resolved setting, `pty_env_vars_carry_the_git_config_floor`,
and the `isolate_subprocess_env` scrub test asserting the floor is
re-set after the scrub) pin each transport. Across earlier runs one hit
a single intermittent PTY failure in `shell_wrapper` (exit 127) that
passes 3/3 in isolation and whose code path never touches `wt_command()`
or the shared cwd; a later run was green under heavier load than the one
that failed.
## Review round
A full review of the branch (three finder lenses, findings adversarially
verified) landed one more commit:
- **The wrapper-suite PTY children never got the floor.**
`configure_pty_command` env-clears, skips the `Cmd` latch, and sets the
real `HOME`, and the shell-wrapper call sites layer only fixture paths
and an identity on top. Every git those ~89 tests ran therefore read the
developer's real `~/.gitconfig` (and lost the gpgsign shield when
`LOCAL_TEST_CONFIG` dropped it). The floor now rides that choke point,
pinned by `configure_pty_command_carries_the_git_config_floor`; every
raw `CommandBuilder` site was checked to route through it.
- **`task profile-tests` could never report CPU.** mvdan/sh's `time`
hardcodes `user`/`sys` to `0m0.000s`, so the numbers the docs said to
track were unproducible; now `bash -c 'time "$@"' bash ...`. Measured
post-fix: user 5m47s / sys 12m8s, a 67.7% kernel share, confirming the
two-thirds claim above; the integration mean measured ~1s and the docs
were corrected from ~0.6s.
- Smaller: two raw `Command::new("git")` asserts in `remove.rs` tests
now go through `configure_git_cmd`; the hermetic meta-test keys on
`--show-scope` scopes rather than git's origin-path spelling; the dead
`template-repo` fixture is deleted; three test `git init`s name `-b
main`; doc corrections (sun_path arithmetic, recover-walk attribution,
dead `[TEST_GIT_CONFIG]` remnants, volatile counts).
Final gate on the finished tree: green, 4,612 tests. Deferred with
rationale: ~985 snapshots carry stale env-block metadata (insta never
compares it; it churns on future re-records), `$TMPDIR/wt` is not
per-user on Linux (a root run poisons it for later users), and
`spawn_detached_exec_*` / `step tether` do not hand-apply the floor (no
in-process test reaches them today).
> _This was written by Claude Code on behalf of Maximilian_
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,14 @@ nextest-version = "0.9.118"
|
||||
|
||||
# Enable setup-scripts (still experimental in nextest) so the build-bins script
|
||||
# below can run before any test starts.
|
||||
#
|
||||
# Nothing in this file may change what a test observes — no `[env]`, no
|
||||
# `$NEXTEST_ENV` from a setup script. `cargo test`, `cargo llvm-cov`, `cargo
|
||||
# bench` and the Nix derivation run this same suite and would silently disagree.
|
||||
# Environment belongs in the hermetic latch (`shell_exec::enable_hermetic_test_env`)
|
||||
# and `.cargo/config.toml`, behavior in the fixture. See
|
||||
# tests/CLAUDE.md → One Result Per Test, Whatever Runs It; build-bins below is a
|
||||
# build step, not an exception to it.
|
||||
experimental = ["setup-scripts"]
|
||||
|
||||
[profile.default]
|
||||
|
||||
@@ -12,6 +12,8 @@ test-concurrent.sh
|
||||
# Insta snapshot test artifacts
|
||||
*.snap.new
|
||||
*.snap.pending
|
||||
# Pending *inline* snapshots, named `<source-file>.pending-snap`
|
||||
*.pending-snap
|
||||
logo-variant-*.png
|
||||
test-results/
|
||||
|
||||
|
||||
@@ -13,6 +13,19 @@ tasks:
|
||||
desc: Run tests with coverage report
|
||||
cmd: cargo llvm-cov nextest --html --features shell-integration-tests {{.CLI_ARGS}}
|
||||
|
||||
profile-tests:
|
||||
desc: Run the suite under CPU accounting and report where the time goes
|
||||
# tests/CLAUDE.md → Profiling the Suite covers how to read the numbers.
|
||||
cmds:
|
||||
# Build first so the measurement covers the test run alone.
|
||||
- cargo nextest run --no-run --features shell-integration-tests
|
||||
# bash's `time` keyword counts every reaped child. Task's own interpreter
|
||||
# (mvdan/sh) parses `time` but reports user/sys as zero, and /usr/bin/time
|
||||
# is an extra package on minimal Linux; bash is already required on every
|
||||
# platform (see .config/nextest.toml's build-bins).
|
||||
- bash -c 'time "$@"' bash cargo nextest run --features shell-integration-tests {{.CLI_ARGS}}
|
||||
- echo "per-test timings in target/nextest/default/junit.xml"
|
||||
|
||||
setup-web:
|
||||
desc: Setup Claude Code web environment for development
|
||||
platforms: [linux]
|
||||
|
||||
@@ -37,6 +37,24 @@ pub fn is_config_path_explicit() -> bool {
|
||||
///
|
||||
/// The first two are supplied by the user, so a relative one resolves against
|
||||
/// `-C` (see [`resolve_input_path`]). The third is an absolute XDG location.
|
||||
///
|
||||
/// Priority 3 is absent under `#[cfg(test)]`: it resolves the developer's own
|
||||
/// config, which callers both read (`prewarm_user_config`) and — via
|
||||
/// `set_skip_shell_integration_prompt` / `set_skip_commit_generation_prompt` —
|
||||
/// write. An in-process test has no way to set priority 2 for itself
|
||||
/// (`std::env::set_var` is `unsafe`, and this crate forbids `unsafe`), so a test
|
||||
/// that reaches here wanted an explicit path, not the developer's.
|
||||
///
|
||||
/// `None` rather than the `panic!` [`crate::config::approvals_path`] uses: that
|
||||
/// one guards a mutation target, where a silent absence would let a test believe
|
||||
/// it saved something. This is a lookup whose absent state is already meaningful
|
||||
/// and already handled — `require_config_path` turns it into an error, so a
|
||||
/// config *write* still fails loudly, while a best-effort *read* like the
|
||||
/// prewarm cache simply preloads nothing.
|
||||
///
|
||||
/// The guard fires for lib-crate tests only; a bin-crate test links this crate
|
||||
/// in non-test mode, so `src/commands/` and `src/output/` stay uncovered. See
|
||||
/// `tests/CLAUDE.md`.
|
||||
pub fn config_path() -> Option<PathBuf> {
|
||||
// Priority 1: CLI --config flag
|
||||
if let Some(path) = CONFIG_PATH.get() {
|
||||
@@ -49,6 +67,10 @@ pub fn config_path() -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
// Priority 3: Platform-specific default location
|
||||
#[cfg(test)]
|
||||
return None;
|
||||
|
||||
#[cfg(not(test))]
|
||||
default_config_path()
|
||||
}
|
||||
|
||||
@@ -118,6 +140,12 @@ pub fn system_config_path() -> Option<PathBuf> {
|
||||
// Priority 2+3: Check XDG_CONFIG_DIRS (if set), otherwise platform defaults.
|
||||
// When XDG_CONFIG_DIRS is set, system_config_dirs() returns only those dirs
|
||||
// (per XDG spec, no fallback to platform defaults).
|
||||
//
|
||||
// Deliberately unguarded, unlike `config_path()`: this resolves a
|
||||
// machine-wide file (`/etc/xdg`, `/Library/Application Support`) rather than
|
||||
// the developer's own config, and `config::deprecation`'s `PendingDefault`
|
||||
// rules genuinely need the lookup to decide whether the system layer already
|
||||
// defines a key.
|
||||
for dir in &system_config_dirs() {
|
||||
let path = dir.join("worktrunk").join("config.toml");
|
||||
if path.exists() {
|
||||
|
||||
@@ -20,18 +20,11 @@ fn test_default_config_path_returns_platform_path() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_path_falls_through_to_default() {
|
||||
// When no CLI override or WORKTRUNK_CONFIG_PATH env var is set,
|
||||
// config_path() should fall through to default_config_path().
|
||||
// This also verifies both functions return the same path.
|
||||
let default = default_config_path().unwrap();
|
||||
let resolved = config_path().unwrap();
|
||||
assert_eq!(
|
||||
resolved, default,
|
||||
"config_path() should match default_config_path() when no overrides are set"
|
||||
);
|
||||
}
|
||||
// `config_path()`'s fall-through to `default_config_path()` has no test here on
|
||||
// purpose: it resolves the developer's real config, which is what the
|
||||
// `#[cfg(test)]` guard in `config_path()` now refuses. The platform path it
|
||||
// would return is covered above, and the two overrides that outrank it are
|
||||
// exercised by the subprocess suite, which sets `WORKTRUNK_CONFIG_PATH`.
|
||||
|
||||
#[test]
|
||||
fn test_compute_unknown_tree_empty() {
|
||||
|
||||
+11
-11
@@ -216,12 +216,12 @@ fn paths_match(worktree_path: &Path, deleted_path: &Path) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::TestRepo;
|
||||
use crate::testing::{TestRepo, test_tempdir};
|
||||
use ansi_str::AnsiStr;
|
||||
|
||||
#[test]
|
||||
fn test_try_repo_at_rejects_git_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let tmp = test_tempdir();
|
||||
// Create a .git file (not directory) — simulates a linked worktree
|
||||
std::fs::write(tmp.path().join(".git"), "gitdir: /some/path").unwrap();
|
||||
assert!(try_repo_at(tmp.path()).is_none());
|
||||
@@ -254,7 +254,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_paths_match_same_name_same_parent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let tmp = test_tempdir();
|
||||
// Both paths share the same existing parent and same name
|
||||
let a = tmp.path().join("feature");
|
||||
let b = tmp.path().join("feature");
|
||||
@@ -263,7 +263,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_paths_match_different_parent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let tmp = test_tempdir();
|
||||
let dir_a = tmp.path().join("a");
|
||||
let dir_b = tmp.path().join("b");
|
||||
std::fs::create_dir(&dir_a).unwrap();
|
||||
@@ -275,7 +275,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_was_worktree_of_finds_existing_worktree() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let tmp = test_tempdir();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let test = TestRepo::at(&base.join("repo"));
|
||||
test.commit("init");
|
||||
@@ -325,7 +325,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_recover_from_path_finds_deleted_worktree() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let tmp = test_tempdir();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let test = TestRepo::at(&base.join("repo"));
|
||||
test.commit("init");
|
||||
@@ -346,7 +346,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_recover_from_path_returns_none_for_unrelated_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let tmp = test_tempdir();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let test = TestRepo::at(&base.join("repo"));
|
||||
test.commit("init");
|
||||
@@ -358,7 +358,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_recover_from_path_multi_repo_siblings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let tmp = test_tempdir();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
|
||||
// Create two sibling repos
|
||||
@@ -393,7 +393,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_recover_from_path_nested_worktree() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let tmp = test_tempdir();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
|
||||
let test = TestRepo::at(&base.join("myrepo"));
|
||||
@@ -416,7 +416,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_recover_from_path_deep_pwd() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let tmp = test_tempdir();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let test = TestRepo::at(&base.join("repo"));
|
||||
test.commit("init");
|
||||
@@ -445,7 +445,7 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_recover_from_path_symlinked_subdir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let tmp = test_tempdir();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let test = TestRepo::at(&base.join("repo"));
|
||||
test.commit("init");
|
||||
|
||||
+9
-25
@@ -632,7 +632,9 @@ mod tests {
|
||||
);
|
||||
|
||||
// Ref should be gone.
|
||||
let exit = std::process::Command::new("git")
|
||||
let mut rev_parse = std::process::Command::new("git");
|
||||
crate::testing::configure_git_cmd(&mut rev_parse);
|
||||
let exit = rev_parse
|
||||
.args(["rev-parse", "--verify", "--quiet", "refs/heads/feature"])
|
||||
.current_dir(test.root_path())
|
||||
.status()
|
||||
@@ -716,7 +718,9 @@ mod tests {
|
||||
);
|
||||
|
||||
// Branch was deleted.
|
||||
let exit = std::process::Command::new("git")
|
||||
let mut rev_parse = std::process::Command::new("git");
|
||||
crate::testing::configure_git_cmd(&mut rev_parse);
|
||||
let exit = rev_parse
|
||||
.args(["rev-parse", "--verify", "--quiet", "refs/heads/feature"])
|
||||
.current_dir(test.root_path())
|
||||
.status()
|
||||
@@ -806,15 +810,7 @@ mod tests {
|
||||
use crate::git::Repository;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let gitconfig = tmp.path().join("gitconfig");
|
||||
std::fs::write(
|
||||
&gitconfig,
|
||||
"[init]\n\tdefaultBranch = main\n[user]\n\tname = t\n\temail = t@t\n",
|
||||
)
|
||||
.unwrap();
|
||||
let git = |dir: &Path| {
|
||||
crate::testing::configure_git_env(Cmd::new("git"), &gitconfig).current_dir(dir)
|
||||
};
|
||||
let git = |dir: &Path| crate::testing::configure_git_env(Cmd::new("git")).current_dir(dir);
|
||||
|
||||
let main = tmp.path().join("repo");
|
||||
std::fs::create_dir(&main).unwrap();
|
||||
@@ -864,15 +860,9 @@ mod tests {
|
||||
use crate::git::Repository;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let gitconfig = tmp.path().join("gitconfig");
|
||||
std::fs::write(
|
||||
&gitconfig,
|
||||
"[init]\n\tdefaultBranch = main\n[user]\n\tname = t\n\temail = t@t\n",
|
||||
)
|
||||
.unwrap();
|
||||
let main = tmp.path().join("repo");
|
||||
std::fs::create_dir(&main).unwrap();
|
||||
crate::testing::configure_git_env(Cmd::new("git"), &gitconfig)
|
||||
crate::testing::configure_git_env(Cmd::new("git"))
|
||||
.current_dir(&main)
|
||||
.args(["init", "-b", "main"])
|
||||
.run()
|
||||
@@ -899,15 +889,9 @@ mod tests {
|
||||
use crate::git::Repository;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let gitconfig = tmp.path().join("gitconfig");
|
||||
std::fs::write(
|
||||
&gitconfig,
|
||||
"[init]\n\tdefaultBranch = main\n[user]\n\tname = t\n\temail = t@t\n",
|
||||
)
|
||||
.unwrap();
|
||||
let main = tmp.path().join("repo");
|
||||
std::fs::create_dir(&main).unwrap();
|
||||
crate::testing::configure_git_env(Cmd::new("git"), &gitconfig)
|
||||
crate::testing::configure_git_env(Cmd::new("git"))
|
||||
.current_dir(&main)
|
||||
.args(["init", "-b", "main"])
|
||||
.run()
|
||||
|
||||
@@ -1478,6 +1478,9 @@ impl Repository {
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
// The one production git spawn that bypasses `Cmd` (see the daemon
|
||||
// rationale above), so it re-applies the test floor by hand.
|
||||
crate::shell_exec::apply_hermetic_test_env(&mut cmd);
|
||||
crate::shell_exec::scrub_directive_env_vars(&mut cmd);
|
||||
// Trace the daemon launch so it's attributed in the timeline rather than
|
||||
// appearing as a gap on the switch hot path. Uses `status()` (not
|
||||
|
||||
@@ -972,13 +972,7 @@ fn build_worktree_config_bare_layout() -> (tempfile::TempDir, std::path::PathBuf
|
||||
std::fs::create_dir_all(&project_root).unwrap();
|
||||
let git_dir = project_root.join(".git");
|
||||
|
||||
let gitconfig = tmp.path().join("test-gitconfig");
|
||||
std::fs::write(
|
||||
&gitconfig,
|
||||
"[init]\n\tdefaultBranch = main\n[user]\n\tname = test\n\temail = test@test\n",
|
||||
)
|
||||
.unwrap();
|
||||
let git = || crate::testing::configure_git_env(Cmd::new("git"), &gitconfig);
|
||||
let git = || crate::testing::configure_git_env(Cmd::new("git"));
|
||||
|
||||
let path_str = |p: &std::path::Path| p.to_str().unwrap().to_owned();
|
||||
|
||||
@@ -1142,10 +1136,8 @@ fn prewarm_still_caches_preload_when_worktree_config_disabled() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = canonicalize(tmp.path()).unwrap().join("normal");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let gitconfig = tmp.path().join("test-gitconfig");
|
||||
std::fs::write(&gitconfig, "[init]\n\tdefaultBranch = main\n").unwrap();
|
||||
|
||||
let out = crate::testing::configure_git_env(Cmd::new("git"), &gitconfig)
|
||||
let out = crate::testing::configure_git_env(Cmd::new("git"))
|
||||
.args(["init", "-b", "main", root.to_str().unwrap()])
|
||||
.run()
|
||||
.unwrap();
|
||||
@@ -1366,17 +1358,8 @@ fn test_worktree_for_branch_dedups_duplicate_warning() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = canonicalize(tmp.path()).unwrap().join("repo");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let gitconfig = tmp.path().join("test-gitconfig");
|
||||
std::fs::write(
|
||||
&gitconfig,
|
||||
"[user]\n\tname = t\n\temail = t@example.com\n[init]\n\tdefaultBranch = main\n",
|
||||
)
|
||||
.unwrap();
|
||||
let git = |args: &[&str], dir: &Path| {
|
||||
let out = Cmd::new("git")
|
||||
.env("GIT_CONFIG_GLOBAL", &gitconfig)
|
||||
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
||||
.env("LC_ALL", "C")
|
||||
let out = crate::testing::configure_git_env(Cmd::new("git"))
|
||||
.args(args.iter().copied())
|
||||
.current_dir(dir)
|
||||
.run()
|
||||
|
||||
@@ -574,6 +574,53 @@ pub fn scrub_git_discovery_env_vars(cmd: &mut std::process::Command) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The hermetic git-config floor for test processes: the deny pair points
|
||||
/// global and system config at a path that does not exist (git reads a
|
||||
/// missing config file as empty), and `GIT_CONFIG_COUNT` with its numbered
|
||||
/// keys and values — git's environment spelling of `-c` — supplies the two
|
||||
/// settings the suite needs in the denied config's place. What each entry is
|
||||
/// for, and why `-c` precedence keeps this list short: `tests/CLAUDE.md` →
|
||||
/// Git Config Isolation.
|
||||
pub const HERMETIC_TEST_GIT_ENV: [(&str, &str); 7] = [
|
||||
("GIT_CONFIG_GLOBAL", "/nonexistent/wt/gitconfig"),
|
||||
("GIT_CONFIG_SYSTEM", "/nonexistent/wt/gitconfig"),
|
||||
("GIT_CONFIG_COUNT", "2"),
|
||||
("GIT_CONFIG_KEY_0", "user.useConfigOnly"),
|
||||
("GIT_CONFIG_VALUE_0", "true"),
|
||||
("GIT_CONFIG_KEY_1", "rerere.enabled"),
|
||||
("GIT_CONFIG_VALUE_1", "false"),
|
||||
];
|
||||
|
||||
/// When latched, every child spawned through [`Cmd`] gets
|
||||
/// [`HERMETIC_TEST_GIT_ENV`] — including the git that *production* code
|
||||
/// spawns while a test drives it in-process, which no per-command harness
|
||||
/// hook can reach. The test harness latches it before the first fixture;
|
||||
/// production code never does. An in-process test cannot set its own
|
||||
/// environment instead — `std::env::set_var` races the other test threads —
|
||||
/// but an atomic latch is sound from any thread.
|
||||
///
|
||||
/// TODO(hermetic-env): a test-serving switch in production code, accepted as
|
||||
/// the pragmatic middle over its alternatives — a pre-`main` constructor
|
||||
/// crate every test target must link, or threading an explicit env value
|
||||
/// through `Repository`, which is the structural fix.
|
||||
static HERMETIC_TEST_ENV_LATCHED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Latch [`HERMETIC_TEST_GIT_ENV`] onto every future [`Cmd`] child in this
|
||||
/// process. Called by the `worktrunk::testing` harness; idempotent.
|
||||
pub fn enable_hermetic_test_env() {
|
||||
HERMETIC_TEST_ENV_LATCHED.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Apply [`HERMETIC_TEST_GIT_ENV`] to `cmd` if the latch is set. The unlatched
|
||||
/// path is production's: one relaxed load, no env writes.
|
||||
pub fn apply_hermetic_test_env(cmd: &mut std::process::Command) {
|
||||
if HERMETIC_TEST_ENV_LATCHED.load(Ordering::Relaxed) {
|
||||
for (key, val) in HERMETIC_TEST_GIT_ENV {
|
||||
cmd.env(key, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Directive-Payload Shell Escaping
|
||||
// ============================================================================
|
||||
@@ -1218,6 +1265,9 @@ impl Cmd {
|
||||
cmd.env(key, val);
|
||||
}
|
||||
|
||||
// Before `self.envs`, so a per-command env can override the floor.
|
||||
apply_hermetic_test_env(cmd);
|
||||
|
||||
for (key, val) in &self.envs {
|
||||
cmd.env(key, val);
|
||||
}
|
||||
|
||||
@@ -420,11 +420,11 @@ pub fn create_mock_ruff(bin_dir: &Path) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
use crate::testing::test_tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_mock_config_write() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let temp = test_tempdir();
|
||||
let bin_dir = temp.path();
|
||||
|
||||
MockConfig::new("test-cmd")
|
||||
|
||||
+270
-106
@@ -25,12 +25,30 @@
|
||||
//!
|
||||
//! ## Environment Isolation
|
||||
//!
|
||||
//! Git commands are run with isolated environments using `Cmd::env()` to ensure:
|
||||
//! - No interference from global git config
|
||||
//! - Deterministic commit timestamps
|
||||
//! - Consistent locale settings
|
||||
//! - No cross-test contamination
|
||||
//! - Thread-safe execution (no global state mutation)
|
||||
//! No `git` the suite runs reads the developer's `~/.gitconfig`. The
|
||||
//! guarantee is `shell_exec::HERMETIC_TEST_GIT_ENV` — the deny pair pointing
|
||||
//! `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` at a path that does not exist,
|
||||
//! plus `user.useConfigOnly` through `GIT_CONFIG_COUNT` so a git with no
|
||||
//! identity fails rather than guessing one from the host — applied to every
|
||||
//! child at its spawn site. For the git that *production* code spawns while a
|
||||
//! test drives it in-process, the spawn site is `Cmd`, and the harness has no
|
||||
//! per-command hook there; instead the fixture constructors latch
|
||||
//! `shell_exec::enable_hermetic_test_env`, and `Cmd` applies the floor to
|
||||
//! every child while the latch is set. (A test cannot set its own process
|
||||
//! environment instead — under `cargo test` tests are parallel threads, and
|
||||
//! `std::env::set_var` beside them is the race that makes it `unsafe`; the
|
||||
//! atomic latch is sound from any thread.)
|
||||
//!
|
||||
//! The rest applies the same floor at the spawn sites the harness does own:
|
||||
//!
|
||||
//! - [`git_test_env`] adds the test identity, pinned dates, and locale per
|
||||
//! command, which the floor leaves to the per-command layers.
|
||||
//! - [`isolate_subprocess_env`] scrubs the host's `GIT_*` from `wt` children
|
||||
//! and re-applies the floor explicitly, so a subprocess denies host config
|
||||
//! just as this process does.
|
||||
//!
|
||||
//! On top of that isolation the helpers pin commit timestamps, locale, and
|
||||
//! terminal width, all per command — no test mutates process-global state.
|
||||
|
||||
pub mod mock_commands;
|
||||
|
||||
@@ -40,7 +58,7 @@ use std::process::Command;
|
||||
|
||||
use crate::config::sanitize_branch_name;
|
||||
use crate::git::Repository;
|
||||
use crate::shell_exec::{Cmd, INHERITED_GIT_PATH_VARS};
|
||||
use crate::shell_exec::{self, Cmd, INHERITED_GIT_PATH_VARS};
|
||||
use path_slash::PathExt;
|
||||
|
||||
use self::mock_commands::{MockConfig, MockResponse};
|
||||
@@ -150,7 +168,7 @@ fn claim_template(parent: &Path, dir: &Path, build: impl FnOnce(&Path)) {
|
||||
/// back-pointing remote), and three feature worktrees with one commit each.
|
||||
fn build_standard_fixture(root: &Path) {
|
||||
let git = |dir: &Path| {
|
||||
configure_git_env(Cmd::new("git"), test_gitconfig_path())
|
||||
configure_git_env(Cmd::new("git"))
|
||||
.env("GIT_AUTHOR_DATE", STANDARD_FIXTURE_COMMIT_DATE)
|
||||
.env("GIT_COMMITTER_DATE", STANDARD_FIXTURE_COMMIT_DATE)
|
||||
.current_dir(dir)
|
||||
@@ -212,6 +230,8 @@ fn build_standard_fixture(root: &Path) {
|
||||
/// Pure Rust recursive copy - 2.5x faster than spawning cp/robocopy.
|
||||
/// Benchmarked at 21ms vs 53ms per fixture copy on macOS.
|
||||
fn copy_standard_fixture(dest: &Path) -> FixtureWorktrees {
|
||||
shell_exec::enable_hermetic_test_env();
|
||||
|
||||
fn copy_dir_recursive(src: &Path, dest: &Path) {
|
||||
std::fs::create_dir_all(dest).unwrap();
|
||||
for entry in std::fs::read_dir(src).unwrap() {
|
||||
@@ -271,49 +291,39 @@ fn copy_standard_fixture(dest: &Path) -> FixtureWorktrees {
|
||||
FixtureWorktrees { worktrees, remote }
|
||||
}
|
||||
|
||||
/// The gitconfig that harness-built commands point `GIT_CONFIG_GLOBAL` at
|
||||
/// (see [`configure_git_env`]). The content is identical for every test, so
|
||||
/// one file per process serves them all — created lazily like
|
||||
/// `isolated_test_cwd`, reaped by the OS after the process exits. Settings
|
||||
/// that must also hold for git spawned in-process go in `LOCAL_TEST_CONFIG`
|
||||
/// instead.
|
||||
pub fn test_gitconfig_path() -> &'static Path {
|
||||
static GITCONFIG: std::sync::LazyLock<(TempDir, PathBuf)> = std::sync::LazyLock::new(|| {
|
||||
let dir = TempDir::new().expect("create test gitconfig dir");
|
||||
let path = dir.path().join("test-gitconfig");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"[user]\n\tname = Test User\n\temail = test@example.com\n\
|
||||
[advice]\n\tmergeConflict = false\n\tresolveConflict = false\n\
|
||||
[init]\n\tdefaultBranch = main\n\
|
||||
[commit]\n\tgpgsign = false\n\
|
||||
[rerere]\n\tenabled = true\n",
|
||||
)
|
||||
.expect("write test gitconfig");
|
||||
(dir, path)
|
||||
});
|
||||
&GITCONFIG.1
|
||||
}
|
||||
/// The identity every test commit is authored and committed under.
|
||||
///
|
||||
/// Reaches a harness-built `git` through [`git_test_env`] and an in-process one
|
||||
/// through [`LOCAL_TEST_CONFIG`], which repeats these two values because a
|
||||
/// config file cannot interpolate a constant.
|
||||
const TEST_IDENTITY_NAME: &str = "Test User";
|
||||
const TEST_IDENTITY_EMAIL: &str = "test@example.com";
|
||||
|
||||
/// Settings written into every test repo's own config by
|
||||
/// [`write_local_test_config`].
|
||||
///
|
||||
/// A unit test driving the library in-process — `Repository::run_command` and
|
||||
/// everything layered on it — gets a `git` carrying the *test process's*
|
||||
/// environment, so none of [`configure_git_env`]'s isolation applies: no
|
||||
/// `GIT_CONFIG_GLOBAL`, meaning the developer's own `~/.gitconfig`, and no
|
||||
/// `GIT_ALLOW_PROTOCOL`. The repo's own config is the one layer such a command
|
||||
/// still reads, so whatever must hold for it lives here.
|
||||
/// environment, so none of [`configure_git_env`]'s per-command isolation
|
||||
/// applies: no `GIT_ALLOW_PROTOCOL`, no identity, no pinned dates. The
|
||||
/// repo's own config is the one layer such a command still reads, so whatever
|
||||
/// must hold for it lives here.
|
||||
///
|
||||
/// What it does *not* have to carry is host-config denial or the floor's
|
||||
/// settings. The hermetic latch (`shell_exec::enable_hermetic_test_env`)
|
||||
/// puts both on every `Cmd` child, so an in-process git resolves those
|
||||
/// rather than the developer's — see the Git Config Isolation section of
|
||||
/// `tests/CLAUDE.md`.
|
||||
///
|
||||
/// `protocol.allow = never` with a `file` exception is the config spelling of
|
||||
/// `GIT_ALLOW_PROTOCOL=file` (see [`GIT_ALLOWED_PROTOCOLS`] for why the suite
|
||||
/// stays off the wire). Identity and `commit.gpgsign` make an in-process commit
|
||||
/// work, and work the same way, on a machine that signs commits by default.
|
||||
/// stays off the wire). The identity is required rather than convenient: the
|
||||
/// hermetic floor sets `user.useConfigOnly`, and deliberately carries no name or
|
||||
/// email, so a repo without a local identity fails its commit instead of
|
||||
/// authoring one from the host's username and hostname.
|
||||
const LOCAL_TEST_CONFIG: &str = r#"[user]
|
||||
name = Test User
|
||||
email = test@example.com
|
||||
[commit]
|
||||
gpgsign = false
|
||||
[protocol]
|
||||
allow = never
|
||||
[protocol "file"]
|
||||
@@ -482,11 +492,15 @@ pub fn pty_env_vars(paths: TestEnvPaths<'_>) -> Vec<(String, String)> {
|
||||
.chain(PTY_TEST_ENV_VARS)
|
||||
.map(|&(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect();
|
||||
// A PTY child is `env_clear`ed, so the hermetic floor reaches it only if
|
||||
// carried across by hand — every other transport gets it from the `Cmd`
|
||||
// latch or `isolate_subprocess_env`.
|
||||
vars.extend(
|
||||
git_test_env(test_gitconfig_path())
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v)),
|
||||
shell_exec::HERMETIC_TEST_GIT_ENV
|
||||
.iter()
|
||||
.map(|&(k, v)| (k.to_string(), v.to_string())),
|
||||
);
|
||||
vars.extend(git_test_env().into_iter().map(|(k, v)| (k.to_string(), v)));
|
||||
|
||||
vars.extend(
|
||||
[
|
||||
@@ -514,13 +528,6 @@ pub fn pty_env_vars(paths: TestEnvPaths<'_>) -> Vec<(String, String)> {
|
||||
vars
|
||||
}
|
||||
|
||||
/// Null device path, platform-appropriate.
|
||||
/// Use this for GIT_CONFIG_SYSTEM to disable system config in tests.
|
||||
#[cfg(windows)]
|
||||
pub const NULL_DEVICE: &str = "NUL";
|
||||
#[cfg(not(windows))]
|
||||
pub const NULL_DEVICE: &str = "/dev/null";
|
||||
|
||||
/// Default user-config path for isolated subprocesses — points at a
|
||||
/// nonexistent file so wt treats it as "no config." Callers can override
|
||||
/// via the `user_config` parameter to [`isolate_subprocess_env`].
|
||||
@@ -575,7 +582,8 @@ fn default_llvm_profile_file_with(inherited: Option<std::ffi::OsString>) -> std:
|
||||
/// Prepare a subprocess to run with a clean wt environment.
|
||||
///
|
||||
/// Strips every `GIT_*` and `WORKTRUNK_*` from the parent env, plus
|
||||
/// `NO_COLOR` / `FORCE_HYPERLINK` / `SHELL` / `PSModulePath`, then points the three
|
||||
/// `NO_COLOR` / `FORCE_HYPERLINK` / `SHELL` / `PSModulePath`; re-applies the
|
||||
/// hermetic floor (`shell_exec::HERMETIC_TEST_GIT_ENV`), then points the three
|
||||
/// `WORKTRUNK_*_PATH` env vars at known locations:
|
||||
///
|
||||
/// - `WORKTRUNK_CONFIG_PATH` ← `user_config` (or [`DEFAULT_ISOLATED_USER_CONFIG`])
|
||||
@@ -614,6 +622,12 @@ where
|
||||
cmd.env_remove(&key);
|
||||
}
|
||||
}
|
||||
// The hermetic floor, restated explicitly now that every inherited
|
||||
// `GIT_*` is gone — the subprocess must deny the host's git config just
|
||||
// as this process does.
|
||||
for (key, val) in shell_exec::HERMETIC_TEST_GIT_ENV {
|
||||
cmd.env(key, val);
|
||||
}
|
||||
cmd.env_remove("NO_COLOR");
|
||||
// Overrides the OSC 8 probe, so an inherited value changes whether `wt
|
||||
// list` links its CI cell and shortens its URL cell to `:port`. The
|
||||
@@ -660,19 +674,71 @@ pub fn scrub_git_path_vars(cmd: &mut Command) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A process-scoped empty directory used as the default `current_dir` for
|
||||
/// [`wt_command`]. Created lazily on first use and kept alive for the rest of
|
||||
/// the test process; the OS reaps it from the temp dir afterwards (statics
|
||||
/// aren't dropped at process exit, so `TempDir::drop` doesn't run).
|
||||
/// Root for every temp directory the test fixtures create: one subdirectory of
|
||||
/// the system temp dir rather than hundreds of siblings directly inside it.
|
||||
///
|
||||
/// The dir is guaranteed to be outside any git repository and to have no
|
||||
/// `.config/wt.toml`, so `wt` invocations spawned through [`wt_command`] don't
|
||||
/// pick up the test process's inherited CWD (which is typically the worktrunk
|
||||
/// repo root, with its own `.config/wt.toml` and git history).
|
||||
/// Entries in the shared temp root are cheap to ignore but expensive to *walk*,
|
||||
/// and `git::recover::recover_from_path` read_dirs every existing ancestor of a
|
||||
/// deleted CWD until a repo claims it — reaching the shared dir itself when
|
||||
/// nothing nearer does. This sub-root can't shorten that worst-case walk; what
|
||||
/// it does is keep the suite's own churn from growing the shared dir every
|
||||
/// run, the accumulation that made the walk slow (see `isolated_test_cwd`).
|
||||
///
|
||||
/// Where that root sits carries no isolation weight: the fixtures read no
|
||||
/// git config outside themselves wherever they live, so a conditional
|
||||
/// `includeIf "gitdir:<home>"` in the developer's config can't reach them.
|
||||
/// Only the ancestor-walk cost above argues for one location over another.
|
||||
///
|
||||
/// The name is two characters because a unix socket path can't exceed
|
||||
/// `sun_path` (104 bytes on macOS, including the NUL). macOS's per-user
|
||||
/// `$TMPDIR` is 56 canonicalized characters, and
|
||||
/// `test_copy_ignored_skips_non_regular_files` binds a listener at
|
||||
/// `<fixture>/repo/target/test.sock` — 89 bytes before this directory exists at
|
||||
/// all. The name and its slash come out of the 14 bytes that were spare;
|
||||
/// `worktrunk-tests` (16 with its slash) would overflow them by two.
|
||||
///
|
||||
/// Created on first use and left in place; what goes inside it are `TempDir`s
|
||||
/// that remove themselves on drop, so it stays near-empty between runs.
|
||||
pub fn test_temp_root() -> &'static Path {
|
||||
static ROOT: std::sync::LazyLock<PathBuf> = std::sync::LazyLock::new(|| {
|
||||
let root = std::env::temp_dir().join("wt");
|
||||
std::fs::create_dir_all(&root).expect("create test temp root");
|
||||
root
|
||||
});
|
||||
ROOT.as_path()
|
||||
}
|
||||
|
||||
/// Create a temp directory under [`test_temp_root`].
|
||||
///
|
||||
/// The fixtures' replacement for `TempDir::new()` / `tempfile::tempdir()`.
|
||||
pub fn test_tempdir() -> TempDir {
|
||||
TempDir::new_in(test_temp_root()).expect("create test temp dir")
|
||||
}
|
||||
|
||||
/// A single fixed empty directory used as the default `current_dir` for
|
||||
/// [`wt_command`], created on first use and shared by every test process.
|
||||
///
|
||||
/// The dir is outside any git repository and has no `.config/wt.toml`, so `wt`
|
||||
/// invocations spawned through [`wt_command`] don't pick up the test process's
|
||||
/// inherited CWD (which is typically the worktrunk repo root, with its own
|
||||
/// `.config/wt.toml` and git history). Nothing writes into it — a test that
|
||||
/// needs to write uses a `TestRepo`.
|
||||
///
|
||||
/// Deliberately *not* a `TempDir`. Statics aren't dropped at process exit, so a
|
||||
/// `TempDir` here is never cleaned up, and nextest runs one process per test:
|
||||
/// that leaked one empty directory per test — ~700 per integration-suite run —
|
||||
/// into a temp root nothing reliably sweeps (macOS clears it only at boot).
|
||||
/// Hundreds of thousands of stale entries cost nothing to ignore but are
|
||||
/// expensive to enumerate, and `git::recover::recover_from_path` reads every
|
||||
/// ancestor directory of a deleted CWD — the measured cost is in
|
||||
/// `tests/CLAUDE.md` → Profiling the Suite. One fixed directory never grows.
|
||||
fn isolated_test_cwd() -> &'static Path {
|
||||
static ISOLATED_CWD: std::sync::LazyLock<TempDir> =
|
||||
std::sync::LazyLock::new(|| TempDir::new().expect("create isolated test cwd"));
|
||||
ISOLATED_CWD.path()
|
||||
static ISOLATED_CWD: std::sync::LazyLock<PathBuf> = std::sync::LazyLock::new(|| {
|
||||
let dir = test_temp_root().join("isolated-cwd");
|
||||
std::fs::create_dir_all(&dir).expect("create isolated test cwd");
|
||||
dir
|
||||
});
|
||||
ISOLATED_CWD.as_path()
|
||||
}
|
||||
|
||||
/// Create a `wt` CLI command with standardized test environment settings.
|
||||
@@ -681,8 +747,8 @@ fn isolated_test_cwd() -> &'static Path {
|
||||
/// - All host `GIT_*` and `WORKTRUNK_*` variables are cleared
|
||||
/// - Color output is forced (`CLICOLOR_FORCE=1`) so ANSI styling appears in snapshots
|
||||
/// - Terminal width set to 150 columns (`COLUMNS=150`)
|
||||
/// - `current_dir` defaults to a process-scoped empty tempdir (not a git repo,
|
||||
/// no project config), so `wt` doesn't pick up worktrunk's own
|
||||
/// - `current_dir` defaults to one fixed empty directory shared by every test
|
||||
/// process (not a git repo, no project config), so `wt` doesn't pick up worktrunk's own
|
||||
/// `.config/wt.toml` or detect a git repo from the test process's inherited
|
||||
/// CWD. Tests that need a specific CWD must override via
|
||||
/// `cmd.current_dir(...)`; `repo.wt_command()` does so automatically.
|
||||
@@ -806,17 +872,27 @@ pub fn configure_cli_command(cmd: &mut Command) {
|
||||
// callers get the same defense; nothing extra needed here.
|
||||
}
|
||||
|
||||
/// The environment for a directly-spawned test `git`: isolated config,
|
||||
/// deterministic timestamps and locale, no terminal prompts, no network
|
||||
/// transports (`GIT_ALLOWED_PROTOCOLS`).
|
||||
/// The environment for a directly-spawned test `git`: deterministic identity,
|
||||
/// timestamps and locale, no terminal prompts, no network transports
|
||||
/// (`GIT_ALLOWED_PROTOCOLS`). Host-config denial is not here — that is the
|
||||
/// hermetic floor's job (`shell_exec::HERMETIC_TEST_GIT_ENV`), which every
|
||||
/// consumer of this set applies through its own transport.
|
||||
///
|
||||
/// Single home for these settings — [`configure_git_cmd`] (Command),
|
||||
/// [`configure_git_env`] (`Cmd`), and [`pty_env_vars`] (PTY) all
|
||||
/// consume it, so the three spellings cannot drift.
|
||||
pub fn git_test_env(git_config_path: &Path) -> [(&'static str, String); 9] {
|
||||
///
|
||||
/// The identity is here rather than in the hermetic floor because the floor
|
||||
/// carries only the denial and the two `-c` settings; identity already has
|
||||
/// this per-command home, and a second copy in the floor could only drift
|
||||
/// from it. A harness-built `git` needs one because it commits into repos it
|
||||
/// has just created, before `LOCAL_TEST_CONFIG` reaches their local config.
|
||||
pub fn git_test_env() -> [(&'static str, String); 11] {
|
||||
[
|
||||
("GIT_CONFIG_GLOBAL", git_config_path.display().to_string()),
|
||||
("GIT_CONFIG_SYSTEM", NULL_DEVICE.to_string()),
|
||||
("GIT_AUTHOR_NAME", TEST_IDENTITY_NAME.to_string()),
|
||||
("GIT_AUTHOR_EMAIL", TEST_IDENTITY_EMAIL.to_string()),
|
||||
("GIT_COMMITTER_NAME", TEST_IDENTITY_NAME.to_string()),
|
||||
("GIT_COMMITTER_EMAIL", TEST_IDENTITY_EMAIL.to_string()),
|
||||
("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z".to_string()),
|
||||
("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z".to_string()),
|
||||
("LC_ALL", "C".to_string()),
|
||||
@@ -830,17 +906,19 @@ pub fn git_test_env(git_config_path: &Path) -> [(&'static str, String); 9] {
|
||||
/// Configure a git command with isolated environment for testing.
|
||||
///
|
||||
/// Applies [`git_test_env`].
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `cmd` - The git Command to configure
|
||||
/// * `git_config_path` - Path to git config file (use `/dev/null` or `NULL_DEVICE` for none)
|
||||
pub fn configure_git_cmd(cmd: &mut Command, git_config_path: &Path) {
|
||||
pub fn configure_git_cmd(cmd: &mut Command) {
|
||||
shell_exec::enable_hermetic_test_env();
|
||||
// Defensive: every existing caller is downstream of `configure_cli_command`
|
||||
// (which already stripped these via `isolate_subprocess_env`), but a future
|
||||
// test that spawns `git` from an unprepared parent shouldn't be vulnerable
|
||||
// to an inherited relative `GIT_DIR` redirecting discovery.
|
||||
scrub_git_path_vars(cmd);
|
||||
for (key, value) in git_test_env(git_config_path) {
|
||||
// The floor by hand — a plain `Command` child doesn't pass through the
|
||||
// `Cmd` latch.
|
||||
for (key, val) in shell_exec::HERMETIC_TEST_GIT_ENV {
|
||||
cmd.env(key, val);
|
||||
}
|
||||
for (key, value) in git_test_env() {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
}
|
||||
@@ -849,12 +927,13 @@ pub fn configure_git_cmd(cmd: &mut Command, git_config_path: &Path) {
|
||||
///
|
||||
/// This is the `Cmd` equivalent of [`configure_git_cmd`]. Use this when building
|
||||
/// git commands via the builder pattern (`Cmd::new("git")`).
|
||||
pub fn configure_git_env(cmd: Cmd, git_config_path: &Path) -> Cmd {
|
||||
pub fn configure_git_env(cmd: Cmd) -> Cmd {
|
||||
shell_exec::enable_hermetic_test_env();
|
||||
// Defensive `GIT_*` path-var strip — see `configure_git_cmd` for rationale.
|
||||
let cmd = INHERITED_GIT_PATH_VARS
|
||||
.iter()
|
||||
.fold(cmd, |acc, var| acc.env_remove(var));
|
||||
git_test_env(git_config_path)
|
||||
git_test_env()
|
||||
.into_iter()
|
||||
.fold(cmd, |acc, (key, value)| acc.env(key, value))
|
||||
}
|
||||
@@ -864,17 +943,14 @@ pub fn configure_git_env(cmd: Cmd, git_config_path: &Path) -> Cmd {
|
||||
/// Provides `configure_git_cmd()` (for `Command`), `git_command()` (returns `Cmd`),
|
||||
/// and `run_git_in()` with consistent environment isolation.
|
||||
pub trait TestRepoBase {
|
||||
/// Path to the git config file for this test.
|
||||
fn git_config_path(&self) -> &Path;
|
||||
|
||||
/// Configure a git command with isolated environment.
|
||||
fn configure_git_cmd(&self, cmd: &mut Command) {
|
||||
configure_git_cmd(cmd, self.git_config_path());
|
||||
configure_git_cmd(cmd);
|
||||
}
|
||||
|
||||
/// Create a git command for the given directory.
|
||||
fn git_command(&self, dir: &Path) -> Cmd {
|
||||
configure_git_env(Cmd::new("git"), self.git_config_path()).current_dir(dir)
|
||||
configure_git_env(Cmd::new("git")).current_dir(dir)
|
||||
}
|
||||
|
||||
/// Run a git command in a specific directory, panicking on failure.
|
||||
@@ -1046,8 +1122,8 @@ pub fn check_git_status(output: &std::process::Output, cmd_desc: &str) {
|
||||
}
|
||||
|
||||
/// The isolated per-test config files a [`TestRepo`] carries, both rooted in
|
||||
/// one temp directory. (The test gitconfig is not here: its content is
|
||||
/// per-process constant, so it lives at [`test_gitconfig_path`].)
|
||||
/// one temp directory. (There is no git config among them: the suite's git
|
||||
/// isolation is environment, carrying no per-test state.)
|
||||
struct TestConfigPaths {
|
||||
wt: PathBuf,
|
||||
approvals: PathBuf,
|
||||
@@ -1119,7 +1195,7 @@ impl TestRepo {
|
||||
/// Bare repos have no working tree — useful for testing error paths
|
||||
/// and bare-repo-specific behavior (e.g., hint fallback to `wt list`).
|
||||
pub fn bare() -> Self {
|
||||
Self::init_repo(&["init", "--bare"])
|
||||
Self::init_repo(&["init", "--bare", "-b", "main"])
|
||||
}
|
||||
|
||||
/// Path to the repository working directory.
|
||||
@@ -1143,7 +1219,7 @@ impl TestRepo {
|
||||
/// Also sets up mock gh/glab commands that appear authenticated to prevent
|
||||
/// CI status hints from appearing in test output.
|
||||
pub fn standard() -> Self {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let temp_dir = test_tempdir();
|
||||
|
||||
// Copy from standard fixture (includes worktrees and remote)
|
||||
let fixture = copy_standard_fixture(temp_dir.path());
|
||||
@@ -1179,7 +1255,7 @@ impl TestRepo {
|
||||
/// project/.git` pattern where the bare dir sits inside a project
|
||||
/// directory the test owns.
|
||||
pub fn bare_at(path: &Path) -> Self {
|
||||
Self::at_with(path, &["init", "--bare", "--quiet"])
|
||||
Self::at_with(path, &["init", "--bare", "-b", "main", "--quiet"])
|
||||
}
|
||||
|
||||
/// Shared initializer for [`at()`](Self::at) and [`bare_at()`](Self::bare_at):
|
||||
@@ -1187,10 +1263,10 @@ impl TestRepo {
|
||||
fn at_with(path: &Path, git_args: &[&str]) -> Self {
|
||||
std::fs::create_dir_all(path).unwrap();
|
||||
|
||||
let config_dir = TempDir::new().unwrap();
|
||||
let config_dir = test_tempdir();
|
||||
let paths = TestConfigPaths::in_dir(config_dir.path());
|
||||
|
||||
configure_git_env(Cmd::new("git"), test_gitconfig_path())
|
||||
configure_git_env(Cmd::new("git"))
|
||||
.args(git_args.iter().copied())
|
||||
.current_dir(path)
|
||||
.run()
|
||||
@@ -1204,19 +1280,20 @@ impl TestRepo {
|
||||
/// Use this for tests that specifically need to test behavior in an
|
||||
/// uninitialized repo. Most tests should use `new()` instead.
|
||||
pub fn empty() -> Self {
|
||||
Self::init_repo(&["init", "-q"])
|
||||
Self::init_repo(&["init", "-q", "-b", "main"])
|
||||
}
|
||||
|
||||
/// Shared initializer for `new()`, `bare()`, and `empty()`: makes a tempdir
|
||||
/// and runs `git init` with the given arguments inside it.
|
||||
fn init_repo(git_args: &[&str]) -> Self {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
shell_exec::enable_hermetic_test_env();
|
||||
let temp_dir = test_tempdir();
|
||||
let root = temp_dir.path().join("repo");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
|
||||
let paths = TestConfigPaths::in_dir(temp_dir.path());
|
||||
|
||||
configure_git_env(Cmd::new("git"), test_gitconfig_path())
|
||||
configure_git_env(Cmd::new("git"))
|
||||
.args(git_args.iter().copied())
|
||||
.current_dir(&root)
|
||||
.run()
|
||||
@@ -1259,7 +1336,7 @@ impl TestRepo {
|
||||
/// This sets environment variables only for the specific command,
|
||||
/// ensuring thread-safety and test isolation.
|
||||
pub fn configure_git_cmd(&self, cmd: &mut Command) {
|
||||
configure_git_cmd(cmd, test_gitconfig_path());
|
||||
configure_git_cmd(cmd);
|
||||
}
|
||||
|
||||
/// This repo's environment for a PTY-spawned wt subprocess.
|
||||
@@ -1304,7 +1381,7 @@ impl TestRepo {
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn git_command(&self) -> Cmd {
|
||||
configure_git_env(Cmd::new("git"), test_gitconfig_path()).current_dir(&self.root)
|
||||
configure_git_env(Cmd::new("git")).current_dir(&self.root)
|
||||
}
|
||||
|
||||
/// Run a git command in the repo root, panicking on failure.
|
||||
@@ -2690,11 +2767,7 @@ impl TestRepo {
|
||||
}
|
||||
}
|
||||
|
||||
impl TestRepoBase for TestRepo {
|
||||
fn git_config_path(&self) -> &Path {
|
||||
test_gitconfig_path()
|
||||
}
|
||||
}
|
||||
impl TestRepoBase for TestRepo {}
|
||||
|
||||
/// Helper to create a bare repository test setup.
|
||||
///
|
||||
@@ -2796,11 +2869,7 @@ impl BareRepoTest {
|
||||
}
|
||||
}
|
||||
|
||||
impl TestRepoBase for BareRepoTest {
|
||||
fn git_config_path(&self) -> &Path {
|
||||
test_gitconfig_path()
|
||||
}
|
||||
}
|
||||
impl TestRepoBase for BareRepoTest {}
|
||||
|
||||
/// Create a configured Command for snapshot testing
|
||||
///
|
||||
@@ -3270,7 +3339,7 @@ mod tests {
|
||||
// named a protocol list would silently keep the fixture clone offline,
|
||||
// and the daily benchmark run is the only thing that would notice.
|
||||
let mut opted_out = Command::new("git");
|
||||
configure_git_cmd(&mut opted_out, Path::new(NULL_DEVICE));
|
||||
configure_git_cmd(&mut opted_out);
|
||||
allow_network_transports(&mut opted_out);
|
||||
assert!(
|
||||
opted_out
|
||||
@@ -3376,7 +3445,7 @@ mod tests {
|
||||
fn test_standard_fixture_template_reproduces_pinned_shas() {
|
||||
let repo = standard_fixture_template().join("repo");
|
||||
let rev_parse = |rev: &str| {
|
||||
let output = configure_git_env(Cmd::new("git"), test_gitconfig_path())
|
||||
let output = configure_git_env(Cmd::new("git"))
|
||||
.args(["rev-parse", rev])
|
||||
.current_dir(&repo)
|
||||
.run()
|
||||
@@ -3444,12 +3513,41 @@ mod tests {
|
||||
assert!(warnings.is_empty() || !warnings[0].contains("loses"));
|
||||
}
|
||||
|
||||
/// A PTY child is the one transport that inherits nothing, so the floor
|
||||
/// is carried by hand — by `configure_pty_command` (the PTY choke point in
|
||||
/// `tests/common`, pinned by its own test there) and by this vector, which
|
||||
/// declares a PTY `wt` child's complete environment and so carries the
|
||||
/// floor itself rather than leaning on the transport. Losing the copy here
|
||||
/// would break that contract silently: the settings only quiet advice and
|
||||
/// refuse a guessed identity, so no PTY assertion would notice.
|
||||
#[test]
|
||||
fn pty_env_vars_carry_the_git_config_floor() {
|
||||
let dir = Path::new("/tmp");
|
||||
let vars = pty_env_vars(TestEnvPaths {
|
||||
home: dir,
|
||||
wt_config: dir,
|
||||
approvals: dir,
|
||||
});
|
||||
let keys: Vec<&str> = vars.iter().map(|(k, _)| k.as_str()).collect();
|
||||
|
||||
// The vector must be complete on its own — every member present,
|
||||
// the numbered settings as much as the deny pair.
|
||||
for (var, _) in shell_exec::HERMETIC_TEST_GIT_ENV {
|
||||
assert!(keys.contains(&var), "{var} missing: {keys:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolate_subprocess_env_scrubs_git_and_worktrunk_keys() {
|
||||
let mut cmd = Command::new("true");
|
||||
let synthetic_env = [
|
||||
"GIT_DIR".to_string(),
|
||||
"GIT_AUTHOR_DATE".to_string(),
|
||||
"GIT_CONFIG_GLOBAL".to_string(),
|
||||
"GIT_CONFIG_SYSTEM".to_string(),
|
||||
"GIT_CONFIG_COUNT".to_string(),
|
||||
"GIT_CONFIG_KEY_0".to_string(),
|
||||
"GIT_CONFIG_VALUE_0".to_string(),
|
||||
"WORKTRUNK_CONFIG_PATH".to_string(),
|
||||
"WORKTRUNK_HISTORY".to_string(),
|
||||
"PATH".to_string(),
|
||||
@@ -3480,6 +3578,18 @@ mod tests {
|
||||
// Not scrubbed: vars that don't match either prefix.
|
||||
assert!(!removed.contains_key("PATH"));
|
||||
assert!(!removed.contains_key("HOME"));
|
||||
// The `GIT_CONFIG_*` family is scrubbed like the rest of `GIT_*`, then
|
||||
// re-set to the hermetic floor's values — a host-exported member can't
|
||||
// reach the child, and the child still denies `~/.gitconfig`. The
|
||||
// numbered members matter as much as the deny pair: drop them and the
|
||||
// child keeps the denial but loses every setting it was denying *for*.
|
||||
for (var, val) in shell_exec::HERMETIC_TEST_GIT_ENV {
|
||||
assert_eq!(
|
||||
removed.get(var),
|
||||
Some(&Some(val.to_string())),
|
||||
"{var} should be re-set to the floor value"
|
||||
);
|
||||
}
|
||||
// No underscore — prefix check requires `GIT_`/`WORKTRUNK_`.
|
||||
assert!(!removed.contains_key("GIT"));
|
||||
assert!(!removed.contains_key("WORKTRUNK"));
|
||||
@@ -3515,6 +3625,60 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The isolation the whole suite rests on, asserted where it is weakest.
|
||||
///
|
||||
/// `Repository::run_command` builds a plain `Cmd::new("git")` with no
|
||||
/// `GIT_CONFIG_*` of its own, so what its child resolves is whatever env
|
||||
/// the spawn site gives it — which the hermetic latch pins to the floor
|
||||
/// for every `Cmd` child. Resolving it through the production API fails loudly if the layer
|
||||
/// goes missing, instead of leaving the suite to read the developer's
|
||||
/// `~/.gitconfig` and pass or fail on its contents.
|
||||
#[test]
|
||||
fn in_process_git_reads_only_the_hermetic_config() {
|
||||
let repo = TestRepo::with_initial_commit();
|
||||
|
||||
// Every setting resolves, and every one of them comes from the
|
||||
// environment rather than a file on the host.
|
||||
let floor = repo
|
||||
.repo
|
||||
.run_command(&["config", "--list", "--show-scope"])
|
||||
.unwrap();
|
||||
let from_env: Vec<&str> = floor
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("command\t"))
|
||||
.collect();
|
||||
insta::assert_snapshot!(from_env.join("\n"), @r"
|
||||
user.useconfigonly=true
|
||||
rerere.enabled=false
|
||||
");
|
||||
|
||||
// Nothing outside the fixture contributes. The only scopes a resolved
|
||||
// setting may carry are `command` (the environment floor above) and
|
||||
// `local` (the fixture's own config); a host `~/.gitconfig` or a
|
||||
// system file reaching this git would surface as `global` or `system`.
|
||||
let outside: Vec<&str> = floor
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with("command\t") && !line.starts_with("local\t"))
|
||||
.collect();
|
||||
assert!(
|
||||
outside.is_empty(),
|
||||
"config resolved from outside the fixture: {outside:#?}"
|
||||
);
|
||||
|
||||
// Identity comes from the fixture's own local config, so a commit made
|
||||
// through the production API is authored the same way on every machine
|
||||
// — and `useConfigOnly` in the floor means a fixture that forgot one
|
||||
// errors rather than borrowing the host's username.
|
||||
std::fs::write(repo.path().join("second.txt"), "second").unwrap();
|
||||
repo.repo.run_command(&["add", "second.txt"]).unwrap();
|
||||
repo.repo.run_command(&["commit", "-m", "second"]).unwrap();
|
||||
let author = repo
|
||||
.repo
|
||||
.run_command(&["log", "-1", "--format=%an <%ae>"])
|
||||
.unwrap();
|
||||
insta::assert_snapshot!(author.trim(), @"Test User <test@example.com>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_llvm_profile_file_with_inherited_value_returns_it_verbatim() {
|
||||
let inherited = std::ffi::OsString::from("/cov/expected-%p.profraw");
|
||||
|
||||
+85
-18
@@ -20,6 +20,28 @@ A target-filtered run (`--lib`, `--test integration`, …) on a fresh `target/`
|
||||
|
||||
**The gate runs one platform, so `#[cfg(unix)]` hides dead code from it.** A helper, const, or import whose every use sits behind `#[cfg(unix)]` is live locally and dead on Windows, where `-D warnings` fails `test (windows)` with "never used". Gate the item with the same predicate as its uses. Cross-compiling to check locally doesn't work: the C build scripts (tree-sitter, libmimalloc-sys) fail before the Rust lint runs.
|
||||
|
||||
## One Result Per Test, Whatever Runs It
|
||||
|
||||
Five runners execute this suite — `cargo test`, `cargo nextest run`, `cargo llvm-cov nextest`, `cargo bench`, and the Nix `worktrunk-tests` derivation — and CI uses several of them on the same commit. **A test's result must not depend on which one started it.**
|
||||
|
||||
So `.config/nextest.toml` carries no setting that changes what a test observes: no `[env]`, and no setup script exporting through `$NEXTEST_ENV`. Anything load-bearing goes where every runner sees it — the harness-latched floor in `shell_exec` for git environment (see Git Config Isolation), `.cargo/config.toml` for `COLUMNS`, the fixture for behavior.
|
||||
|
||||
A nextest-only knob doesn't fail loudly when another runner misses it. It yields a *different result*, and the runner that disagrees is typically `cargo llvm-cov` — whose numbers gate a merge, and whose disagreement therefore reads as a coverage regression rather than as missing configuration.
|
||||
|
||||
The one setup script here, `build-bins`, is a build step rather than a behavior setting: it compiles the `mock-stub` helper a target-filtered run would otherwise skip, and the same gap under plain `cargo test` is handled by `default-members` (see Running the Suite). It carries its own TODO to disappear once cargo-dist supports per-binary exclusion. It is not precedent.
|
||||
|
||||
## Profiling the Suite
|
||||
|
||||
```bash
|
||||
task profile-tests # CPU accounting plus per-test timings
|
||||
```
|
||||
|
||||
The integration binary dominates: ~2,200 tests averaging ~1s, each spawning `wt` and `git` against a fresh fixture copy. Two thirds of the suite's CPU is kernel time, so the cost is process creation and filesystem churn rather than computation, and it sits in a broad middle rather than in a few outliers. Track `user`/`sys` from the `time` line; wall time is unreliable whenever a sibling worktree is building or testing, which on this project is most of the time. Per-test durations land in `target/nextest/default/junit.xml`.
|
||||
|
||||
The fixtures put their temp directories under `test_temp_root()` (`$TMPDIR/wt`) rather than directly in the system temp dir, and `test_tempdir()` is the fixture-side replacement for `TempDir::new()`. Entries in the shared temp root are cheap to ignore but expensive to enumerate, and `git::recover::recover_from_path` reads every ancestor directory of a deleted CWD — its own unit test ran 14.2s against a temp root holding 454k leaked entries, 0.06s once the leak stopped. That root's short name is load-bearing: a unix socket path can't exceed 104 bytes on macOS, and `test_copy_ignored_skips_non_regular_files` binds one 89 bytes in.
|
||||
|
||||
Process-scoped scratch space belongs in a fixed directory, not a `TempDir` in a `static`: statics don't run destructors at process exit, so under nextest's process-per-test model that leaks one directory per test into a temp root nothing reliably sweeps (macOS clears it only at boot). To check for a recurrence, run the suite with `TMPDIR` pointed at a fresh directory and see what survives.
|
||||
|
||||
## Coverage Investigation
|
||||
|
||||
`task coverage` runs the suite through nextest and writes an HTML report to `target/llvm-cov/html/index.html`. Coverage uses the same process isolation as the regular suite: PTY tests must not share one crowded test process. Both CI (the `coverage` workflow) and local `task coverage` pass `--features shell-integration-tests`, so code behind that flag is compiled and measured.
|
||||
@@ -75,7 +97,7 @@ be isolated from the host environment to prevent:
|
||||
|
||||
- **Directive leakage**: Test commands writing to the user's shell directive file
|
||||
- **Config pollution**: Tests reading/writing the user's real config
|
||||
- **Git interference**: Host GIT_* environment variables affecting test behavior
|
||||
- **Git interference**: Host GIT_* environment variables affecting test behavior (the developer's *config* is denied a layer lower — see Git Config Isolation below)
|
||||
- **Network access**: a fixture's `https://` remote URL becoming a real connect, with no timeout bounding it (`GIT_ALLOWED_PROTOCOLS` in `src/testing/mod.rs`)
|
||||
|
||||
### With a TestRepo fixture (most tests)
|
||||
@@ -150,6 +172,34 @@ binaries, not just knobs `wt` itself reads: the harness sets
|
||||
`WORKTRUNK_TEST_MOCK_CONFIG_DIR` and only `mock-stub` reads it. A variable `wt`
|
||||
reads in production drops `TEST` and keeps `WORKTRUNK_`.
|
||||
|
||||
## Git Config Isolation
|
||||
|
||||
**No `git` the suite runs reads the developer's `~/.gitconfig`**, whatever the test drives it through and wherever the fixture lives. The guarantee is one environment set, `shell_exec::HERMETIC_TEST_GIT_ENV` — the deny pair pointing `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` at a path that does not exist, plus the settings the suite needs in the denied config's place — applied to every child at its spawn site. There is no git-config file anywhere in the repo.
|
||||
|
||||
**Production spawn sites get the floor from a latch, not from the test.** Most test variables are set on a *child*: `git_test_env` on a git command, `configure_cli_command` on a `wt` subprocess. In-process git is not a child the test configures. `TestRepo` exposes a `repo` field of the production `Repository` type, and `Repository::run_command` builds a plain `Cmd::new("git")` — the test never holds the command, so there is no place to set env on it, and a test cannot set its own process environment instead (under `cargo test` the tests sharing the process run in parallel threads, and `std::env::set_var` beside a thread that spawns a process is the race that makes it `unsafe`). What a test *can* do safely is flip an atomic: the harness latches `shell_exec::enable_hermetic_test_env` in the fixture constructors and `configure_git_*`, and `Cmd` applies the floor to every child it spawns while the latch is set. Production code never latches it, so a user's `wt` inherits their real config; the latch is a test-serving switch in `shell_exec` accepted deliberately — see the `TODO(hermetic-env)` there for the structural alternative (threading an explicit env value through `Repository`).
|
||||
|
||||
The harness applies the same floor at the spawn sites it does own:
|
||||
|
||||
- `git_test_env` — reaching git through `configure_git_env` / `configure_git_cmd` (`TestRepo::git_command()`, `run_git()`, `run_git_in()`, `git_output()`, `commit_in()`) and a PTY child through `pty_env_vars` — adds the test identity, pinned dates, and locale; `configure_git_cmd` also applies the floor, since a plain `Command` child bypasses the `Cmd` latch.
|
||||
- `isolate_subprocess_env` scrubs every inherited `GIT_*` from `wt` children — a host-exported `GIT_CONFIG_*` included — then re-applies the floor explicitly, so a subprocess denies host config just as this process does.
|
||||
- a PTY child is `env_clear`ed and inherits nothing, so the floor is carried by hand at the PTY choke point every spawn routes through — `configure_pty_command` in `tests/common/mod.rs` — and again by `pty_env_vars`, whose vector declares a PTY `wt` child's complete environment. Each copy is pinned by its own test (`configure_pty_command_carries_the_git_config_floor`, `pty_env_vars_carry_the_git_config_floor`), because `useConfigOnly` fires only where an identity is missing — no PTY assertion would notice the floor going missing.
|
||||
|
||||
**The floor carries two settings.** `user.useConfigOnly` is a backstop: denial alone leaves git *guessing* an identity from the OS username and hostname rather than failing, which is the one way a hermetic suite could still author a commit as the developer. Nothing exercises it, because every path sets an identity, and that is the reason to keep it — without it a future gap goes silent. `rerere.enabled = false` is *set* rather than left unset because git enables rerere on its own whenever `$GIT_DIR/rr-cache` exists, so leaving it unset makes the suite's rerere state depend on what a fixture happens to carry. `commit.gpgsign` is gone, since denial already leaves git on its own default, and so are `advice.mergeConflict` / `advice.resolveConflict` — the snapshot layer strips gutter-prefixed `hint:` lines because they vary across git versions (the filter in `tests/common/mod.rs`), so nothing depends on quieting them at the source.
|
||||
|
||||
**A local run can measure a stale fixture.** The standard fixture is built once into `target/debug/wt-test-fixtures/standard-v<N>/` and copied per test, so whatever a git-config change alters *during fixture construction* survives in that copy until the cache is rebuilt. A floor change can therefore pass locally and fail on CI, which always builds fresh. Removing `rerere.enabled` did exactly that: the cached fixture already held an `rr-cache` directory from when the floor enabled rerere, git turns rerere on whenever that directory exists, and so every local test kept the behavior the change had just removed. Before trusting a local measurement of a git-config change, delete `target/debug/wt-test-fixtures/` or bump `STANDARD_FIXTURE_VERSION`.
|
||||
|
||||
Three things follow that are easy to get wrong:
|
||||
|
||||
- **`GIT_CONFIG_COUNT` is `-c`, so it outranks a repository's own config**, where a global *file* would yield to it. A key belongs in the floor only when no test needs to override it locally. `init.defaultBranch` is the one that doesn't qualify — `default_branch.rs` sets it in a repo to prove `wt` reads it — so every `git init` in the harness names its branch instead.
|
||||
- **Identity is not in the floor.** A harness-built git gets it from `git_test_env`; an in-process git gets it from the fixture repo's local config, which every `TestRepo` constructor writes. The floor carries only the denial and the two `-c` settings; identity has those per-command homes already, and a copy in the floor could only drift from them, with `useConfigOnly` failing loudly if a path misses both.
|
||||
- **Where fixtures live carries no isolation weight.** A conditional `includeIf "gitdir:<home>"` can't reach them wherever they sit, so `test_temp_root()`'s location is a question of ancestor-walk cost alone.
|
||||
|
||||
What the hole cost before this: any key in the developer's config applied to fixture repos, so `commit.gpgsign` failed their commits, `core.hooksPath` ran their hooks, and `core.fsmonitor` / `credential.helper` / `filter.*` ran programs of their choosing. A conditional `includeIf` made which of those happened depend on where `$TMPDIR` sat — the suite passed by accident of the temp dir being outside `$HOME`.
|
||||
|
||||
`cargo run -- <cmd>` is untouched: nothing in production latches the floor, so a developer's own invocations keep their aliases, credential helper, and identity.
|
||||
|
||||
`GIT_AUTHOR_DATE` / `GIT_COMMITTER_DATE` are **not** part of this floor: `git_test_env` pins them per command, so a commit made through `Repository::run_command` still gets the wall clock. Snapshot the author, not the date.
|
||||
|
||||
## Config Isolation for In-Process Unit Tests
|
||||
|
||||
`repo.wt_command()` / `wt_command()` isolate *subprocess* tests (above). An
|
||||
@@ -187,24 +237,41 @@ approvals.approve_command(project, command, &approvals_path).unwrap();
|
||||
</good>
|
||||
</example>
|
||||
|
||||
Git needs the same care. An in-process `Repository::run_command()` spawns git
|
||||
with the test process's environment, so it reads the developer's real
|
||||
`~/.gitconfig` and none of `configure_git_env`'s variables — no
|
||||
`GIT_CONFIG_GLOBAL`, no `GIT_ALLOW_PROTOCOL`. The repo's own config is the one
|
||||
layer such a command still reads, so every `TestRepo` constructor appends
|
||||
`LOCAL_TEST_CONFIG` (identity, `commit.gpgsign`, and the `protocol.allow`
|
||||
transport deny) to it. `TestRepo::assemble` is the single call site; a new
|
||||
constructor routes through it and inherits the settings, and the bare
|
||||
fixtures (`BareRepoTest`, `NestedBareRepoTest`) get them by composing over
|
||||
`TestRepo`.
|
||||
Git needs the same care, for a narrower reason. An in-process
|
||||
`Repository::run_command()` spawns git with the test process's environment, so
|
||||
none of `configure_git_env`'s per-command variables apply — no
|
||||
`GIT_ALLOW_PROTOCOL`, no per-test `GIT_CONFIG_GLOBAL`. Host *config* is not
|
||||
among the gaps: the latched floor above already denies it. The
|
||||
repo's own config is the one layer such a command still reads, so every
|
||||
`TestRepo` constructor appends `LOCAL_TEST_CONFIG` (identity
|
||||
and the `protocol.allow` transport deny) to it. The identity is required rather
|
||||
than convenient — the floor sets `user.useConfigOnly` and carries no name, so a
|
||||
repo without one fails its commit instead of authoring from the host's username.
|
||||
`TestRepo::assemble` is the single call site; a new constructor routes through it
|
||||
and inherits the settings, and the bare fixtures (`BareRepoTest`,
|
||||
`NestedBareRepoTest`) get them by composing over `TestRepo`.
|
||||
|
||||
`approvals_path()` panics when `WORKTRUNK_APPROVALS_PATH` is unset, but
|
||||
`#[cfg(test)]` makes that guard fire only for `worktrunk` lib-crate tests. A
|
||||
bin-crate test (anything under `src/commands/`) links the lib in non-test
|
||||
mode, so the guard is compiled out and a global read hits the real config
|
||||
`approvals_path()` and `config_path()` refuse the developer's real
|
||||
`~/.config/worktrunk/` rather than resolving it. `config_path()` is the one that
|
||||
matters most: `set_skip_shell_integration_prompt` and
|
||||
`set_skip_commit_generation_prompt` reach it to **write**, so an unguarded
|
||||
fall-through edits the config the developer is using. `approvals_path()` panics;
|
||||
`config_path()` returns `None`, because its absent state is already meaningful —
|
||||
`require_config_path()` turns it into an error, so a write still fails loudly
|
||||
while a best-effort read like `prewarm_user_config` simply preloads nothing.
|
||||
|
||||
`#[cfg(test)]` makes both guards fire for `worktrunk` lib-crate tests only. A
|
||||
bin-crate test (anything compiled into the `wt` binary — `src/commands/`,
|
||||
`src/output/`, and the other `main.rs` modules) links the lib in non-test mode,
|
||||
so the guard is compiled out there and a global read hits the real config
|
||||
silently: it passes wherever `$HOME` is writable and fails only in a sandbox
|
||||
that forbids it. `config_path()` and `system_config_path()` have no guard at
|
||||
all.
|
||||
that forbids it. Nothing exercises that today — no bin-crate test creates a
|
||||
config under a scratch `$HOME` — so it's a live requirement on new tests, not a
|
||||
known leak.
|
||||
|
||||
`system_config_path()` is deliberately unguarded: it resolves a machine-wide
|
||||
file rather than the developer's own, and `config::deprecation`'s
|
||||
`PendingDefault` rules need the lookup.
|
||||
|
||||
## Timing Tests: Polling and Absence Windows
|
||||
|
||||
@@ -447,7 +514,7 @@ settings.add_filter(r"_REPO_/system-config\.toml", "[TEST_SYSTEM_CONFIG_FILE]");
|
||||
|
||||
The helper wraps the pattern in `(?:\x1b\[\d+m)*` brackets, which eat only the bold open/close immediately adjacent to the path — surrounding color spans (yellow warning, etc.) are preserved.
|
||||
|
||||
Setup-side path-redaction placeholders in the strip list (`add_placeholder_ansi_strip_filter` in `tests/common/mod.rs`): `[TEST_CONFIG]`, `[TEST_CONFIG_NEW]`, `[TEST_APPROVALS]`, `[TEST_GIT_CONFIG]`, `[PROJECT_ID]`, `[TEMP_HOME]`, `[TEMP]`. Placeholders that hold a real value (`[VERSION]`, `[HASH]`, `[BUILD_MODE]`, `[BINARY_PATH]`) keep their bold codes so the snapshot still asserts the user-visible styling. The strip pass is invoked at the end of every `setup_*_snapshot_settings` helper, so the contract holds uniformly across `setup_snapshot_settings*`, `setup_home_snapshot_settings`, and `setup_temp_snapshot_settings`.
|
||||
Setup-side path-redaction placeholders in the strip list (`add_placeholder_ansi_strip_filter` in `tests/common/mod.rs`): `[TEST_CONFIG]`, `[TEST_CONFIG_NEW]`, `[TEST_APPROVALS]`, `[PROJECT_ID]`, `[TEMP_HOME]`, `[TEMP]`. Placeholders that hold a real value (`[VERSION]`, `[HASH]`, `[BUILD_MODE]`, `[BINARY_PATH]`) keep their bold codes so the snapshot still asserts the user-visible styling. The strip pass is invoked at the end of every `setup_*_snapshot_settings` helper, so the contract holds uniformly across `setup_snapshot_settings*`, `setup_home_snapshot_settings`, and `setup_temp_snapshot_settings`.
|
||||
|
||||
## Test Style
|
||||
|
||||
|
||||
+56
-30
@@ -150,7 +150,7 @@ pub fn repo() -> TestRepo {
|
||||
/// ```
|
||||
#[rstest::fixture]
|
||||
pub fn temp_home() -> TempDir {
|
||||
TempDir::new().unwrap()
|
||||
test_tempdir()
|
||||
}
|
||||
|
||||
/// Canonicalize a `temp_home` for use as a base when building paths that
|
||||
@@ -414,7 +414,11 @@ pub fn open_pty_with_size(rows: u16, cols: u16) -> portable_pty::PtyPair {
|
||||
/// the Windows equivalents)
|
||||
/// 3. Applies the `STATIC_TEST_ENV_VARS` and `PTY_TEST_ENV_VARS` determinism
|
||||
/// baselines
|
||||
/// 4. Passes through LLVM coverage profiling vars so subprocess coverage works
|
||||
/// 4. Applies the hermetic git floor (`HERMETIC_TEST_GIT_ENV`) — a PTY child
|
||||
/// never passes through the `Cmd` latch, and `HOME` here is the real one,
|
||||
/// so without the floor every git a PTY script runs would resolve the
|
||||
/// developer's `~/.gitconfig`
|
||||
/// 5. Passes through LLVM coverage profiling vars so subprocess coverage works
|
||||
///
|
||||
/// It supplies no fixture paths, having no fixture to read them from; a caller
|
||||
/// with one adds `TestRepo::test_env_vars()` on top, which carries the
|
||||
@@ -436,6 +440,14 @@ pub fn configure_pty_command(cmd: &mut portable_pty::CommandBuilder) {
|
||||
worktrunk::testing::TEST_EPOCH.to_string(),
|
||||
);
|
||||
|
||||
// The hermetic git floor, by hand: a PTY child is `env_clear`ed and never
|
||||
// passes through the `Cmd` latch, and HOME below is the developer's real
|
||||
// one — without the floor, every git a PTY script runs would resolve the
|
||||
// real `~/.gitconfig`.
|
||||
for (key, value) in worktrunk::shell_exec::HERMETIC_TEST_GIT_ENV {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
// Minimal environment for shells/binaries to function
|
||||
let home_dir = home::home_dir().unwrap().to_string_lossy().to_string();
|
||||
cmd.env("HOME", &home_dir);
|
||||
@@ -569,7 +581,6 @@ pub fn shell_command(
|
||||
/// These redact volatile metadata captured by insta-cmd in the `info` block.
|
||||
/// Called by all snapshot settings helpers for consistency.
|
||||
pub fn add_standard_env_redactions(settings: &mut insta::Settings) {
|
||||
settings.add_redaction(".env.GIT_CONFIG_GLOBAL", "[TEST_GIT_CONFIG]");
|
||||
settings.add_redaction(".env.WORKTRUNK_CONFIG_PATH", "[TEST_CONFIG]");
|
||||
settings.add_redaction(".env.WORKTRUNK_SYSTEM_CONFIG_PATH", "[TEST_SYSTEM_CONFIG]");
|
||||
settings.add_redaction(
|
||||
@@ -825,10 +836,6 @@ fn add_temp_path_placeholder_filters(settings: &mut insta::Settings) {
|
||||
&format!(r"{TEST_PATH_PREFIX}test-approvals\.toml'?"),
|
||||
"[TEST_APPROVALS]",
|
||||
);
|
||||
settings.add_filter(
|
||||
r"(?:[A-Z]:)?/[^\s]+/\.tmp[^/]+/test-gitconfig",
|
||||
"[TEST_GIT_CONFIG]",
|
||||
);
|
||||
}
|
||||
|
||||
/// Strip ANSI codes immediately wrapping a path-redaction placeholder so a
|
||||
@@ -844,7 +851,7 @@ fn add_temp_path_placeholder_filters(settings: &mut insta::Settings) {
|
||||
/// they must consume ANSI inline via [`add_path_placeholder_filter`].
|
||||
fn add_placeholder_ansi_strip_filter(settings: &mut insta::Settings) {
|
||||
settings.add_filter(
|
||||
r"(?:\x1b\[\d+m)+(\[(?:TEST_(?:CONFIG(?:_NEW)?|APPROVALS|GIT_CONFIG)|PROJECT_ID|TEMP(?:_HOME)?)\])(?:\x1b\[\d+m)+",
|
||||
r"(?:\x1b\[\d+m)+(\[(?:TEST_(?:CONFIG(?:_NEW)?|APPROVALS)|PROJECT_ID|TEMP(?:_HOME)?)\])(?:\x1b\[\d+m)+",
|
||||
"$1",
|
||||
);
|
||||
}
|
||||
@@ -924,38 +931,38 @@ fn add_temp_home_filters(settings: &mut insta::Settings, temp_home: &Path) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Catch tempfile::tempdir() paths under non-standard OS temp directories.
|
||||
/// Catch temp paths under whichever roots the suite actually creates them in.
|
||||
///
|
||||
/// `add_project_id_filters` has hardcoded patterns for standard temp locations
|
||||
/// (/tmp, /var/folders, C:/Users/.../AppData/Local/Temp). CI may use a different
|
||||
/// TEMP (e.g., D:\tmp for faster I/O on Windows). This filter uses the runtime
|
||||
/// temp directory to catch those paths.
|
||||
/// Two roots are live: the fixtures use [`test_temp_root`], and a test that
|
||||
/// reaches for `tempfile` directly lands in the OS temp dir — which CI may
|
||||
/// point somewhere non-standard (e.g. D:\tmp for faster I/O on Windows).
|
||||
/// Deriving both at runtime covers them without another hardcoded platform
|
||||
/// path; `add_project_id_filters` keeps the hardcoded set for paths that
|
||||
/// arrive from a subprocess whose temp dir isn't ours.
|
||||
fn add_os_temp_dir_filter(settings: &mut insta::Settings) {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let temp_dir_str = temp_dir.to_string_lossy().replace('\\', "/");
|
||||
let temp_dir_str = temp_dir_str.trim_end_matches('/');
|
||||
for root in [std::env::temp_dir(), test_temp_root().to_path_buf()] {
|
||||
let root_str = root.to_string_lossy().replace('\\', "/");
|
||||
let root_str = root_str.trim_end_matches('/').to_string();
|
||||
|
||||
let canonical = canonicalize(&temp_dir).unwrap_or_else(|_| temp_dir.clone());
|
||||
let canonical_str = canonical.to_string_lossy().replace('\\', "/");
|
||||
let canonical_str = canonical_str.trim_end_matches('/');
|
||||
let canonical = canonicalize(&root).unwrap_or_else(|_| root.clone());
|
||||
let canonical_str = canonical.to_string_lossy().replace('\\', "/");
|
||||
let canonical_str = canonical_str.trim_end_matches('/').to_string();
|
||||
|
||||
// Canonical (longer) path first so it matches before the shorter one
|
||||
// (e.g., /private/var/folders/... before /var/folders/... on macOS).
|
||||
settings.add_filter(
|
||||
&format!(
|
||||
r"'?{}/\.tmp[^/']+/[^)'\s\x1b]+'?",
|
||||
regex::escape(canonical_str)
|
||||
),
|
||||
"[PROJECT_ID]",
|
||||
);
|
||||
if canonical_str != temp_dir_str {
|
||||
// Canonical (longer) path first so it matches before the shorter one
|
||||
// (e.g., /private/var/folders/... before /var/folders/... on macOS).
|
||||
settings.add_filter(
|
||||
&format!(
|
||||
r"'?{}/\.tmp[^/']+/[^)'\s\x1b]+'?",
|
||||
regex::escape(temp_dir_str)
|
||||
regex::escape(&canonical_str)
|
||||
),
|
||||
"[PROJECT_ID]",
|
||||
);
|
||||
if canonical_str != root_str {
|
||||
settings.add_filter(
|
||||
&format!(r"'?{}/\.tmp[^/']+/[^)'\s\x1b]+'?", regex::escape(&root_str)),
|
||||
"[PROJECT_ID]",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1332,6 +1339,25 @@ mod tests {
|
||||
use insta::assert_snapshot;
|
||||
use rstest::rstest;
|
||||
|
||||
/// Every PTY spawn routes through [`configure_pty_command`] (directly or
|
||||
/// via `shell_command` / `build_pty_command`), and it is the only floor
|
||||
/// the shell-wrapper suite gets: those call sites layer fixture paths and
|
||||
/// an identity on top, never `pty_env_vars`. `useConfigOnly` fires only
|
||||
/// where an identity is missing, so no wrapper assertion would notice the
|
||||
/// floor going missing — this pins it instead.
|
||||
#[test]
|
||||
fn configure_pty_command_carries_the_git_config_floor() {
|
||||
let mut cmd = portable_pty::CommandBuilder::new("true");
|
||||
configure_pty_command(&mut cmd);
|
||||
for (key, value) in worktrunk::shell_exec::HERMETIC_TEST_GIT_ENV {
|
||||
assert_eq!(
|
||||
cmd.get_env(key),
|
||||
Some(std::ffi::OsStr::new(value)),
|
||||
"{key} missing from configure_pty_command"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_commit_with_age(repo: TestRepo) {
|
||||
// TestRepo::standard() already includes one initial commit from fixture
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Force LF line endings for all text files
|
||||
* text=auto eol=lf
|
||||
-1
@@ -1 +0,0 @@
|
||||
ref: refs/heads/main
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = false
|
||||
logallrefupdates = true
|
||||
ignorecase = true
|
||||
precomposeunicode = true
|
||||
autocrlf = false
|
||||
[user]
|
||||
name = Test User
|
||||
email = test@example.com
|
||||
[commit]
|
||||
gpgsign = false
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
-1
@@ -1 +0,0 @@
|
||||
x+)JMU0·`040031QÐKÏ,I,))ÊL*-I-fè=©Þx{vvï‰ø¢Ð6Å_~ÕpAU¦eæ¤ê•T”0D}q³üª»Ö`íDçY¿Elã_“!¨
|
||||
BIN
Binary file not shown.
-2
@@ -1,2 +0,0 @@
|
||||
x•<>Á
|
||||
1D=÷+r¤5›îñê]? †€k¡ÿ߈_à†a˜ÇHo¤L;ª<>åŽËQH)$%FE, EPeše*L<>ßö论Ü6°šÇó×ÒÛ ÒŒ”—’c„}toýÊ|ú.¯j•Ÿð£Ã`ì5
|
||||
@@ -1 +0,0 @@
|
||||
a1e809f511b21ee516412a4520497f0cea4af366
|
||||
-1
@@ -1 +0,0 @@
|
||||
Initial commit
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
[user]
|
||||
name = Test User
|
||||
email = test@example.com
|
||||
[advice]
|
||||
mergeConflict = false
|
||||
resolveConflict = false
|
||||
[init]
|
||||
defaultBranch = main
|
||||
[core]
|
||||
autocrlf = false
|
||||
@@ -28,7 +28,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
use tempfile::TempDir;
|
||||
use worktrunk::testing::{NULL_DEVICE, allow_network_transports, configure_git_cmd};
|
||||
use worktrunk::testing::{allow_network_transports, configure_git_cmd};
|
||||
|
||||
/// Lazy-initialized rust repo path.
|
||||
static RUST_REPO: OnceLock<PathBuf> = OnceLock::new();
|
||||
@@ -152,15 +152,15 @@ pub fn parse_pair(config: &str, prefix: &str) -> Option<(usize, usize)> {
|
||||
Some((a.parse().ok()?, b.parse().ok()?))
|
||||
}
|
||||
|
||||
/// Build a `git` command isolated from host context, with config
|
||||
/// redirected to `NULL_DEVICE`. Thin call-site wrapper around
|
||||
/// Build a `git` command isolated from host context, with the host's
|
||||
/// config denied by the hermetic floor. Thin call-site wrapper around
|
||||
/// [`configure_git_cmd`] — every git invocation in this crate goes
|
||||
/// through here. Doesn't set `current_dir`; callers do that explicitly
|
||||
/// when they have a target. Network transports are denied; the upstream
|
||||
/// fixture clone re-permits them via [`allow_network_transports`].
|
||||
fn git_command() -> Command {
|
||||
let mut cmd = Command::new("git");
|
||||
configure_git_cmd(&mut cmd, Path::new(NULL_DEVICE));
|
||||
configure_git_cmd(&mut cmd);
|
||||
cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::common::{
|
||||
BareRepoTest, SLEEP_FOR_ABSENCE_CHECK, TestRepo, TestRepoBase, canonicalize,
|
||||
configure_directive_files, configure_git_cmd, configure_git_env, directive_files, repo,
|
||||
setup_temp_snapshot_settings, test_gitconfig_path, wait_for_file, wait_for_file_content,
|
||||
wait_for_worktree_removed, wt_command,
|
||||
setup_temp_snapshot_settings, wait_for_file, wait_for_file_content, wait_for_worktree_removed,
|
||||
wt_command,
|
||||
};
|
||||
use insta_cmd::assert_cmd_snapshot;
|
||||
use rstest::rstest;
|
||||
@@ -401,16 +401,12 @@ fn test_repo_path_via_real_git_alias_bare_dot_git_layout() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let temp_path = canonicalize(temp_dir.path()).unwrap();
|
||||
|
||||
// Isolated gitconfig (shared, read-only) so we don't leak the user's
|
||||
// real git settings.
|
||||
let git_config_path = test_gitconfig_path();
|
||||
|
||||
// Layout: repo/.git (bare) + repo/main (linked worktree).
|
||||
let repo_dir = temp_path.join("repo");
|
||||
fs::create_dir(&repo_dir).unwrap();
|
||||
let bare_git = repo_dir.join(".git");
|
||||
|
||||
let git = |dir: &Path| configure_git_env(Cmd::new("git"), git_config_path).current_dir(dir);
|
||||
let git = |dir: &Path| configure_git_env(Cmd::new("git")).current_dir(dir);
|
||||
|
||||
git(&temp_path)
|
||||
.args(["init", "--bare", "--initial-branch", "main"])
|
||||
@@ -463,7 +459,7 @@ fn test_repo_path_via_real_git_alias_bare_dot_git_layout() {
|
||||
|
||||
// Shared wt env applied to both the direct and aliased invocations.
|
||||
let apply_wt_env = |cmd: &mut Command| {
|
||||
configure_git_cmd(cmd, git_config_path);
|
||||
configure_git_cmd(cmd);
|
||||
cmd.env("WORKTRUNK_CONFIG_PATH", &user_config)
|
||||
.env(
|
||||
"WORKTRUNK_SYSTEM_CONFIG_PATH",
|
||||
@@ -1477,11 +1473,7 @@ impl NestedBareRepoTest {
|
||||
}
|
||||
}
|
||||
|
||||
impl TestRepoBase for NestedBareRepoTest {
|
||||
fn git_config_path(&self) -> &Path {
|
||||
test_gitconfig_path()
|
||||
}
|
||||
}
|
||||
impl TestRepoBase for NestedBareRepoTest {}
|
||||
|
||||
/// instead of project/.git/ (GitHub issue #313)
|
||||
#[test]
|
||||
@@ -1679,12 +1671,11 @@ fn test_bare_repo_bootstrap_first_worktree() {
|
||||
#[test]
|
||||
fn test_clone_bare_repo_list_no_status_errors() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let git_config_path = test_gitconfig_path();
|
||||
let test_config_path = temp_dir.path().join("test-config.toml");
|
||||
fs::write(&test_config_path, "").unwrap();
|
||||
|
||||
let run_git = |dir: &Path, args: &[&str]| {
|
||||
let output = configure_git_env(Cmd::new("git"), git_config_path)
|
||||
let output = configure_git_env(Cmd::new("git"))
|
||||
.args(args.iter().copied())
|
||||
.current_dir(dir)
|
||||
.run()
|
||||
@@ -1734,7 +1725,7 @@ fn test_clone_bare_repo_list_no_status_errors() {
|
||||
|
||||
// Run wt list from the bare repo directory (the reported scenario)
|
||||
let mut cmd = wt_command();
|
||||
configure_git_cmd(&mut cmd, git_config_path);
|
||||
configure_git_cmd(&mut cmd);
|
||||
cmd.env("WORKTRUNK_CONFIG_PATH", &test_config_path)
|
||||
.arg("list")
|
||||
.current_dir(&bare_path);
|
||||
@@ -2012,7 +2003,7 @@ mod bare_repo_prompt_pty {
|
||||
|
||||
// Declining records the opt-out as a hint (count 1), not under the legacy
|
||||
// top-level key — so it participates in `wt config state`.
|
||||
let hint_value = configure_git_env(Cmd::new("git"), test.git_config_path())
|
||||
let hint_value = configure_git_env(Cmd::new("git"))
|
||||
.args(["config", "worktrunk.hints.skip-bare-repo-prompt"])
|
||||
.current_dir(&main_worktree)
|
||||
.run()
|
||||
@@ -2024,7 +2015,7 @@ mod bare_repo_prompt_pty {
|
||||
);
|
||||
|
||||
// The legacy top-level key must not be written anymore.
|
||||
let legacy_key = configure_git_env(Cmd::new("git"), test.git_config_path())
|
||||
let legacy_key = configure_git_env(Cmd::new("git"))
|
||||
.args(["config", "worktrunk.skip-bare-repo-prompt"])
|
||||
.current_dir(&main_worktree)
|
||||
.run()
|
||||
|
||||
@@ -587,7 +587,7 @@ fn test_complete_excludes_remote_branches(repo: TestRepo) {
|
||||
// Create a new bare repo to act as remote (fixture already has origin remote)
|
||||
let remote_dir = repo.root_path().parent().unwrap().join("remote.git");
|
||||
repo.git_command()
|
||||
.args(["init", "--bare", remote_dir.to_str().unwrap()])
|
||||
.args(["init", "--bare", "-b", "main", remote_dir.to_str().unwrap()])
|
||||
.run()
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -3533,7 +3533,7 @@ fn test_remove_foreground_with_submodules(mut repo: TestRepo) {
|
||||
// Create a local repo to use as a submodule source
|
||||
let sub_source = repo.root_path().parent().unwrap().join("sub-source");
|
||||
std::fs::create_dir_all(&sub_source).unwrap();
|
||||
repo.run_git_in(&sub_source, &["init"]);
|
||||
repo.run_git_in(&sub_source, &["init", "-b", "main"]);
|
||||
std::fs::write(sub_source.join("sub.txt"), "submodule content").unwrap();
|
||||
repo.run_git_in(&sub_source, &["add", "sub.txt"]);
|
||||
repo.run_git_in(&sub_source, &["commit", "-m", "sub init"]);
|
||||
@@ -3614,7 +3614,7 @@ fn test_remove_worktree_submodule_dirty_fails_closed(mut repo: TestRepo) {
|
||||
// Submodule source.
|
||||
let sub_source = repo.root_path().parent().unwrap().join("sub-source-dirty");
|
||||
std::fs::create_dir_all(&sub_source).unwrap();
|
||||
repo.run_git_in(&sub_source, &["init"]);
|
||||
repo.run_git_in(&sub_source, &["init", "-b", "main"]);
|
||||
std::fs::write(sub_source.join("sub.txt"), "submodule content").unwrap();
|
||||
repo.run_git_in(&sub_source, &["add", "sub.txt"]);
|
||||
repo.run_git_in(&sub_source, &["commit", "-m", "sub init"]);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Integration tests for `wt step promote`
|
||||
|
||||
use crate::common::{TestRepo, make_snapshot_cmd, repo, setup_snapshot_settings, wt_command};
|
||||
use crate::common::{
|
||||
TestRepo, configure_git_env, make_snapshot_cmd, repo, setup_snapshot_settings, wt_command,
|
||||
};
|
||||
use insta_cmd::assert_cmd_snapshot;
|
||||
use rstest::rstest;
|
||||
use std::fs;
|
||||
use worktrunk::git::Repository;
|
||||
use worktrunk::shell_exec::Cmd;
|
||||
|
||||
/// Helper to get the current branch in a directory
|
||||
@@ -292,44 +293,47 @@ fn test_promote_bare_repo_with_worktrees() {
|
||||
let worktree_path = temp_dir.path().join("worktree");
|
||||
let temp_clone = temp_dir.path().join("temp");
|
||||
|
||||
let run_git = |dir: &std::path::Path, args: &[&str]| {
|
||||
let output = configure_git_env(Cmd::new("git"))
|
||||
.args(args.iter().copied())
|
||||
.current_dir(dir)
|
||||
.run()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
};
|
||||
|
||||
// Create a bare repository
|
||||
Cmd::new("git")
|
||||
.args([
|
||||
run_git(
|
||||
temp_dir.path(),
|
||||
&[
|
||||
"init",
|
||||
"--bare",
|
||||
"--initial-branch=main",
|
||||
bare_repo.to_str().unwrap(),
|
||||
])
|
||||
.run()
|
||||
.unwrap();
|
||||
],
|
||||
);
|
||||
|
||||
// Create a commit via a temporary clone
|
||||
Cmd::new("git")
|
||||
.args([
|
||||
run_git(
|
||||
temp_dir.path(),
|
||||
&[
|
||||
"clone",
|
||||
bare_repo.to_str().unwrap(),
|
||||
temp_clone.to_str().unwrap(),
|
||||
])
|
||||
.run()
|
||||
.unwrap();
|
||||
|
||||
let clone_repo = Repository::at(&temp_clone).unwrap();
|
||||
clone_repo
|
||||
.run_command(&["config", "user.email", "test@test.com"])
|
||||
.unwrap();
|
||||
clone_repo
|
||||
.run_command(&["config", "user.name", "Test"])
|
||||
.unwrap();
|
||||
clone_repo
|
||||
.run_command(&["commit", "--allow-empty", "-m", "init"])
|
||||
.unwrap();
|
||||
clone_repo.run_command(&["push", "origin", "main"]).unwrap();
|
||||
],
|
||||
);
|
||||
run_git(&temp_clone, &["commit", "--allow-empty", "-m", "init"]);
|
||||
run_git(&temp_clone, &["push", "origin", "main"]);
|
||||
|
||||
// Add a worktree to the bare repo
|
||||
let bare_repo_handle = Repository::at(&bare_repo).unwrap();
|
||||
bare_repo_handle
|
||||
.run_command(&["worktree", "add", worktree_path.to_str().unwrap(), "main"])
|
||||
.unwrap();
|
||||
run_git(
|
||||
&bare_repo,
|
||||
&["worktree", "add", worktree_path.to_str().unwrap(), "main"],
|
||||
);
|
||||
|
||||
// Try to run promote in the bare repo - should fail with bare repo error
|
||||
let output = wt_command()
|
||||
|
||||
@@ -7,10 +7,16 @@ info:
|
||||
- main
|
||||
env:
|
||||
APPDATA: "[TEST_CONFIG_HOME]"
|
||||
CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]"
|
||||
CLICOLOR_FORCE: "1"
|
||||
COLUMNS: "500"
|
||||
GIT_ALLOW_PROTOCOL: file
|
||||
GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z"
|
||||
GIT_AUTHOR_EMAIL: test@example.com
|
||||
GIT_AUTHOR_NAME: Test User
|
||||
GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z"
|
||||
GIT_COMMITTER_EMAIL: test@example.com
|
||||
GIT_COMMITTER_NAME: Test User
|
||||
GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]"
|
||||
GIT_CONFIG_SYSTEM: /dev/null
|
||||
GIT_TERMINAL_PROMPT: "0"
|
||||
@@ -35,6 +41,7 @@ info:
|
||||
WORKTRUNK_TEST_MOCK_CONFIG_DIR: "[TEST_MOCK_CONFIG]"
|
||||
WORKTRUNK_TEST_NUSHELL_ENV: "0"
|
||||
WORKTRUNK_TEST_OPENCODE_INSTALLED: "0"
|
||||
WORKTRUNK_TEST_PARENT_SHELL: ""
|
||||
WORKTRUNK_TEST_POWERSHELL_ENV: "0"
|
||||
WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0"
|
||||
WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1"
|
||||
@@ -50,7 +57,6 @@ exit_code: 1
|
||||
[31m✗[39m [31mRebase onto [1mmain[22m incomplete[39m
|
||||
[107m [0m Rebasing (1/1)
|
||||
[107m [0m error: could not apply b0165c1... Update shared.txt in feature
|
||||
[107m [0m Recorded preimage for 'shared.txt'
|
||||
[107m [0m Could not apply b0165c1... # Update shared.txt in feature
|
||||
[107m [0m Auto-merging shared.txt
|
||||
[107m [0m CONFLICT (content): Merge conflict in shared.txt
|
||||
|
||||
Reference in New Issue
Block a user