mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
4554f50ce6
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>
2086 lines
76 KiB
Rust
2086 lines
76 KiB
Rust
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, wait_for_file, wait_for_file_content, wait_for_worktree_removed,
|
|
wt_command,
|
|
};
|
|
use insta_cmd::assert_cmd_snapshot;
|
|
use rstest::rstest;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::thread;
|
|
use worktrunk::shell_exec::Cmd;
|
|
|
|
#[test]
|
|
fn test_bare_repo_list_worktrees() {
|
|
let test = BareRepoTest::new();
|
|
|
|
// Create worktrees inside bare repo matching template: {{ branch }}
|
|
// Worktrees are at repo/main and repo/feature
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit on main");
|
|
|
|
let feature_worktree = test.create_worktree("feature", "feature");
|
|
test.commit_in(&feature_worktree, "Work on feature");
|
|
|
|
let settings = setup_temp_snapshot_settings(test.temp_path());
|
|
settings.bind(|| {
|
|
// Run wt list from the main worktree
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
cmd.arg("list").current_dir(&main_worktree);
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_list_shows_no_bare_entry() {
|
|
let test = BareRepoTest::new();
|
|
|
|
// Create one worktree
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
|
|
// Run wt list and verify bare repo is NOT shown (only main worktree appears)
|
|
let settings = setup_temp_snapshot_settings(test.temp_path());
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
cmd.arg("list").current_dir(&main_worktree);
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_switch_creates_worktree() {
|
|
let test = BareRepoTest::new();
|
|
|
|
// Create initial worktree
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
|
|
// Run wt switch --create to create a new worktree
|
|
// Config uses {{ branch }} template, so worktrees are created inside bare repo
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "feature"])
|
|
.current_dir(&main_worktree);
|
|
|
|
let output = cmd.output().unwrap();
|
|
|
|
if !output.status.success() {
|
|
panic!(
|
|
"wt switch failed:\nstdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
// Verify the new worktree was created inside the bare repo
|
|
// Template: {{ branch }} -> repo/feature
|
|
let expected_path = test.bare_repo_path().join("feature");
|
|
assert!(
|
|
expected_path.exists(),
|
|
"Expected worktree at {:?}",
|
|
expected_path
|
|
);
|
|
|
|
// Verify git worktree list shows both worktrees (but not bare repo)
|
|
let output = test
|
|
.git_command(test.bare_repo_path())
|
|
.args(["worktree", "list"])
|
|
.run()
|
|
.unwrap();
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
// Should show 3 entries: bare repo + 2 worktrees
|
|
assert_eq!(stdout.lines().count(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_switch_with_configured_naming() {
|
|
let test = BareRepoTest::new();
|
|
|
|
// Create initial worktree
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
|
|
// Config uses "{{ branch }}" template, so worktrees are created inside bare repo
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "feature"])
|
|
.current_dir(&main_worktree);
|
|
|
|
let output = cmd.output().unwrap();
|
|
|
|
if !output.status.success() {
|
|
panic!(
|
|
"wt switch failed:\nstdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
// Verify worktree was created inside bare repo
|
|
let expected_path = test.bare_repo_path().join("feature");
|
|
assert!(
|
|
expected_path.exists(),
|
|
"Expected worktree at {:?}",
|
|
expected_path
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_remove_worktree() {
|
|
let test = BareRepoTest::new();
|
|
|
|
// Create two worktrees
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
|
|
let feature_worktree = test.create_worktree("feature", "feature");
|
|
test.commit_in(&feature_worktree, "Feature work");
|
|
|
|
// Remove feature worktree from main worktree
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["remove", "feature", "--foreground"])
|
|
.current_dir(&main_worktree);
|
|
|
|
let output = cmd.output().unwrap();
|
|
|
|
if !output.status.success() {
|
|
panic!(
|
|
"wt remove failed:\nstdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
// Verify feature worktree was removed
|
|
assert!(
|
|
!feature_worktree.exists(),
|
|
"Feature worktree should be removed"
|
|
);
|
|
|
|
// Verify main worktree still exists
|
|
assert!(main_worktree.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_identifies_primary_correctly() {
|
|
let test = BareRepoTest::new();
|
|
|
|
// Create multiple worktrees
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Main commit");
|
|
|
|
let _feature1 = test.create_worktree("feature1", "feature1");
|
|
let _feature2 = test.create_worktree("feature2", "feature2");
|
|
|
|
// Run wt list to see which is marked as primary
|
|
let settings = setup_temp_snapshot_settings(test.temp_path());
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
cmd.arg("list").current_dir(&main_worktree);
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_path_used_for_worktree_paths() {
|
|
let test = BareRepoTest::new();
|
|
|
|
// Create initial worktree
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
|
|
// Create new worktree - config uses {{ branch }} template
|
|
// Worktrees are created inside the bare repo directory
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "dev"])
|
|
.current_dir(&main_worktree);
|
|
|
|
cmd.output().unwrap();
|
|
|
|
// Verify path is created inside bare repo (using repo_path as base)
|
|
// Template: {{ branch }} -> repo/dev
|
|
let expected = test.bare_repo_path().join("dev");
|
|
assert!(
|
|
expected.exists(),
|
|
"Worktree should be created using repo_path: {:?}",
|
|
expected
|
|
);
|
|
|
|
// Should NOT be relative to main worktree's directory (as if it were a non-bare repo)
|
|
let wrong_path = main_worktree.parent().unwrap().join("main.dev");
|
|
assert!(
|
|
!wrong_path.exists(),
|
|
"Worktree should not use worktree directory as base"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_with_repo_path_variable() {
|
|
// Test that {{ repo_path }} resolves correctly in bare repos
|
|
// For bare repos, repo_path should be the bare repo directory itself
|
|
let test = BareRepoTest::new();
|
|
|
|
// Override config to use {{ repo_path }} explicitly
|
|
fs::write(
|
|
test.config_path(),
|
|
"worktree-path = \"{{ repo_path }}/../worktrees/{{ branch | sanitize }}\"\n",
|
|
)
|
|
.unwrap();
|
|
|
|
// Create initial worktree
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
|
|
// Create new worktree using wt switch
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "feature/auth"])
|
|
.current_dir(&main_worktree);
|
|
|
|
let output = cmd.output().unwrap();
|
|
|
|
if !output.status.success() {
|
|
panic!(
|
|
"wt switch failed:\nstdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
// Verify worktree was created at sibling path using {{ repo_path }}/../worktrees/
|
|
// Bare repo is at /tmp/xxx/repo, so worktree should be at /tmp/xxx/worktrees/feature-auth
|
|
let expected_path = test
|
|
.bare_repo_path()
|
|
.parent()
|
|
.unwrap()
|
|
.join("worktrees")
|
|
.join("feature-auth");
|
|
assert!(
|
|
expected_path.exists(),
|
|
"Expected worktree at {:?} (using repo_path variable)",
|
|
expected_path
|
|
);
|
|
}
|
|
|
|
/// Regression test for #1914: when `wt` is invoked via a git shell alias
|
|
/// (`alias.wt = "!wt"`), git exports `GIT_DIR` — sometimes as a *relative*
|
|
/// path like `.git`. That relative path would otherwise re-resolve against
|
|
/// every child command's `current_dir`, breaking worktrunk's repo discovery
|
|
/// in bare-layout repositories. Worktrunk normalizes inherited relative
|
|
/// `GIT_*` path variables at startup so `{{ repo_path }}` resolves
|
|
/// identically whether `wt` is invoked directly or via a git alias.
|
|
#[test]
|
|
fn test_bare_repo_repo_path_with_inherited_relative_git_dir() {
|
|
let test = BareRepoTest::new();
|
|
|
|
// Configure a user-level alias that prints repo_path. We use user config
|
|
// (not project config) so it's discoverable even when the command is
|
|
// launched from the bare repo's parent directory.
|
|
let user_config = test.temp_path().join("user-config.toml");
|
|
fs::write(
|
|
&user_config,
|
|
"[aliases]\nprint-repo-path = \"echo REPO_PATH={{ repo_path }}\"\n",
|
|
)
|
|
.unwrap();
|
|
|
|
// Create a linked worktree so there's somewhere to run from.
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
|
|
// Helper: run `wt step print-repo-path` and extract the
|
|
// `REPO_PATH=...` value emitted by the alias. We compare these as-is
|
|
// so platform-specific path formatting (e.g. msys-style paths that
|
|
// Git Bash uses on Windows for `echo`) doesn't affect the test — we
|
|
// only assert that both invocations produce the *same* value.
|
|
// No `-y` needed: user-config aliases skip approval entirely.
|
|
let extract_repo_path = |out: &std::process::Output| -> String {
|
|
let combined = format!(
|
|
"{}{}",
|
|
String::from_utf8_lossy(&out.stdout),
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
combined
|
|
.lines()
|
|
.find_map(|line| line.trim().strip_prefix("REPO_PATH=").map(str::to_owned))
|
|
.unwrap_or_else(|| panic!("no REPO_PATH= line in output:\n{combined}"))
|
|
};
|
|
|
|
// Baseline: invoke `wt` normally from the main worktree.
|
|
let mut baseline = wt_command();
|
|
test.configure_wt_cmd(&mut baseline);
|
|
baseline
|
|
.env("WORKTRUNK_CONFIG_PATH", &user_config)
|
|
.args(["step", "print-repo-path"])
|
|
.current_dir(&main_worktree);
|
|
let baseline_out = baseline.output().unwrap();
|
|
assert!(
|
|
baseline_out.status.success(),
|
|
"baseline wt failed:\nstdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&baseline_out.stdout),
|
|
String::from_utf8_lossy(&baseline_out.stderr)
|
|
);
|
|
let baseline_repo_path = extract_repo_path(&baseline_out);
|
|
|
|
// Simulate a git alias invocation: git sets GIT_DIR (and GIT_PREFIX) for
|
|
// shell aliases. From a linked worktree, git sets GIT_DIR to the
|
|
// per-worktree admin dir — use a relative spelling to exercise the bug.
|
|
let worktree_git_dir = test.bare_repo_path().join("worktrees").join("main");
|
|
assert!(
|
|
worktree_git_dir.exists(),
|
|
"expected linked worktree admin dir at {worktree_git_dir:?}"
|
|
);
|
|
// Relative path from main_worktree to its per-worktree admin dir.
|
|
let relative_git_dir = PathBuf::from("..").join("worktrees").join("main");
|
|
|
|
let mut via_alias = wt_command();
|
|
test.configure_wt_cmd(&mut via_alias);
|
|
via_alias
|
|
.env("WORKTRUNK_CONFIG_PATH", &user_config)
|
|
.env("GIT_DIR", &relative_git_dir)
|
|
.env("GIT_PREFIX", "")
|
|
.args(["step", "print-repo-path"])
|
|
.current_dir(&main_worktree);
|
|
let via_alias_out = via_alias.output().unwrap();
|
|
assert!(
|
|
via_alias_out.status.success(),
|
|
"wt via git alias failed:\nstdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&via_alias_out.stdout),
|
|
String::from_utf8_lossy(&via_alias_out.stderr)
|
|
);
|
|
let via_alias_repo_path = extract_repo_path(&via_alias_out);
|
|
|
|
assert_eq!(
|
|
baseline_repo_path, via_alias_repo_path,
|
|
"repo_path differed when invoked via a simulated git alias \
|
|
(relative GIT_DIR was not normalized — see #1914)"
|
|
);
|
|
}
|
|
|
|
/// Companion to `test_bare_repo_repo_path_with_inherited_relative_git_dir`
|
|
/// that exercises the full `git wt …` → `sh -c` → `wt` path using a *real*
|
|
/// git alias instead of hand-crafted `GIT_*` env vars.
|
|
///
|
|
/// To actually trigger #1914, the layout must match the issue reporter's:
|
|
/// `repo/.git` is the bare repository and `repo/main` is a linked worktree.
|
|
/// Running `git wt …` from `repo/` causes git to export `GIT_DIR=.git`
|
|
/// (relative), which is the bug vector — `BareRepoTest` uses a different
|
|
/// layout (`repo/` *is* the bare) where git exports an absolute `GIT_DIR`
|
|
/// and the bug doesn't reproduce, so we build the layout by hand here.
|
|
///
|
|
/// Unix-only: git aliases execute via `sh -c`, and shell-quoting the wt
|
|
/// binary path for Windows' bundled sh is fiddly. The underlying fix in
|
|
/// `shell_exec.rs` has no platform-specific code, so the simulated-env
|
|
/// companion test still exercises the normalization on Windows.
|
|
#[cfg(not(windows))]
|
|
#[test]
|
|
fn test_repo_path_via_real_git_alias_bare_dot_git_layout() {
|
|
use crate::common::{configure_git_env, wt_bin};
|
|
|
|
let temp_dir = tempfile::TempDir::new().unwrap();
|
|
let temp_path = canonicalize(temp_dir.path()).unwrap();
|
|
|
|
// 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")).current_dir(dir);
|
|
|
|
git(&temp_path)
|
|
.args(["init", "--bare", "--initial-branch", "main"])
|
|
.arg(bare_git.to_str().unwrap())
|
|
.run()
|
|
.unwrap();
|
|
git(&bare_git)
|
|
.args(["worktree", "add", "../main"])
|
|
.run()
|
|
.unwrap();
|
|
|
|
let main_worktree = repo_dir.join("main");
|
|
fs::write(main_worktree.join("a.txt"), "hello").unwrap();
|
|
git(&main_worktree).args(["add", "a.txt"]).run().unwrap();
|
|
git(&main_worktree)
|
|
.args(["commit", "-m", "Initial commit"])
|
|
.run()
|
|
.unwrap();
|
|
|
|
// wt config with worktree-path template and the `print-repo-path` alias.
|
|
let user_config = temp_path.join("test-config.toml");
|
|
fs::write(
|
|
&user_config,
|
|
"worktree-path = \"{{ branch }}\"\n\
|
|
[aliases]\nprint-repo-path = \"echo REPO_PATH={{ repo_path }}\"\n",
|
|
)
|
|
.unwrap();
|
|
let approvals_path = temp_path.join("test-approvals.toml");
|
|
|
|
// Register the real git alias in the bare repo's config.
|
|
let wt_path = wt_bin();
|
|
let wt_path_lossy = wt_path.to_string_lossy();
|
|
let wt_path_escaped = shell_escape::unix::escape(wt_path_lossy);
|
|
git(&bare_git)
|
|
.args(["config", "alias.wt", &format!("!{wt_path_escaped}")])
|
|
.run()
|
|
.unwrap();
|
|
|
|
let extract_repo_path = |out: &std::process::Output| -> String {
|
|
let combined = format!(
|
|
"{}{}",
|
|
String::from_utf8_lossy(&out.stdout),
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
combined
|
|
.lines()
|
|
.find_map(|line| line.trim().strip_prefix("REPO_PATH=").map(str::to_owned))
|
|
.unwrap_or_else(|| panic!("no REPO_PATH= line in output:\n{combined}"))
|
|
};
|
|
|
|
// Shared wt env applied to both the direct and aliased invocations.
|
|
let apply_wt_env = |cmd: &mut Command| {
|
|
configure_git_cmd(cmd);
|
|
cmd.env("WORKTRUNK_CONFIG_PATH", &user_config)
|
|
.env(
|
|
"WORKTRUNK_SYSTEM_CONFIG_PATH",
|
|
"/etc/xdg/worktrunk/config.toml",
|
|
)
|
|
.env("WORKTRUNK_APPROVALS_PATH", &approvals_path)
|
|
.env_remove("NO_COLOR")
|
|
.env_remove("CLICOLOR_FORCE");
|
|
};
|
|
|
|
// Baseline: invoke `wt` directly from `repo/`. Git discovers the bare
|
|
// `.git` automatically without exporting any `GIT_*` path vars.
|
|
let mut baseline = Command::new(wt_bin());
|
|
apply_wt_env(&mut baseline);
|
|
baseline
|
|
.args(["step", "print-repo-path"])
|
|
.current_dir(&repo_dir);
|
|
let baseline_out = baseline.output().unwrap();
|
|
assert!(
|
|
baseline_out.status.success(),
|
|
"baseline wt failed:\nstdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&baseline_out.stdout),
|
|
String::from_utf8_lossy(&baseline_out.stderr)
|
|
);
|
|
let baseline_repo_path = extract_repo_path(&baseline_out);
|
|
|
|
// Via the real git alias: `git wt step print-repo-path`. From the
|
|
// `repo/` dir (not inside the worktree), git sets `GIT_DIR=.git`
|
|
// (relative) when exporting the alias environment — the exact bug vector
|
|
// from #1914.
|
|
let mut via_alias = Command::new("git");
|
|
apply_wt_env(&mut via_alias);
|
|
via_alias
|
|
.args(["wt", "step", "print-repo-path"])
|
|
.current_dir(&repo_dir);
|
|
let via_alias_out = via_alias.output().unwrap();
|
|
assert!(
|
|
via_alias_out.status.success(),
|
|
"git wt via real alias failed:\nstdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&via_alias_out.stdout),
|
|
String::from_utf8_lossy(&via_alias_out.stderr)
|
|
);
|
|
let via_alias_repo_path = extract_repo_path(&via_alias_out);
|
|
|
|
assert_eq!(
|
|
baseline_repo_path, via_alias_repo_path,
|
|
"repo_path differed when invoked via a real `git wt` alias — see #1914"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_bare_repo_equivalent_to_normal_repo(repo: TestRepo) {
|
|
// This test verifies that bare repos behave identically to normal repos
|
|
// from the user's perspective
|
|
|
|
// Remove fixture worktrees to get a clean state with just main
|
|
for branch in &["feature-a", "feature-b", "feature-c"] {
|
|
let worktree_path = repo
|
|
.root_path()
|
|
.parent()
|
|
.unwrap()
|
|
.join(format!("repo.{}", branch));
|
|
if worktree_path.exists() {
|
|
repo.git_command()
|
|
.args([
|
|
"worktree",
|
|
"remove",
|
|
"--force",
|
|
worktree_path.to_str().unwrap(),
|
|
])
|
|
.run()
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
// Set up bare repo
|
|
let bare_test = BareRepoTest::new();
|
|
let bare_main = bare_test.create_worktree("main", "main");
|
|
bare_test.commit_in(&bare_main, "Commit in bare repo");
|
|
|
|
// Set up normal repo (using fixture)
|
|
repo.commit("Commit in normal repo");
|
|
|
|
// Configure both with same worktree path pattern
|
|
let config = r#"
|
|
worktree-path = "{{ branch }}"
|
|
"#;
|
|
fs::write(bare_test.config_path(), config).unwrap();
|
|
fs::write(repo.test_config_path(), config).unwrap();
|
|
|
|
// List worktrees in both - should show similar structure
|
|
let mut bare_list = wt_command();
|
|
bare_test.configure_wt_cmd(&mut bare_list);
|
|
bare_list.arg("list").current_dir(&bare_main);
|
|
|
|
let mut normal_list = wt_command();
|
|
repo.configure_wt_cmd(&mut normal_list);
|
|
normal_list.arg("list").current_dir(repo.root_path());
|
|
|
|
let bare_output = bare_list.output().unwrap();
|
|
let normal_output = normal_list.output().unwrap();
|
|
|
|
// Both should show 1 worktree (main/main) - table output is on stdout
|
|
let bare_stdout = String::from_utf8_lossy(&bare_output.stdout);
|
|
let normal_stdout = String::from_utf8_lossy(&normal_output.stdout);
|
|
|
|
assert!(bare_stdout.contains("main"));
|
|
assert!(normal_stdout.contains("main"));
|
|
assert_eq!(bare_stdout.lines().count(), normal_stdout.lines().count());
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_commands_from_bare_directory() {
|
|
let test = BareRepoTest::new();
|
|
|
|
// Create a worktree so the repo has some content
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
|
|
// Run wt list from the bare repo directory itself (not from a worktree)
|
|
// Should list the worktree even when run from bare repo, not showing bare repo itself
|
|
let settings = setup_temp_snapshot_settings(test.temp_path());
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
cmd.arg("list").current_dir(test.bare_repo_path());
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
});
|
|
}
|
|
|
|
/// Full merge workflow in a bare repo: create main + feature worktrees, merge
|
|
/// feature into main, and confirm the merge landed.
|
|
///
|
|
/// Runs on all platforms. Background worktree cleanup after merge is tolerated
|
|
/// via `wait_for_worktree_removed` — on Windows, file locking can leave an empty
|
|
/// placeholder dir behind, which is production-harmless (see the predicate note
|
|
/// at the call site below).
|
|
#[test]
|
|
fn test_bare_repo_merge_workflow() {
|
|
let test = BareRepoTest::new();
|
|
|
|
// Create main worktree
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit on main");
|
|
|
|
// Create feature branch worktree using wt switch
|
|
// Config uses {{ branch }} template, so worktrees are inside bare repo
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "feature"])
|
|
.current_dir(&main_worktree);
|
|
cmd.output().unwrap();
|
|
|
|
// Get feature worktree path (template: {{ branch }} -> repo/feature)
|
|
let feature_worktree = test.bare_repo_path().join("feature");
|
|
assert!(feature_worktree.exists());
|
|
|
|
// Make a commit in feature worktree
|
|
test.commit_in(&feature_worktree, "Feature work");
|
|
|
|
// Merge feature into main (explicitly specify target)
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args([
|
|
"merge",
|
|
"main", // Explicitly specify target branch
|
|
"--no-squash", // Skip squash to avoid LLM dependency
|
|
"--no-hooks", // Skip pre-merge hooks
|
|
])
|
|
.current_dir(&feature_worktree);
|
|
|
|
let output = cmd.output().unwrap();
|
|
|
|
if !output.status.success() {
|
|
panic!(
|
|
"wt merge failed:\nstdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
// Wait for background removal to complete
|
|
// Use the "gone or empty placeholder" predicate — the instant-removal
|
|
// path leaves an empty dir behind if the background `rmdir` silently
|
|
// fails (stray `.DS_Store`, scheduling delay under parallel load).
|
|
// That placeholder is production-harmless but trips a strict
|
|
// `!exists()` check.
|
|
wait_for_worktree_removed(&feature_worktree);
|
|
|
|
// Verify main worktree still exists and has the feature commit
|
|
assert!(main_worktree.exists());
|
|
|
|
// Check that feature branch commit is now in main
|
|
let log_output = test
|
|
.git_command(&main_worktree)
|
|
.args(["log", "--oneline"])
|
|
.run()
|
|
.unwrap();
|
|
|
|
let log = String::from_utf8_lossy(&log_output.stdout);
|
|
assert!(
|
|
log.contains("Feature work"),
|
|
"Main should contain feature commit after merge"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_background_logs_location() {
|
|
// This test verifies that background operation logs go to the correct location
|
|
// in bare repos (bare_repo/wt/logs/ instead of worktree/.git/wt/logs/)
|
|
let test = BareRepoTest::new();
|
|
|
|
// Create main worktree
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
|
|
// Create feature worktree
|
|
let feature_worktree = test.create_worktree("feature", "feature");
|
|
test.commit_in(&feature_worktree, "Feature work");
|
|
|
|
// Run remove in background to test log file location
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["remove", "feature"]).current_dir(&main_worktree);
|
|
|
|
let output = cmd.output().unwrap();
|
|
|
|
if !output.status.success() {
|
|
panic!(
|
|
"wt remove failed:\nstdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
// Wait for background process to create log file (poll instead of fixed sleep)
|
|
// The key test is that the path is correct, not that content was written
|
|
// (background processes are flaky in tests). Logs live at:
|
|
// `{bare_repo}/wt/logs/{sanitized-branch}/internal/remove.log`
|
|
let log_dir = test.bare_repo_path().join("wt/logs");
|
|
let remove_log = log_dir
|
|
.join(worktrunk::path::sanitize_for_filename("feature"))
|
|
.join("internal")
|
|
.join("remove.log");
|
|
wait_for_file(&remove_log);
|
|
assert!(
|
|
remove_log.exists(),
|
|
"Expected remove log at {}",
|
|
remove_log.display()
|
|
);
|
|
|
|
// Verify it's NOT in the worktree's .git directory (which doesn't exist for linked worktrees)
|
|
let wrong_dir = main_worktree.join(".git/wt/logs");
|
|
assert!(
|
|
!wrong_dir.exists()
|
|
|| std::fs::read_dir(&wrong_dir)
|
|
.map(|d| d.count())
|
|
.unwrap_or(0)
|
|
== 0,
|
|
"Log should NOT be in worktree's .git directory"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_project_config_found_from_bare_root() {
|
|
// Regression test for #1691: project config in the primary worktree should be
|
|
// found when running from the bare repo root directory, not just from within
|
|
// a worktree that contains the config.
|
|
let test = BareRepoTest::new();
|
|
|
|
// Create main worktree (the primary worktree for bare repos)
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
|
|
// Place project config in the primary worktree's .config/wt.toml
|
|
let config_dir = main_worktree.join(".config");
|
|
fs::create_dir_all(&config_dir).unwrap();
|
|
|
|
// Use a marker file to prove the hook ran
|
|
let marker_path = test.bare_repo_path().join("hook-ran.marker");
|
|
let marker_str = marker_path.to_str().unwrap().replace('\\', "/");
|
|
fs::write(
|
|
config_dir.join("wt.toml"),
|
|
format!("post-start = \"echo hook-executed > '{}'\"\n", marker_str),
|
|
)
|
|
.unwrap();
|
|
|
|
// Commit the config so it's part of the worktree
|
|
let output = test
|
|
.git_command(&main_worktree)
|
|
.args(["add", ".config/wt.toml"])
|
|
.run()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
test.commit_in(&main_worktree, "Add project config");
|
|
|
|
// Now run `wt switch --create feature` from the bare repo root (NOT from main worktree)
|
|
// This is the scenario described in #1691
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "feature", "--yes"])
|
|
.current_dir(test.bare_repo_path());
|
|
|
|
let output = cmd.output().unwrap();
|
|
|
|
if !output.status.success() {
|
|
panic!(
|
|
"wt switch failed:\nstdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
// The hook from the primary worktree's config should have executed
|
|
wait_for_file_content(&marker_path);
|
|
let content = fs::read_to_string(&marker_path).unwrap();
|
|
assert!(
|
|
content.contains("hook-executed"),
|
|
"Hook from primary worktree config should run when command is invoked from bare root. \
|
|
Marker file content: {:?}",
|
|
content
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_project_config_found_when_primary_on_non_default_branch() {
|
|
// Regression test for #3461: the project config must still be found from the
|
|
// bare root when the primary worktree is temporarily checked out to a
|
|
// *non-default* branch. This is the gap left by #1691's fix —
|
|
// `project_config_path()` locates the primary worktree via
|
|
// `primary_worktree()`, which looks it up by "which worktree holds the
|
|
// default branch". When an agent-driven workflow briefly checks out a PR
|
|
// branch in the primary worktree, no worktree holds the default branch, so
|
|
// the project source was dropped silently and NO project hooks fired.
|
|
//
|
|
// The fix reads the committed default-branch config from the object store
|
|
// (`git show <default>:.config/wt.toml`), so this test also pins that it
|
|
// reads the *default branch's* config, not whatever the parked worktree
|
|
// happens to have on disk: after parking the primary off-branch, the
|
|
// working-tree config is overwritten with a divergent hook that must NOT
|
|
// run.
|
|
let test = BareRepoTest::new();
|
|
|
|
// Create main worktree (the primary worktree for bare repos)
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
|
|
// Place project config in the primary worktree's .config/wt.toml
|
|
let config_dir = main_worktree.join(".config");
|
|
fs::create_dir_all(&config_dir).unwrap();
|
|
|
|
// Marker written by the default branch's committed hook (the one that
|
|
// must run), plus a marker for a divergent on-disk hook that must not.
|
|
let marker_path = test.bare_repo_path().join("hook-ran-off-branch.marker");
|
|
let marker_str = marker_path.to_str().unwrap().replace('\\', "/");
|
|
let stale_marker_path = test.bare_repo_path().join("hook-ran-stale.marker");
|
|
let stale_marker_str = stale_marker_path.to_str().unwrap().replace('\\', "/");
|
|
fs::write(
|
|
config_dir.join("wt.toml"),
|
|
format!("post-start = \"echo hook-executed > '{}'\"\n", marker_str),
|
|
)
|
|
.unwrap();
|
|
|
|
// Commit the config so it's part of the worktree
|
|
let output = test
|
|
.git_command(&main_worktree)
|
|
.args(["add", ".config/wt.toml"])
|
|
.run()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
test.commit_in(&main_worktree, "Add project config");
|
|
|
|
// Move the primary worktree off the default branch, so no worktree holds
|
|
// `main`. This is the exact state that triggers the regression.
|
|
let output = test
|
|
.git_command(&main_worktree)
|
|
.args(["checkout", "-b", "feature-x"])
|
|
.run()
|
|
.unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"checkout -b feature-x failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
// Overwrite the parked worktree's on-disk config with a divergent hook,
|
|
// uncommitted. If resolution read the parked worktree's files instead of
|
|
// the default branch's committed tree, this stale hook would run.
|
|
fs::write(
|
|
config_dir.join("wt.toml"),
|
|
format!("post-start = \"echo stale > '{}'\"\n", stale_marker_str),
|
|
)
|
|
.unwrap();
|
|
|
|
// Now run `wt switch --create test-repro` from the bare repo root.
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "test-repro", "--yes"])
|
|
.current_dir(test.bare_repo_path());
|
|
|
|
let output = cmd.output().unwrap();
|
|
|
|
if !output.status.success() {
|
|
panic!(
|
|
"wt switch failed:\nstdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
// The default branch's committed hook must run even though the primary
|
|
// worktree is parked on a non-default branch.
|
|
wait_for_file_content(&marker_path);
|
|
let content = fs::read_to_string(&marker_path).unwrap();
|
|
assert!(
|
|
content.contains("hook-executed"),
|
|
"Project hook must run from the bare root even when the primary worktree \
|
|
is on a non-default branch (#3461). Marker file content: {:?}",
|
|
content
|
|
);
|
|
|
|
// The parked worktree's divergent on-disk hook must NOT run — resolution
|
|
// reads the default branch's committed config, not the checked-out files.
|
|
assert!(
|
|
!stale_marker_path.exists(),
|
|
"Resolution must read the default branch's committed config via `git \
|
|
show`, not the parked worktree's on-disk file"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_no_project_config_when_primary_off_branch_and_none_present() {
|
|
// Companion to the #3461 fix: the object-store fallback that reads
|
|
// `git show <default>:.config/wt.toml` when the default branch is checked
|
|
// out nowhere must not conjure a config that doesn't exist. With the primary
|
|
// off the default branch and no config committed on the default branch,
|
|
// `git show` exits non-zero and resolution stays `None` — the command
|
|
// succeeds and no project hook runs.
|
|
let test = BareRepoTest::new();
|
|
|
|
// Create main worktree (the primary worktree for bare repos) — no config
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
|
|
// Move the primary worktree off the default branch.
|
|
let output = test
|
|
.git_command(&main_worktree)
|
|
.args(["checkout", "-b", "feature-x"])
|
|
.run()
|
|
.unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"checkout -b feature-x failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
// Run `wt switch --create foo` from the bare repo root. With no project
|
|
// config anywhere, it should still succeed.
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "foo", "--yes"])
|
|
.current_dir(test.bare_repo_path());
|
|
|
|
let output = cmd.output().unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"wt switch should succeed with no project config:\nstdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
// No config exists, so no worktree should be reported as carrying one and
|
|
// no project hook can run. `wt config show` from the bare root confirms the
|
|
// fallback found nothing rather than resolving a phantom config.
|
|
let mut show = wt_command();
|
|
test.configure_wt_cmd(&mut show);
|
|
show.args(["config", "show"])
|
|
.current_dir(test.bare_repo_path());
|
|
let show_out = show.output().unwrap();
|
|
let stdout = String::from_utf8_lossy(&show_out.stdout);
|
|
assert!(
|
|
!stdout.contains("[pre-start]") && !stdout.contains("[post-start]"),
|
|
"no project hooks should be resolved when no config exists:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_committed_config_does_not_supersede_override() {
|
|
// Companion to the #3461 fix: the object-store fallback must never
|
|
// supersede an explicit `WORKTRUNK_PROJECT_CONFIG_PATH`. The override
|
|
// names the config source outright — a missing file there means no
|
|
// project config — and it exists for test isolation, where reading the
|
|
// repo's own committed config is exactly the leak being prevented.
|
|
let test = BareRepoTest::new();
|
|
|
|
// Commit a project config on the default branch, then park the primary
|
|
// worktree off it — the state where the committed fallback applies.
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
let config_dir = main_worktree.join(".config");
|
|
fs::create_dir_all(&config_dir).unwrap();
|
|
fs::write(
|
|
config_dir.join("wt.toml"),
|
|
"pre-start = \"echo committed\"\n",
|
|
)
|
|
.unwrap();
|
|
let output = test
|
|
.git_command(&main_worktree)
|
|
.args(["add", ".config/wt.toml"])
|
|
.run()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
test.commit_in(&main_worktree, "Add project config");
|
|
let output = test
|
|
.git_command(&main_worktree)
|
|
.args(["checkout", "-b", "feature-x"])
|
|
.run()
|
|
.unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"checkout -b feature-x failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let mut cmd = test.wt_command();
|
|
cmd.env(
|
|
"WORKTRUNK_PROJECT_CONFIG_PATH",
|
|
test.temp_path().join("nonexistent.toml"),
|
|
);
|
|
cmd.args(["config", "show", "--format=json"])
|
|
.current_dir(test.bare_repo_path());
|
|
let output = cmd.output().unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"stderr: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
let json: serde_json::Value =
|
|
serde_json::from_str(&String::from_utf8_lossy(&output.stdout)).unwrap();
|
|
assert!(
|
|
json["project"]["config"].is_null(),
|
|
"an override pointing at a missing file must not fall back to the committed config, got: {}",
|
|
json["project"]["config"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_project_config_found_from_linked_worktree_when_primary_off_branch() {
|
|
// Regression for the #3461 fix: the object-store fallback must resolve the
|
|
// *default branch's* config by name, not via `HEAD`. `git show HEAD:...`
|
|
// resolves HEAD against the invocation cwd's per-worktree HEAD, so when `wt`
|
|
// runs from inside a linked worktree parked on another branch — the common
|
|
// agent case — HEAD is that worktree's branch, not the default. Reading
|
|
// HEAD there dropped the default branch's config and every project hook.
|
|
//
|
|
// This test runs `wt switch --create` from inside a *second* linked worktree
|
|
// whose branch has no `.config/wt.toml` on disk, with the default branch
|
|
// checked out nowhere, and asserts the default branch's committed hook still
|
|
// fires. `git show HEAD:...` would read the invoking worktree's branch
|
|
// (no config) and silently drop the hook; `git show <default>:...` finds it.
|
|
let test = BareRepoTest::new();
|
|
|
|
// Primary worktree on the default branch, carrying the project config.
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
|
|
let config_dir = main_worktree.join(".config");
|
|
fs::create_dir_all(&config_dir).unwrap();
|
|
let marker_path = test.bare_repo_path().join("hook-ran-from-linked.marker");
|
|
let marker_str = marker_path.to_str().unwrap().replace('\\', "/");
|
|
fs::write(
|
|
config_dir.join("wt.toml"),
|
|
format!("post-start = \"echo hook-executed > '{}'\"\n", marker_str),
|
|
)
|
|
.unwrap();
|
|
let output = test
|
|
.git_command(&main_worktree)
|
|
.args(["add", ".config/wt.toml"])
|
|
.run()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
test.commit_in(&main_worktree, "Add project config");
|
|
|
|
// A second linked worktree whose branch drops the config on disk, so
|
|
// resolution there must fall through to the object-store read.
|
|
let other_worktree = test.create_worktree("other", "other");
|
|
let output = test
|
|
.git_command(&other_worktree)
|
|
.args(["rm", ".config/wt.toml"])
|
|
.run()
|
|
.unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"git rm failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
test.commit_in(&other_worktree, "Remove project config on other branch");
|
|
|
|
// Park the primary off the default branch, so `main` is checked out nowhere.
|
|
let output = test
|
|
.git_command(&main_worktree)
|
|
.args(["checkout", "-b", "feature-x"])
|
|
.run()
|
|
.unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"checkout -b feature-x failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
// Run `wt switch --create` from *inside* the `other` worktree (HEAD = other).
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "test-repro", "--yes"])
|
|
.current_dir(&other_worktree);
|
|
|
|
let output = cmd.output().unwrap();
|
|
if !output.status.success() {
|
|
panic!(
|
|
"wt switch failed:\nstdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
// The default branch's committed hook must run even though `wt` was invoked
|
|
// from a linked worktree whose own branch carries no config.
|
|
wait_for_file_content(&marker_path);
|
|
let content = fs::read_to_string(&marker_path).unwrap();
|
|
assert!(
|
|
content.contains("hook-executed"),
|
|
"Project hook must resolve the default branch's config by name (not via \
|
|
cwd `HEAD`) when invoked from a linked worktree. Marker content: {:?}",
|
|
content
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_config_show_reflects_object_store_fallback() {
|
|
// Regression for #3476: `wt config show` must display the config that
|
|
// `load_project_config` actually resolves. In the #3461 state — a bare repo
|
|
// whose default branch (carrying `.config/wt.toml`) is checked out in no
|
|
// worktree — the config comes from the object store, but `config show`
|
|
// previously keyed off `project_config_path()` alone and reported the
|
|
// config as absent (`Not found` / `exists: false`) while its hooks run.
|
|
let test = BareRepoTest::new();
|
|
|
|
// Primary worktree on the default branch, carrying the project config.
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
|
|
let config_dir = main_worktree.join(".config");
|
|
fs::create_dir_all(&config_dir).unwrap();
|
|
fs::write(config_dir.join("wt.toml"), "post-start = \"echo hi\"\n").unwrap();
|
|
let output = test
|
|
.git_command(&main_worktree)
|
|
.args(["add", ".config/wt.toml"])
|
|
.run()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
test.commit_in(&main_worktree, "Add project config");
|
|
|
|
// A second linked worktree whose branch drops the config on disk, so
|
|
// resolution from inside it must fall through to the object-store read.
|
|
let other_worktree = test.create_worktree("other", "other");
|
|
let output = test
|
|
.git_command(&other_worktree)
|
|
.args(["rm", ".config/wt.toml"])
|
|
.run()
|
|
.unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"git rm failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
test.commit_in(&other_worktree, "Remove project config on other branch");
|
|
|
|
// Park the primary off the default branch, so `main` is checked out nowhere.
|
|
let output = test
|
|
.git_command(&main_worktree)
|
|
.args(["checkout", "-b", "feature-x"])
|
|
.run()
|
|
.unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"checkout -b feature-x failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
// JSON: `path`/`exists`/`config` must agree with the object-store source.
|
|
let mut cmd = test.wt_command();
|
|
cmd.args(["config", "show", "--format=json"])
|
|
.current_dir(&other_worktree);
|
|
let output = cmd.output().unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"stderr: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
let json: serde_json::Value =
|
|
serde_json::from_str(&String::from_utf8_lossy(&output.stdout)).unwrap();
|
|
assert_eq!(
|
|
json["project"]["config"]["post-start"], "echo hi",
|
|
"config show must surface the object-store config that hooks run from, got: {}",
|
|
json["project"]
|
|
);
|
|
assert_eq!(
|
|
json["project"]["exists"], true,
|
|
"`exists` must be true when config resolved from the object store, got: {}",
|
|
json["project"]
|
|
);
|
|
assert_eq!(
|
|
json["project"]["path"], "main:.config/wt.toml",
|
|
"`path` must name the object-store revision spec, not a missing file, got: {}",
|
|
json["project"]
|
|
);
|
|
|
|
// Text: the object-store source is labeled and the config is dumped —
|
|
// never `Not found`.
|
|
let mut cmd = test.wt_command();
|
|
cmd.args(["config", "show"]).current_dir(&other_worktree);
|
|
let output = cmd.output().unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"stderr: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout.contains("from object store") && stdout.contains("post-start"),
|
|
"config show text must label the object-store source and dump the config; stdout:\n{stdout}"
|
|
);
|
|
assert!(
|
|
!stdout.contains("Not found"),
|
|
"config show must not report the object-store config as `Not found`; stdout:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_project_config_found_with_dash_c_flag() {
|
|
// Regression test for #1691 (comment): project config in the primary worktree
|
|
// should be found when using `-C <repo>` from an unrelated directory.
|
|
let test = BareRepoTest::new();
|
|
|
|
// Create main worktree (the primary worktree for bare repos)
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
|
|
// Place project config in the primary worktree's .config/wt.toml
|
|
let config_dir = main_worktree.join(".config");
|
|
fs::create_dir_all(&config_dir).unwrap();
|
|
|
|
// Use a marker file to prove the hook ran
|
|
let marker_path = test.bare_repo_path().join("hook-ran-c-flag.marker");
|
|
let marker_str = marker_path.to_str().unwrap().replace('\\', "/");
|
|
fs::write(
|
|
config_dir.join("wt.toml"),
|
|
format!("post-start = \"echo hook-executed > '{}'\"\n", marker_str),
|
|
)
|
|
.unwrap();
|
|
|
|
// Commit the config so it's part of the worktree
|
|
let output = test
|
|
.git_command(&main_worktree)
|
|
.args(["add", ".config/wt.toml"])
|
|
.run()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
test.commit_in(&main_worktree, "Add project config");
|
|
|
|
// Run from a completely unrelated directory using -C to point at the bare repo
|
|
let unrelated_dir = tempfile::tempdir().unwrap();
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args([
|
|
"-C",
|
|
test.bare_repo_path().to_str().unwrap(),
|
|
"switch",
|
|
"--create",
|
|
"feature-c-flag",
|
|
"--yes",
|
|
])
|
|
.current_dir(unrelated_dir.path());
|
|
|
|
let output = cmd.output().unwrap();
|
|
|
|
if !output.status.success() {
|
|
panic!(
|
|
"wt switch -C failed:\nstdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
// The hook from the primary worktree's config should have executed
|
|
wait_for_file_content(&marker_path);
|
|
let content = fs::read_to_string(&marker_path).unwrap();
|
|
assert!(
|
|
content.contains("hook-executed"),
|
|
"Hook from primary worktree config should run when using -C flag. \
|
|
Marker file content: {:?}",
|
|
content
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_ignores_config_in_bare_root() {
|
|
// Regression test for #1691: a `.config/wt.toml` placed in the bare repo root
|
|
// directory should NOT be picked up. Only the primary worktree's config matters.
|
|
let test = BareRepoTest::new();
|
|
|
|
// Create main worktree (the primary worktree for bare repos) — no config here
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
|
|
// Place config in the bare repo root (NOT in a worktree)
|
|
let config_dir = test.bare_repo_path().join(".config");
|
|
fs::create_dir_all(&config_dir).unwrap();
|
|
|
|
let marker_path = test.bare_repo_path().join("hook-should-not-run.marker");
|
|
let marker_str = marker_path.to_str().unwrap().replace('\\', "/");
|
|
fs::write(
|
|
config_dir.join("wt.toml"),
|
|
format!("post-start = \"echo bad > '{}'\"\n", marker_str),
|
|
)
|
|
.unwrap();
|
|
|
|
// Run `wt switch --create feature` from the bare repo root
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "feature", "--yes"])
|
|
.current_dir(test.bare_repo_path());
|
|
|
|
let output = cmd.output().unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"wt switch failed:\nstdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
// The hook from the bare root config should NOT have executed
|
|
thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
|
|
assert!(
|
|
!marker_path.exists(),
|
|
"Config in bare repo root should be ignored — only primary worktree config should be used"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_slashed_branch_with_sanitize() {
|
|
// Test that slashed branch names work with bare repos and the sanitize filter
|
|
// This matches the documented workflow in tips-patterns.md
|
|
let test = BareRepoTest::new();
|
|
|
|
// Override config to use sanitize filter (matches documented config)
|
|
fs::write(
|
|
test.config_path(),
|
|
"worktree-path = \"{{ branch | sanitize }}\"\n",
|
|
)
|
|
.unwrap();
|
|
|
|
// Create main worktree
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
|
|
// Create feature branch with slash using wt switch
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "feature/auth"])
|
|
.current_dir(&main_worktree);
|
|
|
|
let output = cmd.output().unwrap();
|
|
|
|
if !output.status.success() {
|
|
panic!(
|
|
"wt switch failed:\nstdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
// Verify worktree was created with sanitized name (feature-auth, not feature/auth)
|
|
let expected_path = test.bare_repo_path().join("feature-auth");
|
|
assert!(
|
|
expected_path.exists(),
|
|
"Expected worktree at {:?} (sanitized from feature/auth)",
|
|
expected_path
|
|
);
|
|
|
|
// Verify slashed path was NOT created
|
|
let wrong_path = test.bare_repo_path().join("feature/auth");
|
|
assert!(
|
|
!wrong_path.exists(),
|
|
"Should not create nested directory for slashed branch"
|
|
);
|
|
|
|
// Verify git branch name is preserved (not sanitized)
|
|
let branch_output = test
|
|
.git_command(&expected_path)
|
|
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
.run()
|
|
.unwrap();
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&branch_output.stdout).trim(),
|
|
"feature/auth",
|
|
"Git branch name should be preserved as feature/auth"
|
|
);
|
|
}
|
|
|
|
/// Helper to create a nested bare repository test setup (project/.git pattern)
|
|
///
|
|
/// This tests the pattern from GitHub issue #313 where users clone with:
|
|
/// `git clone --bare <url> project/.git`
|
|
///
|
|
/// Composes over [`TestRepo::bare_at`], which owns the repo plumbing
|
|
/// (canonicalized root, isolated config paths, `LOCAL_TEST_CONFIG`); this
|
|
/// struct owns the project directory the bare repo nests in, plus the
|
|
/// sibling-worktree template (`../{{ branch }}`).
|
|
struct NestedBareRepoTest {
|
|
/// Owns project/; must outlive `repo`, whose path lives inside it.
|
|
temp_dir: tempfile::TempDir,
|
|
/// Path to the parent directory (project/)
|
|
project_path: PathBuf,
|
|
/// Config lives in `temp_dir`, not in `repo`'s internal config dir:
|
|
/// snapshots print this path, and their filters cover `temp_path()`.
|
|
test_config_path: PathBuf,
|
|
repo: TestRepo,
|
|
}
|
|
|
|
impl NestedBareRepoTest {
|
|
fn new() -> Self {
|
|
let temp_dir = tempfile::TempDir::new().unwrap();
|
|
// Create project directory with the bare repo inside as .git
|
|
let project_path = temp_dir.path().join("project");
|
|
let repo = TestRepo::bare_at(&project_path.join(".git"));
|
|
let project_path = canonicalize(&project_path).unwrap();
|
|
|
|
// Worktrees as siblings to .git: project/main, project/feature
|
|
let test_config_path = temp_dir.path().join("test-config.toml");
|
|
fs::write(&test_config_path, "worktree-path = \"../{{ branch }}\"\n").unwrap();
|
|
|
|
Self {
|
|
temp_dir,
|
|
project_path,
|
|
test_config_path,
|
|
repo,
|
|
}
|
|
}
|
|
|
|
fn project_path(&self) -> &Path {
|
|
&self.project_path
|
|
}
|
|
|
|
fn bare_repo_path(&self) -> &Path {
|
|
self.repo.path()
|
|
}
|
|
|
|
fn config_path(&self) -> &Path {
|
|
&self.test_config_path
|
|
}
|
|
|
|
fn temp_path(&self) -> &Path {
|
|
self.temp_dir.path()
|
|
}
|
|
|
|
/// Configure a wt command with test environment
|
|
fn configure_wt_cmd(&self, cmd: &mut Command) {
|
|
self.configure_git_cmd(cmd);
|
|
cmd.env("WORKTRUNK_CONFIG_PATH", &self.test_config_path)
|
|
.env_remove("NO_COLOR")
|
|
.env_remove("CLICOLOR_FORCE");
|
|
}
|
|
|
|
/// This fixture's environment for a PTY-spawned wt subprocess, built from
|
|
/// the same layers as `TestRepo::test_env_vars`.
|
|
#[cfg(all(unix, feature = "shell-integration-tests"))]
|
|
fn test_env_vars(&self) -> Vec<(String, String)> {
|
|
use crate::common::{TestEnvPaths, pty_env_vars};
|
|
|
|
// HOME and XDG_CONFIG_HOME are needed for config lookups in env_clear'd PTY
|
|
let home = self.temp_dir.path().join("home");
|
|
std::fs::create_dir_all(&home).ok();
|
|
|
|
pty_env_vars(TestEnvPaths {
|
|
home: &home,
|
|
wt_config: &self.test_config_path,
|
|
approvals: &self.temp_dir.path().join("test-approvals.toml"),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl TestRepoBase for NestedBareRepoTest {}
|
|
|
|
/// instead of project/.git/ (GitHub issue #313)
|
|
#[test]
|
|
fn test_nested_bare_repo_worktree_path() {
|
|
let test = NestedBareRepoTest::new();
|
|
|
|
// Create first worktree using wt switch --create
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "main"])
|
|
.current_dir(test.bare_repo_path());
|
|
|
|
let output = cmd.output().unwrap();
|
|
|
|
if !output.status.success() {
|
|
panic!(
|
|
"wt switch --create main failed:\nstdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
// CRITICAL: Worktree should be at project/main, NOT project/.git/main
|
|
let expected_path = test.project_path().join("main");
|
|
let wrong_path = test.bare_repo_path().join("main");
|
|
|
|
assert!(
|
|
expected_path.exists(),
|
|
"Expected worktree at {:?} (sibling to .git)",
|
|
expected_path
|
|
);
|
|
assert!(
|
|
!wrong_path.exists(),
|
|
"Worktree should NOT be inside .git directory at {:?}",
|
|
wrong_path
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_nested_bare_repo_full_workflow() {
|
|
let test = NestedBareRepoTest::new();
|
|
|
|
// Create main worktree
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "main"])
|
|
.current_dir(test.bare_repo_path());
|
|
cmd.output().unwrap();
|
|
|
|
let main_worktree = test.project_path().join("main");
|
|
assert!(main_worktree.exists());
|
|
test.commit_in(&main_worktree, "Initial");
|
|
|
|
// Create feature worktree
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "feature"])
|
|
.current_dir(&main_worktree);
|
|
cmd.output().unwrap();
|
|
|
|
// Feature worktree should be at project/feature
|
|
let feature_worktree = test.project_path().join("feature");
|
|
assert!(
|
|
feature_worktree.exists(),
|
|
"Feature worktree should be at project/feature"
|
|
);
|
|
|
|
// List should show both worktrees
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
cmd.arg("list").current_dir(&main_worktree);
|
|
let output = cmd.output().unwrap();
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
assert!(stdout.contains("main"), "Should list main worktree");
|
|
assert!(stdout.contains("feature"), "Should list feature worktree");
|
|
|
|
// Remove feature worktree
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["remove", "feature", "--foreground"])
|
|
.current_dir(&main_worktree);
|
|
cmd.output().unwrap();
|
|
|
|
assert!(
|
|
!feature_worktree.exists(),
|
|
"Feature worktree should be removed"
|
|
);
|
|
assert!(main_worktree.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn test_nested_bare_repo_list_snapshot() {
|
|
let test = NestedBareRepoTest::new();
|
|
|
|
// Create main worktree
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "main"])
|
|
.current_dir(test.bare_repo_path());
|
|
cmd.output().unwrap();
|
|
|
|
let main_worktree = test.project_path().join("main");
|
|
test.commit_in(&main_worktree, "Initial");
|
|
|
|
// Create feature worktree
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "feature"])
|
|
.current_dir(&main_worktree);
|
|
cmd.output().unwrap();
|
|
|
|
// Take snapshot of list output
|
|
let settings = setup_temp_snapshot_settings(test.temp_path());
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
cmd.arg("list").current_dir(&main_worktree);
|
|
assert_cmd_snapshot!(cmd);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_bootstrap_first_worktree() {
|
|
// Test that we can create the first worktree in a bare repo using wt switch --create
|
|
// without needing to manually run `git worktree add` first.
|
|
// This tests that load_project_config() returns None for bare repos without worktrees,
|
|
// allowing the bootstrap workflow to proceed.
|
|
let test = BareRepoTest::new();
|
|
|
|
// Unlike other tests, we do NOT create any worktrees first.
|
|
// We run wt switch --create directly on the bare repo.
|
|
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "main"])
|
|
.current_dir(test.bare_repo_path());
|
|
|
|
let output = cmd.output().unwrap();
|
|
|
|
if !output.status.success() {
|
|
panic!(
|
|
"wt switch --create main from bare repo with no worktrees failed:\nstdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
// Verify the worktree was created inside the bare repo
|
|
// Template: {{ branch }} -> repo/main
|
|
let expected_path = test.bare_repo_path().join("main");
|
|
assert!(
|
|
expected_path.exists(),
|
|
"Expected first worktree at {:?}",
|
|
expected_path
|
|
);
|
|
|
|
// Verify git worktree list shows the new worktree
|
|
let output = test
|
|
.git_command(test.bare_repo_path())
|
|
.args(["worktree", "list"])
|
|
.run()
|
|
.unwrap();
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
// Should show 2 entries: bare repo + main worktree
|
|
assert_eq!(
|
|
stdout.lines().count(),
|
|
2,
|
|
"Should have bare repo + 1 worktree"
|
|
);
|
|
assert!(stdout.contains("main"), "Should list main worktree");
|
|
}
|
|
|
|
/// Regression test: `wt list` from a `git clone --bare` repo must not run
|
|
/// `git status` on the bare entry. Before the fix, this produced:
|
|
/// "fatal: this operation must be run in a work tree"
|
|
///
|
|
/// Uses `git clone --bare` (real-world pattern) rather than `git init --bare`
|
|
/// (used by BareRepoTest) to cover the exact reported scenario.
|
|
#[test]
|
|
fn test_clone_bare_repo_list_no_status_errors() {
|
|
let temp_dir = tempfile::TempDir::new().unwrap();
|
|
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"))
|
|
.args(args.iter().copied())
|
|
.current_dir(dir)
|
|
.run()
|
|
.unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"git {} failed: {}",
|
|
args.join(" "),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
};
|
|
|
|
// Create a source repo with a commit (clone --bare needs history)
|
|
let source = temp_dir.path().join("source");
|
|
run_git(
|
|
temp_dir.path(),
|
|
&["init", "--initial-branch", "main", source.to_str().unwrap()],
|
|
);
|
|
fs::write(source.join("file.txt"), "content").unwrap();
|
|
run_git(&source, &["add", "file.txt"]);
|
|
run_git(&source, &["commit", "-m", "Initial commit"]);
|
|
|
|
// Clone as bare — the exact pattern from the bug report
|
|
let bare_path = temp_dir.path().join("project.bare");
|
|
run_git(
|
|
temp_dir.path(),
|
|
&[
|
|
"clone",
|
|
"--bare",
|
|
source.to_str().unwrap(),
|
|
bare_path.to_str().unwrap(),
|
|
],
|
|
);
|
|
|
|
// Create linked worktrees (outside the bare dir, matching real usage)
|
|
let main_wt = temp_dir.path().join("main");
|
|
let feature_wt = temp_dir.path().join("feature");
|
|
run_git(
|
|
&bare_path,
|
|
&["worktree", "add", main_wt.to_str().unwrap(), "main"],
|
|
);
|
|
run_git(&bare_path, &["branch", "feature", "main"]);
|
|
run_git(
|
|
&bare_path,
|
|
&["worktree", "add", feature_wt.to_str().unwrap(), "feature"],
|
|
);
|
|
|
|
// Run wt list from the bare repo directory (the reported scenario)
|
|
let mut cmd = wt_command();
|
|
configure_git_cmd(&mut cmd);
|
|
cmd.env("WORKTRUNK_CONFIG_PATH", &test_config_path)
|
|
.arg("list")
|
|
.current_dir(&bare_path);
|
|
let output = cmd.output().unwrap();
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
output.status.success(),
|
|
"wt list should succeed.\nstderr: {stderr}"
|
|
);
|
|
assert!(
|
|
!stderr.contains("must be run in a work tree"),
|
|
"Should not get 'must be run in a work tree' error.\nstderr: {stderr}"
|
|
);
|
|
assert!(
|
|
!stderr.contains("task failed") && !stderr.contains("tasks failed"),
|
|
"Should not have task failures.\nstderr: {stderr}"
|
|
);
|
|
}
|
|
|
|
/// Regression test for #1618: `wt merge` must not remove the default branch
|
|
/// worktree in a bare repo. In bare repos all worktrees are linked, so the
|
|
/// `is_linked()` check alone can't protect the primary worktree.
|
|
#[test]
|
|
fn test_bare_repo_merge_preserves_default_branch_worktree() {
|
|
let test = BareRepoTest::new();
|
|
|
|
// Create main (default branch) worktree and a feature worktree at the same commit
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit on main");
|
|
|
|
// Create feature branch at the same commit as main
|
|
let _feature_worktree = test.create_worktree("feature", "feature");
|
|
|
|
// Run `wt merge feature` from the main (default branch) worktree.
|
|
// This attempts to merge main into feature — the important thing is that
|
|
// the main worktree must NOT be removed even though is_linked() returns true.
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args([
|
|
"merge",
|
|
"feature", // Target = feature branch
|
|
"--no-squash", // Skip squash to avoid LLM dependency
|
|
"--no-hooks", // Skip hooks
|
|
])
|
|
.current_dir(&main_worktree);
|
|
|
|
let output = cmd.output().unwrap();
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
|
|
// The merge itself may succeed or show "already up to date", but the key
|
|
// assertion is that the main worktree is preserved (not removed).
|
|
assert!(
|
|
main_worktree.exists(),
|
|
"Default branch worktree must not be removed.\nstderr: {stderr}"
|
|
);
|
|
|
|
// Should show "primary worktree" preservation message
|
|
assert!(
|
|
stderr.contains("primary worktree"),
|
|
"Should show primary worktree preservation message.\nstderr: {stderr}"
|
|
);
|
|
}
|
|
|
|
/// Helper: create a NestedBareRepoTest with no worktree-path configured and a main worktree.
|
|
///
|
|
/// Reuses NestedBareRepoTest's bare repo setup but clears the worktree-path config,
|
|
/// so the default template (which references `{{ repo }}`) triggers the bare repo prompt.
|
|
fn setup_unconfigured_nested_bare_repo() -> NestedBareRepoTest {
|
|
let test = NestedBareRepoTest::new();
|
|
|
|
// Temporarily set worktree-path so the main worktree lands at project/main
|
|
// (without this, the default {{ repo }} template produces .git.main).
|
|
fs::write(
|
|
test.config_path(),
|
|
"worktree-path = \"../{{ branch | sanitize }}\"\n",
|
|
)
|
|
.unwrap();
|
|
|
|
// Create main worktree with a commit (needed as a starting point for switch)
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "main", "--yes"])
|
|
.current_dir(test.bare_repo_path());
|
|
let output = cmd.output().unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"Failed to create main worktree:\n{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
// Clear config so the default template applies — the test subject is the bare repo prompt.
|
|
// Skip shell integration prompt so it doesn't interfere (especially in PTY tests).
|
|
fs::write(test.config_path(), "skip-shell-integration-prompt = true\n").unwrap();
|
|
|
|
test
|
|
}
|
|
|
|
/// Test that --yes does NOT auto-accept the bare repo config change — it shows
|
|
/// the warning and creates the worktree at the unconfigured (bad) path.
|
|
#[test]
|
|
fn test_bare_repo_worktree_path_prompt_auto_accept() {
|
|
let test = setup_unconfigured_nested_bare_repo();
|
|
let main_worktree = test.project_path().join("main");
|
|
|
|
let settings = setup_temp_snapshot_settings(test.temp_path());
|
|
settings.bind(|| {
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "feature", "--yes"])
|
|
.current_dir(&main_worktree);
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
});
|
|
|
|
// Config should NOT have worktree-path — --yes skips the config prompt
|
|
let config_content = fs::read_to_string(test.config_path()).unwrap();
|
|
assert!(
|
|
!config_content.contains("worktree-path"),
|
|
"Config should NOT contain worktree-path — --yes should not auto-configure.\nConfig: {config_content}"
|
|
);
|
|
|
|
// Worktree created at the unconfigured path (bad but expected without config)
|
|
let bad_path = test.project_path().join(".git.feature");
|
|
assert!(
|
|
bad_path.exists(),
|
|
"Worktree should be at {:?} (unconfigured default path)",
|
|
bad_path
|
|
);
|
|
}
|
|
|
|
/// Test that non-interactive (piped stdin) shows warning instead of prompt.
|
|
#[test]
|
|
fn test_bare_repo_worktree_path_prompt_non_interactive_warning() {
|
|
let test = setup_unconfigured_nested_bare_repo();
|
|
let main_worktree = test.project_path().join("main");
|
|
|
|
let settings = setup_temp_snapshot_settings(test.temp_path());
|
|
settings.bind(|| {
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
// No --yes, but stdin is piped (non-interactive) since assert_cmd_snapshot
|
|
// doesn't attach a TTY
|
|
cmd.args(["switch", "--create", "feature"])
|
|
.current_dir(&main_worktree);
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
});
|
|
}
|
|
|
|
/// Symbolic identifiers (-, @, pr:N) are passed before branch resolution, so
|
|
/// the example paths in the prompt would show the raw symbol. The function
|
|
/// must return early without prompting.
|
|
#[test]
|
|
fn test_bare_repo_worktree_path_prompt_skipped_for_symbolic_identifier() {
|
|
let test = setup_unconfigured_nested_bare_repo();
|
|
let main_worktree = test.project_path().join("main");
|
|
|
|
// `@` means HEAD — resolves to `main`, which already exists. The prompt
|
|
// must not appear because `@` is a symbolic form.
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
cmd.args(["switch", "@"]).current_dir(&main_worktree);
|
|
let output = cmd.output().unwrap();
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
!stderr.contains("Configure worktree-path"),
|
|
"Prompt should not appear for symbolic identifier '@', got: {stderr}"
|
|
);
|
|
assert!(
|
|
!stderr.contains("Bare repo at"),
|
|
"Warning should not appear for symbolic identifier '@', got: {stderr}"
|
|
);
|
|
}
|
|
|
|
/// Once the opt-out is recorded (the `skip-bare-repo-prompt` hint is set), the
|
|
/// bare-repo warning/prompt is suppressed on later switches — the read path
|
|
/// early-returns via `has_shown_hint`.
|
|
#[test]
|
|
fn test_bare_repo_prompt_suppressed_when_opted_out() {
|
|
let test = setup_unconfigured_nested_bare_repo();
|
|
let main_worktree = test.project_path().join("main");
|
|
|
|
// Record the opt-out the way a decline does: `worktrunk.hints.<name> = <count>`.
|
|
test.run_git_in(
|
|
&main_worktree,
|
|
&["config", "worktrunk.hints.skip-bare-repo-prompt", "1"],
|
|
);
|
|
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
cmd.args(["switch", "--create", "feature"])
|
|
.current_dir(&main_worktree);
|
|
let stderr = String::from_utf8(cmd.output().unwrap().stderr).unwrap();
|
|
assert!(
|
|
!stderr.contains("Bare repo at"),
|
|
"Opted-out repo should not re-show the bare-repo warning.\nstderr: {stderr}"
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// PTY-based interactive prompt tests
|
|
// =============================================================================
|
|
|
|
#[cfg(all(unix, feature = "shell-integration-tests"))]
|
|
mod bare_repo_prompt_pty {
|
|
use super::*;
|
|
use crate::common::pty::{build_pty_command, exec_cmd_in_pty_prompted};
|
|
use crate::common::{add_pty_binary_path_filters, add_pty_filters, wt_bin};
|
|
use insta::assert_snapshot;
|
|
|
|
fn prompt_pty_settings(temp_path: &Path) -> insta::Settings {
|
|
let mut settings = setup_temp_snapshot_settings(temp_path);
|
|
add_pty_filters(&mut settings);
|
|
add_pty_binary_path_filters(&mut settings);
|
|
settings
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_worktree_path_prompt_accept_pty() {
|
|
let test = setup_unconfigured_nested_bare_repo();
|
|
let main_worktree = test.project_path().join("main");
|
|
let env_vars = test.test_env_vars();
|
|
|
|
let cmd = build_pty_command(
|
|
wt_bin().to_str().unwrap(),
|
|
&["switch", "--create", "feature"],
|
|
&main_worktree,
|
|
&env_vars,
|
|
None,
|
|
);
|
|
let (output, exit_code) = exec_cmd_in_pty_prompted(cmd, &["y\n"], "[y/N");
|
|
|
|
assert_eq!(exit_code, 0);
|
|
prompt_pty_settings(test.temp_path()).bind(|| {
|
|
assert_snapshot!("bare_repo_prompt_accept", &output);
|
|
});
|
|
|
|
// Verify config was written
|
|
let config_content = fs::read_to_string(test.config_path()).unwrap();
|
|
assert!(
|
|
config_content.contains("worktree-path"),
|
|
"Config should contain worktree-path override.\nConfig: {config_content}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_worktree_path_prompt_decline_pty() {
|
|
let test = setup_unconfigured_nested_bare_repo();
|
|
let main_worktree = test.project_path().join("main");
|
|
let env_vars = test.test_env_vars();
|
|
|
|
let cmd = build_pty_command(
|
|
wt_bin().to_str().unwrap(),
|
|
&["switch", "--create", "feature"],
|
|
&main_worktree,
|
|
&env_vars,
|
|
None,
|
|
);
|
|
let (output, exit_code) = exec_cmd_in_pty_prompted(cmd, &["n\n"], "[y/N");
|
|
|
|
assert_eq!(exit_code, 0);
|
|
prompt_pty_settings(test.temp_path()).bind(|| {
|
|
assert_snapshot!("bare_repo_prompt_decline", &output);
|
|
});
|
|
|
|
// 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"))
|
|
.args(["config", "worktrunk.hints.skip-bare-repo-prompt"])
|
|
.current_dir(&main_worktree)
|
|
.run()
|
|
.unwrap();
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&hint_value.stdout).trim(),
|
|
"1",
|
|
"Declining should record the opt-out as a hint"
|
|
);
|
|
|
|
// The legacy top-level key must not be written anymore.
|
|
let legacy_key = configure_git_env(Cmd::new("git"))
|
|
.args(["config", "worktrunk.skip-bare-repo-prompt"])
|
|
.current_dir(&main_worktree)
|
|
.run()
|
|
.unwrap();
|
|
assert!(
|
|
!legacy_key.status.success(),
|
|
"Legacy top-level skip key should no longer be written"
|
|
);
|
|
|
|
// Round-trip through `wt config state`: the opt-out is listed by the
|
|
// cache view and removed by the aggregate `state clear`.
|
|
let mut hints_cmd = wt_command();
|
|
test.configure_wt_cmd(&mut hints_cmd);
|
|
hints_cmd
|
|
.args(["config", "state", "cache"])
|
|
.current_dir(&main_worktree);
|
|
let hints_listed = String::from_utf8(hints_cmd.output().unwrap().stdout).unwrap();
|
|
assert!(
|
|
hints_listed.contains("skip-bare-repo-prompt"),
|
|
"state cache should list the opt-out hint.\nstdout: {hints_listed}"
|
|
);
|
|
|
|
// `state clear` removes everything but prompts first — `--yes` skips it.
|
|
let mut clear_cmd = wt_command();
|
|
test.configure_wt_cmd(&mut clear_cmd);
|
|
clear_cmd
|
|
.args(["config", "state", "clear", "--yes"])
|
|
.current_dir(&main_worktree);
|
|
assert!(
|
|
clear_cmd.output().unwrap().status.success(),
|
|
"state clear should succeed"
|
|
);
|
|
|
|
let mut hints_after = wt_command();
|
|
test.configure_wt_cmd(&mut hints_after);
|
|
hints_after
|
|
.args(["config", "state", "hints"])
|
|
.current_dir(&main_worktree);
|
|
let hints_remaining = String::from_utf8(hints_after.output().unwrap().stdout).unwrap();
|
|
assert!(
|
|
!hints_remaining.contains("skip-bare-repo-prompt"),
|
|
"state clear should remove the opt-out hint.\nstdout: {hints_remaining}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_repo_worktree_path_prompt_preview_pty() {
|
|
let test = setup_unconfigured_nested_bare_repo();
|
|
let main_worktree = test.project_path().join("main");
|
|
let env_vars = test.test_env_vars();
|
|
|
|
let cmd = build_pty_command(
|
|
wt_bin().to_str().unwrap(),
|
|
&["switch", "--create", "feature"],
|
|
&main_worktree,
|
|
&env_vars,
|
|
None,
|
|
);
|
|
// Send ? first to see preview, then n to decline
|
|
let (output, exit_code) = exec_cmd_in_pty_prompted(cmd, &["?\n", "n\n"], "[y/N");
|
|
|
|
assert_eq!(exit_code, 0);
|
|
prompt_pty_settings(test.temp_path()).bind(|| {
|
|
assert_snapshot!("bare_repo_prompt_preview", &output);
|
|
});
|
|
}
|
|
}
|