Files
max-sixty__worktrunk/tests/integration_tests/config_state.rs
Maximilian Roos bfbc2e2899 fix(picker): abbreviate a PR's commits with git, not a fixed 8 (#3677)
Follow-up to #3676, which made `wt list`'s table render git's `%h`
instead of `&head[..8]`. The `--prs` `log` tab was the last user-visible
fixed-8 abbreviation.

## The defect

`wt switch --prs` renders a row's `log` tab from the local `git log`
when the PR head is in the object store, and from `gh pr view <n> --json
commits` / `glab api …/commits` when it isn't. The forge path sliced
each SHA to 8 characters, so on a repo where git abbreviates to 9 the
same commit read `abc1234` at 8 before a fetch and 9 after, and
`core.abbrev` never reached the pane at all.

## The fix

`Repository::abbrev_len` asks git how wide it abbreviates in this repo
and caches the answer on `RepoCache`, so a picker with 30 PR rows probes
once — clones share the `Arc<RepoCache>`, and
`once_cell::sync::OnceCell` collapses the concurrent background rows
onto one fork.

The pane wants the width rather than `short_sha` because of the branch
it sits on: it runs precisely when the head *isn't* local, and `git
rev-parse --short` takes a single revision — there is no batched form
for a list of absent commits, and looping it would be one subprocess per
commit per row. With no objects to disambiguate against, the width is
git's whole answer for these SHAs.

Both forge arms now parse the full SHA and share one abbreviation step
in `render_commit_lines`. GitLab's ready-made `short_id` goes unused:
it's the server's abbreviation, blind to the reader's `core.abbrev`, and
taking it printed an MR's commits at a different width from a PR's.

## The audit

#8 claimed the `--prs` tab was the last one; that came from a grep.
Sweeping `.take(N)`, `[..N]`, `{:.N}`, `truncate(N)`, `get(..N)`, and
`--abbrev`/`--short=` across `src/` turned up one more user-visible
case: **`wt config state`'s CI cache table**, which sliced `cached.head`
to 8 and now truncates to `abbrev_len`.

One fixed-width slice over a SHA survives deliberately:
`collect/tasks.rs` labels a `tracing::debug!` line with `&sha[..8]` as a
fallback when a timed-out task has no branch name. It's a trace field
rather than rendered output, and the timeout error path is the wrong
place to spawn git.

Everything else the sweep found hashes something other than a git object
— `config::short_hash`'s base36 branch suffix, `summary.rs`'s content
digest — or is a column width rather than a truncation.

## Cost

Both call sites are flat in the number of SHAs: one `git rev-parse
--short HEAD` per repo per process.

The first cut of the CI table called `short_sha` per row instead, to
keep git's extension of an ambiguous prefix. That's a fork apiece, and
it cost more than the whole command was worth — measured on a repo with
a cache entry per branch, `wt config state get` at 50 entries:

`wt config state get`, 50 cache entries, release build, hyperfine
(warmup 20, 80+ runs, quiet machine):

| | mean ± σ | vs. baseline |
|---|---:|---:|
| before this PR (`.take(8)`) | 22.1 ± 0.8 ms | — |
| `abbrev_len` (this PR) | 26.1 ± 1.3 ms | +4.0 ms, **1.18× ± 0.07** |
| `short_sha` per row | 266.7 ± 3.9 ms | **12×** |

The +18% is one `git rev-parse --short HEAD` against a command whose
entire
runtime is ~22 ms. For scale, a trace of the same command shows `gh
--version`
at 15.3 ms and `git config --list -z` running three times for ~12 ms,
both of
which predate this PR. The probe sits inside the non-empty branch, so a
repo
with no CI cache pays nothing.

No Criterion target covers `wt config state` (`time_to_first_output`
covers
`remove`/`switch`/`list`; `picker_preview` covers worktree rows, not
`--prs`,
which needs a forge CLI), hence the standalone measurement. Worth noting
against
the daily suite's own resolution: run-to-run spread on an unchanged
bench
(`worktree_scaling/warm/8`, last 10 nightlies) is 18%, so a benchmark
would not
have caught the +18% either — though it would have caught the 12×.

So the form here pays one probe and gives up disambiguation. Absent
objects have nothing to collide with, and a live collision at 7–9
characters is rare enough for a display column to wear.

On the picker, `abbrev_len` lands on a background pool thread behind a
`gh`/`glab` call that costs orders of magnitude more, and never on a
first-paint path.

#3676 itself cost nothing at runtime, for the record: `%h` was already
in the pre-skeleton `git log --no-walk` batch (`%ct` from the same fork
drives row order), and the format string is byte-identical across that
commit. It moved where the already-fetched string is copied onto the row
and swapped a `COMMIT_HASH_WIDTH = 8` constant for an O(N) width pass
over the rendered SHAs. No fork added, no phase reordered — the
pre-skeleton budget table in `collect/mod.rs` reads 23 commands on both
sides.

## Tests

- `abbrev_len_follows_core_abbrev_and_covers_absent_objects` — the width
matches `%h`, `core.abbrev = 12` reaches it, and it equals what git
gives for a SHA with no object behind it.
- `render_commits_abbreviates_to_the_given_width` — the pane prints the
width it's handed, at 7 and at 12.
- `parse_gitlab_commits_keeps_order_and_takes_the_full_id` — `id` over
`short_id`.
- `test_state_get_ci_head_uses_git_abbreviation` — the CI table's Head
cell equals `git rev-parse --short`. It can't be a snapshot: the width
scales with the repo's object count and a test repo's SHA isn't fixed.

`cargo run -- hook pre-merge --yes`: 4494 passed.

> _This was written by Claude Code on behalf of max-sixty_
2026-08-01 01:21:41 -07:00

3266 lines
111 KiB
Rust
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::common::{TEST_EPOCH, TestRepo, repo, repo_with_remote, wt_command};
use insta::assert_snapshot;
use rstest::rstest;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use worktrunk::path::sanitize_for_filename;
/// Relative path of a hook log file under the wt logs directory.
///
/// Layout: `{branch}/{source}/{hook_type}/{name}.log` — branch and name are
/// sanitized to match `commands::process::HookLog::path`.
fn hook_log_rel_path(branch: &str, source: &str, hook_type: &str, name: &str) -> PathBuf {
let safe_branch = sanitize_for_filename(branch);
let safe_name = sanitize_for_filename(name);
PathBuf::from(safe_branch)
.join(source)
.join(hook_type)
.join(format!("{safe_name}.log"))
}
/// Relative path of an internal-operation log file under the wt logs directory.
///
/// Layout: `{branch}/internal/{op}.log` — branch is sanitized.
fn internal_log_rel_path(branch: &str, op: &str) -> PathBuf {
let safe_branch = sanitize_for_filename(branch);
PathBuf::from(safe_branch)
.join("internal")
.join(format!("{op}.log"))
}
/// Relative path of a repo-wide internal-operation log under the wt logs directory.
fn shared_internal_log_rel_path(op: &str) -> PathBuf {
PathBuf::from(format!("internal-{op}.log"))
}
/// Write a log file at `log_dir / relative`, creating parent directories.
fn write_log_at(log_dir: &Path, relative: &Path, contents: &str) {
let full = log_dir.join(relative);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(&full, contents).unwrap();
}
/// Display string for a relative log path, forward-slashed for stable snapshots.
fn rel_display(p: &Path) -> String {
use path_slash::PathExt as _;
p.to_slash_lossy().into_owned()
}
/// Settings for `wt config state get` snapshots (normalizes log paths)
fn state_get_settings() -> insta::Settings {
let mut settings = insta::Settings::clone_current();
// COMMAND LOG / HOOK OUTPUT / DIAGNOSTIC paths vary per test (temp dir), normalize for stable snapshots
settings.add_filter(r"(COMMAND LOG\x1b\[39m\s+@ )[^\n]+", "${1}<PATH>");
settings.add_filter(r"(HOOK OUTPUT\x1b\[39m\s+@ )[^\n]+", "${1}<PATH>");
settings.add_filter(r"(DIAGNOSTIC\x1b\[39m\s+@ )[^\n]+", "${1}<PATH>");
settings
}
/// Path to the file-based CI cache entry at `.git/wt/cache/ci-status/<branch>.json`.
fn ci_cache_file(repo: &TestRepo, branch: &str) -> PathBuf {
let safe_branch = sanitize_for_filename(branch);
repo.root_path()
.join(".git/wt/cache/ci-status")
.join(format!("{safe_branch}.json"))
}
/// Write CI status to the file-based cache.
fn write_ci_cache(repo: &TestRepo, branch: &str, json: &str) {
let cache_file = ci_cache_file(repo, branch);
std::fs::create_dir_all(cache_file.parent().unwrap()).unwrap();
std::fs::write(&cache_file, json).unwrap();
}
/// Write a summary cache entry at `.git/wt/cache/summary/{branch}/{hash}.json`.
///
/// The hash is opaque to callers — they just need a consistent key per entry —
/// so we accept an arbitrary hex-like string rather than recomputing SHA-256.
fn write_summary_cache(
repo: &TestRepo,
branch: &str,
hash: &str,
summary: &str,
generated_at: u64,
) {
let safe_branch = worktrunk::path::sanitize_for_filename(branch);
let dir = repo
.root_path()
.join(".git/wt/cache/summary")
.join(&safe_branch);
std::fs::create_dir_all(&dir).unwrap();
let body =
format!(r#"{{"summary":"{summary}","branch":"{branch}","generated_at":{generated_at}}}"#);
std::fs::write(dir.join(format!("{hash}.json")), body).unwrap();
}
/// Create a command for `wt config state <key> <action> [args...]`
fn wt_state_cmd(repo: &TestRepo, key: &str, action: &str, args: &[&str]) -> Command {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args(["config", "state", key, action]);
cmd.args(args);
cmd.current_dir(repo.root_path());
cmd
}
fn wt_state_get_cmd(repo: &TestRepo) -> Command {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args(["config", "state", "get"]);
cmd.current_dir(repo.root_path());
cmd
}
fn wt_state_get_json_cmd(repo: &TestRepo) -> Command {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args(["config", "state", "get", "--format=json"]);
cmd.current_dir(repo.root_path());
cmd
}
// ============================================================================
// default-branch
// ============================================================================
#[rstest]
fn test_state_get_default_branch(repo: TestRepo) {
let output = wt_state_cmd(&repo, "default-branch", "get", &[])
.output()
.unwrap();
assert!(output.status.success());
// data() writes to stdout for piping
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "main");
}
#[rstest]
fn test_state_get_default_branch_no_remote(repo: TestRepo) {
// Without remote, should infer from local branches
let output = wt_state_cmd(&repo, "default-branch", "get", &[])
.output()
.unwrap();
assert!(output.status.success());
// Should return the current branch name (main)
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "main");
}
#[rstest]
fn test_state_get_default_branch_fails_when_undetermined(repo: TestRepo) {
// Remove origin (fixture has it) - otherwise remote can determine default branch
repo.run_git(&["remote", "remove", "origin"]);
// Rename main to something non-standard so default branch can't be determined
repo.git_command()
.args(["branch", "-m", "main", "xyz"])
.run()
.unwrap();
repo.git_command().args(["branch", "abc"]).run().unwrap();
repo.git_command().args(["branch", "def"]).run().unwrap();
// Now we have: xyz, abc, def - no common names, no init.defaultBranch
// wt config state default-branch get should fail with an error
let output = wt_state_cmd(&repo, "default-branch", "get", &[])
.output()
.unwrap();
assert!(!output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"✗ Cannot determine default branch. To configure, run wt config state default-branch set BRANCH");
}
#[rstest]
fn test_state_set_default_branch(repo: TestRepo) {
let output = wt_state_cmd(&repo, "default-branch", "set", &["develop"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
▲ Branch develop does not exist locally
✓ Set default branch to develop
");
// Verify it was set in worktrunk's cache
let output = repo
.git_command()
.args(["config", "--get", "worktrunk.default-branch"])
.run()
.unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "develop");
}
#[rstest]
fn test_state_clear_default_branch(mut repo: TestRepo) {
// Set up remote and populate worktrunk's cache
repo.setup_remote("main");
// Trigger cache population by reading default branch
let _ = wt_state_cmd(&repo, "default-branch", "get", &[])
.output()
.unwrap();
// Now clear the cache
let output = wt_state_cmd(&repo, "default-branch", "clear", &[])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"✓ Cleared default branch cache");
// Verify worktrunk's cache was cleared
let output = repo
.git_command()
.args(["config", "--get", "worktrunk.default-branch"])
.run()
.unwrap();
assert!(!output.status.success());
}
#[rstest]
fn test_state_clear_default_branch_empty(repo: TestRepo) {
// Fixture already has origin remote, no default branch cache set
let output = wt_state_cmd(&repo, "default-branch", "clear", &[])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"○ No default branch cache to clear");
}
#[rstest]
fn test_state_show_default_branch_drift_warns(mut repo: TestRepo) {
// origin/HEAD points at main (the fixture default)...
repo.setup_remote("main");
// ...but the persisted cache still names a branch from before a rename.
// This is the shape after a GitHub default-branch rename + `git remote
// set-head origin -a`: the fast-path cache in default_branch() never
// re-validates against origin/HEAD, so `wt config state` calls it out.
repo.git_command()
.args(["config", "worktrunk.default-branch", "old-default"])
.run()
.unwrap();
let output = wt_state_get_cmd(&repo).output().unwrap();
assert!(output.status.success());
state_get_settings().bind(|| {
assert_snapshot!(String::from_utf8_lossy(&output.stdout));
});
}
#[rstest]
fn test_state_show_default_branch_no_drift_when_matching(mut repo: TestRepo) {
// Cache matches origin/HEAD — no warning, just the value.
repo.setup_remote("main");
repo.git_command()
.args(["config", "worktrunk.default-branch", "main"])
.run()
.unwrap();
let output = wt_state_get_cmd(&repo).output().unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("DEFAULT BRANCH"));
// No drift → no divergence warning.
assert!(
!stdout.contains("differs from"),
"expected no drift warning when cache matches origin/HEAD:\n{stdout}"
);
}
#[rstest]
fn test_state_get_json_reports_remote_head_branch(mut repo: TestRepo) {
repo.setup_remote("main");
repo.git_command()
.args(["config", "worktrunk.default-branch", "old-default"])
.run()
.unwrap();
let output = wt_state_get_json_cmd(&repo).output().unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).expect("valid json");
assert_eq!(json["default_branch"], "old-default");
assert_eq!(json["remote_head_branch"], "main");
}
// ============================================================================
// previous-branch
// ============================================================================
#[rstest]
fn test_state_get_previous_branch(repo: TestRepo) {
// Without any previous branch set, should return empty
let output = wt_state_cmd(&repo, "previous-branch", "get", &[])
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "");
}
#[rstest]
fn test_state_set_previous_branch(repo: TestRepo) {
let output = wt_state_cmd(&repo, "previous-branch", "set", &["feature"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
▲ wt config state previous-branch is deprecated; use wt config state cache instead
✓ Set previous branch to feature
");
// Verify it was set
let output = wt_state_cmd(&repo, "previous-branch", "get", &[])
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "feature");
}
#[rstest]
fn test_state_clear_previous_branch(repo: TestRepo) {
// Set a previous branch first
wt_state_cmd(&repo, "previous-branch", "set", &["feature"])
.output()
.unwrap();
let output = wt_state_cmd(&repo, "previous-branch", "clear", &[])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
▲ wt config state previous-branch is deprecated; use wt config state cache instead
✓ Cleared previous branch
");
// Verify it was cleared
let output = wt_state_cmd(&repo, "previous-branch", "get", &[])
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "");
}
#[rstest]
fn test_state_clear_previous_branch_empty(repo: TestRepo) {
// Clear without any previous branch set
let output = wt_state_cmd(&repo, "previous-branch", "clear", &[])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
▲ wt config state previous-branch is deprecated; use wt config state cache instead
○ No previous branch to clear
");
}
// ============================================================================
// bare subcommand defaults (no action → implicit get)
// ============================================================================
/// `wt config state ci-status` (no subcommand) defaults to `get`.
#[rstest]
fn test_state_bare_ci_status(repo: TestRepo) {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args(["config", "state", "ci-status"]);
cmd.current_dir(repo.root_path());
let output = cmd.output().unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "no-ci");
}
/// `wt config state marker` (no subcommand) defaults to `get`.
#[rstest]
fn test_state_bare_marker(repo: TestRepo) {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args(["config", "state", "marker"]);
cmd.current_dir(repo.root_path());
let output = cmd.output().unwrap();
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty());
}
/// `wt config state logs` (no subcommand) defaults to `get`.
#[rstest]
fn test_state_bare_logs(repo: TestRepo) {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args(["config", "state", "logs"]);
cmd.current_dir(repo.root_path());
let output = cmd.output().unwrap();
assert!(output.status.success());
}
/// A synthetic `-vv` trace: two parallel `git status` runs, an in-process span,
/// a failed `git diff`, and a `git config` run twice in the same context (the
/// cache-miss the profile flags), bracketed by milestone events.
const PROFILE_FIXTURE_TRACE: &str = r#"{"kind":"instant","ts":1000,"tid":1,"event":"List collect started"}
{"kind":"cmd_completed","ts":1000,"tid":1,"context":"main","cmd":"git status --porcelain","dur_us":12000,"ok":true}
{"kind":"cmd_completed","ts":1000,"tid":2,"context":"feature","cmd":"git status --porcelain","dur_us":8000,"ok":true}
{"kind":"span","ts":500,"tid":1,"span":"user_config_load","dur_us":2000}
{"kind":"instant","ts":13000,"tid":1,"event":"Skeleton rendered"}
{"kind":"cmd_completed","ts":13000,"tid":1,"context":"main","cmd":"git diff --shortstat","dur_us":5000,"ok":false}
{"kind":"cmd_completed","ts":1000,"tid":1,"context":"main","cmd":"git config --list","dur_us":4000,"ok":true}
{"kind":"cmd_completed","ts":6000,"tid":1,"context":"main","cmd":"git config --list","dur_us":3000,"ok":true}
{"kind":"instant","ts":18000,"tid":1,"event":"All results drained"}
"#;
/// Run `wt config state logs profile <args>` with `input` on stdin.
fn profile_from_stdin(repo: &TestRepo, args: &[&str], input: &str) -> std::process::Output {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args(["config", "state", "logs", "profile"]);
cmd.args(args);
cmd.current_dir(repo.root_path());
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn().unwrap();
child
.stdin
.take()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
child.wait_with_output().unwrap()
}
/// `wt config state logs profile -` reads a trace from stdin and reports where
/// time went, how parallel the run was, and any same-context cache misses.
#[rstest]
fn test_logs_profile_from_stdin(repo: TestRepo) {
let output = profile_from_stdin(&repo, &["-"], PROFILE_FIXTURE_TRACE);
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout));
}
/// `--format=json` emits the same analysis as machine-readable data.
#[rstest]
fn test_logs_profile_json(repo: TestRepo) {
let output = profile_from_stdin(&repo, &["-", "--format=json"], PROFILE_FIXTURE_TRACE);
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @r#"
{
"traced_us": 17500,
"command_count": 5,
"command_total_us": 32000,
"span_total_us": 2000,
"parallelism": 1.8823529411764706,
"peak_concurrency": 3,
"thread_count": 2,
"key_intervals": {
"time_to_skeleton_us": 12000,
"time_to_first_result_us": null,
"parallel_phase_us": null,
"collect_total_us": null
},
"by_type": [
{
"command": "git status",
"count": 2,
"total_us": 20000,
"max_us": 12000,
"avg_us": 10000
},
{
"command": "git config",
"count": 2,
"total_us": 7000,
"max_us": 4000,
"avg_us": 3500
},
{
"command": "git diff",
"count": 1,
"total_us": 5000,
"max_us": 5000,
"avg_us": 5000
}
],
"by_context": [
{
"context": "main",
"count": 4,
"total_us": 24000
},
{
"context": "feature",
"count": 1,
"total_us": 8000
}
],
"slowest": [
{
"dur_us": 12000,
"label": "git status --porcelain [main]"
},
{
"dur_us": 8000,
"label": "git status --porcelain [feature]"
},
{
"dur_us": 5000,
"label": "git diff --shortstat [main] (ok=false)"
},
{
"dur_us": 4000,
"label": "git config --list [main]"
},
{
"dur_us": 3000,
"label": "git config --list [main]"
},
{
"dur_us": 2000,
"label": "span:user_config_load"
}
],
"cache": {
"same_context_duplicates": [
{
"command": "git config --list",
"max_per_context": 2,
"extra_calls": 1,
"extra_us": 3000,
"contexts": [
{
"context": "main",
"count": 2,
"total_us": 7000
}
]
}
],
"same_context_extra_calls": 1,
"same_context_extra_us": 3000
},
"phases": [
{
"name": "List collect started",
"at_us": 500,
"delta_us": null
},
{
"name": "Skeleton rendered",
"at_us": 12500,
"delta_us": 12000
},
{
"name": "All results drained",
"at_us": 17500,
"delta_us": 5000
}
]
}
"#);
}
/// A trace log given as a path argument is read directly.
#[rstest]
fn test_logs_profile_from_file(repo: TestRepo) {
let trace = repo.root_path().join("captured.log");
std::fs::write(&trace, PROFILE_FIXTURE_TRACE).unwrap();
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args(["config", "state", "logs", "profile"]);
cmd.arg(&trace);
cmd.current_dir(repo.root_path());
let output = cmd.output().unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("PERFORMANCE PROFILE"), "stdout: {stdout}");
}
/// A relative trace path resolves against `-C`, the way git resolves the path
/// arguments in its own command line.
///
/// Run from outside the repo, so the process cwd and the `-C` directory
/// disagree: the trace is only reachable from the latter.
#[rstest]
fn test_logs_profile_from_file_honors_directory_flag(repo: TestRepo) {
std::fs::write(repo.root_path().join("captured.log"), PROFILE_FIXTURE_TRACE).unwrap();
let outside = repo.root_path().parent().unwrap().to_path_buf();
let root = repo.root_path().to_string_lossy().to_string();
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args([
"-C",
&root,
"config",
"state",
"logs",
"profile",
"captured.log",
]);
cmd.current_dir(&outside);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"profile should read the trace named relative to -C: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("PERFORMANCE PROFILE"), "stdout: {stdout}");
}
/// A missing path argument fails, naming the file it could not read.
#[rstest]
fn test_logs_profile_file_missing(repo: TestRepo) {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args(["config", "state", "logs", "profile", "no-such-trace.log"]);
cmd.current_dir(repo.root_path());
let output = cmd.output().unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Failed to read trace"), "stderr: {stderr}");
}
/// With no argument, the profile reads the repo's `.git/wt/logs/trace.jsonl`.
#[rstest]
fn test_logs_profile_default_path(repo: TestRepo) {
let logs_dir = repo.root_path().join(".git/wt/logs");
std::fs::create_dir_all(&logs_dir).unwrap();
std::fs::write(logs_dir.join("trace.jsonl"), PROFILE_FIXTURE_TRACE).unwrap();
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args(["config", "state", "logs", "profile"]);
cmd.current_dir(repo.root_path());
let output = cmd.output().unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("PERFORMANCE PROFILE"), "stdout: {stdout}");
assert!(stdout.contains("BY COMMAND TYPE"), "stdout: {stdout}");
}
/// An input with no trace records fails, pointing at `-vv`.
#[rstest]
fn test_logs_profile_no_records(repo: TestRepo) {
let output = profile_from_stdin(&repo, &["-"], "not a trace line\nanother line\n");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("No trace records"), "stderr: {stderr}");
assert!(stderr.contains("-vv"), "stderr: {stderr}");
}
/// End-to-end: a real `wt -vv list` capture must yield the always-fire key
/// intervals. Guards against a milestone-string rename at the emit sites in
/// `src/commands/list/collect/mod.rs` drifting from the `M_*` constants in
/// `src/trace/profile.rs` — only this test exercises the real emit→profile path.
#[rstest]
fn test_logs_profile_real_capture_has_key_intervals(repo: TestRepo) {
// `-vv` writes the trace log. stdout is a pipe here, so the TTY-gated
// skeleton/first-result milestones don't fire, but the four always-fire
// collect milestones do.
let mut list = wt_command();
repo.configure_wt_cmd(&mut list);
list.args(["-vv", "list"]);
list.current_dir(repo.root_path());
assert!(list.output().unwrap().status.success());
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args(["config", "state", "logs", "profile", "--format=json"]);
cmd.current_dir(repo.root_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_slice(&output.stdout).unwrap();
let intervals = &json["key_intervals"];
assert!(
intervals["collect_total_us"].is_u64(),
"collect_total missing — a collect milestone string likely drifted: {intervals}"
);
assert!(
intervals["parallel_phase_us"].is_u64(),
"parallel_phase missing — a collect milestone string likely drifted: {intervals}"
);
}
/// Without a trace, the error names the missing file and points at `-vv`.
#[rstest]
fn test_logs_profile_missing(repo: TestRepo) {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args(["config", "state", "logs", "profile"]);
cmd.current_dir(repo.root_path());
let output = cmd.output().unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("No trace at"), "stderr: {stderr}");
assert!(stderr.contains("-vv"), "stderr: {stderr}");
}
/// With no FILE argument and no surrounding git repository, the error explains
/// the absent default trace and points at the path / `-` (stdin) alternatives,
/// rather than surfacing a bare git "not a repository" error.
/// `wt_command()`'s default cwd is a process-scoped tempdir outside any repo.
#[rstest]
fn test_logs_profile_no_repo() {
let output = wt_command()
.args(["config", "state", "logs", "profile"])
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Not inside a git repository"),
"stderr: {stderr}"
);
// The `-` is bold-wrapped (`\x1b[1m-\x1b[22m`), so assert on the surrounding
// unstyled text rather than a substring that crosses the ANSI boundary.
assert!(stderr.contains("pass a trace path"), "stderr: {stderr}");
assert!(stderr.contains("for stdin"), "stderr: {stderr}");
}
/// `wt config state hints` (no subcommand) defaults to `get`.
#[rstest]
fn test_state_bare_hints(repo: TestRepo) {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args(["config", "state", "hints"]);
cmd.current_dir(repo.root_path());
let output = cmd.output().unwrap();
assert!(output.status.success());
}
// ============================================================================
// ci-status
// ============================================================================
#[rstest]
fn test_state_get_ci_status(repo: TestRepo) {
// Without any CI configured, should return "no-ci"
let output = wt_state_cmd(&repo, "ci-status", "get", &[])
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "no-ci");
}
#[rstest]
fn test_state_get_ci_status_specific_branch(repo: TestRepo) {
repo.git_command()
.args(["branch", "feature"])
.run()
.unwrap();
// Without any CI configured, should return "no-ci"
let output = wt_state_cmd(&repo, "ci-status", "get", &["--branch", "feature"])
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "no-ci");
}
#[rstest]
fn test_state_get_ci_status_nonexistent_branch(repo: TestRepo) {
// Should error for nonexistent branch
let output = wt_state_cmd(&repo, "ci-status", "get", &["--branch", "nonexistent"])
.output()
.unwrap();
assert!(!output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
▲ wt config state ci-status is deprecated; use wt config state cache instead
✗ No branch named nonexistent
↳ To create a new branch, run wt switch --create nonexistent; to list branches, run wt list --branches --remotes
");
}
/// Resolve a branch that exists only as a remote-tracking ref (no local
/// counterpart). Exercises the `remote_branch` arm of the BranchRef match.
#[rstest]
fn test_state_get_ci_status_remote_only_branch(#[from(repo_with_remote)] repo: TestRepo) {
repo.create_branch("foo");
repo.run_git(&["checkout", "foo"]);
std::fs::write(repo.root_path().join("f.txt"), "f").unwrap();
repo.run_git(&["add", "."]);
repo.run_git(&["commit", "-m", "foo commit"]);
repo.push_branch("foo");
repo.run_git(&["checkout", "main"]);
repo.run_git(&["branch", "-D", "foo"]);
let output = wt_state_cmd(&repo, "ci-status", "get", &["--branch", "origin/foo"])
.output()
.unwrap();
assert!(
output.status.success(),
"command should succeed: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "no-ci");
}
/// A fresh cached CI status returns from `get` without re-fetching. Exercises
/// the cache-hit path where `PrStatus::detect` returns `Some` and the match
/// arm unwraps `ci_status`.
#[rstest]
fn test_state_get_ci_status_returns_cached_status(repo: TestRepo) {
let head = repo.head_sha();
write_ci_cache(
&repo,
"main",
&format!(
r#"{{"status":{{"ci_status":"passed","source":"pr","is_stale":false}},"checked_at":{TEST_EPOCH},"head":"{head}","branch":"main"}}"#
),
);
let output = wt_state_cmd(&repo, "ci-status", "get", &["--branch", "main"])
.output()
.unwrap();
assert!(
output.status.success(),
"command should succeed: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "passed");
}
/// `--format=json` honors the configured `[forge].platform` when deriving the
/// repo provider from the cached PR URL, matching `wt list --format=json`.
/// Without the override an opaque-host `pull` URL resolves to `unknown`; with
/// `[forge] platform = "github"` it resolves to `github`. Regression guard for
/// the `state ci-status` JSON path that previously hard-coded no override.
#[rstest]
fn test_state_get_ci_status_json_honors_forge_platform(repo: TestRepo) {
let head = repo.head_sha();
write_ci_cache(
&repo,
"main",
&format!(
r#"{{"status":{{"ci_status":"passed","source":"pr","is_stale":false,"url":"https://git.example.com/org/repo/pull/7"}},"checked_at":{TEST_EPOCH},"head":"{head}","branch":"main"}}"#
),
);
repo.write_project_config("[forge]\nplatform = \"github\"\n");
let output = wt_state_cmd(
&repo,
"ci-status",
"get",
&["--branch", "main", "--format=json"],
)
.output()
.unwrap();
assert!(
output.status.success(),
"command should succeed: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["repo"]["provider"].as_str(), Some("github"));
assert_eq!(json["repo"]["host"].as_str(), Some("git.example.com"));
}
/// `wt config state ci-status --branch origin/foo` must resolve cleanly when a
/// local branch literally named `origin/foo` shadows a remote-tracking ref of
/// the same name. Smoke test: exercises the shadowing code path. The
/// visible-to-user consequences of the underlying bug (is_remote flag out of
/// sync with the HEAD SHA, affecting how `gh`/`glab` get invoked) aren't
/// observable without mocking those tools, but this guards against
/// regressions that would make the command error on ambiguity (e.g., naive
/// use of `rev-parse --symbolic-full-name`, which fails on shadowed refs).
#[rstest]
fn test_state_get_ci_status_shadow_origin_prefixed(#[from(repo_with_remote)] repo: TestRepo) {
// Remote `foo` pushed to origin.
repo.create_branch("foo");
repo.run_git(&["checkout", "foo"]);
std::fs::write(repo.root_path().join("remote.txt"), "remote").unwrap();
repo.run_git(&["add", "."]);
repo.run_git(&["commit", "-m", "Remote foo commit"]);
repo.push_branch("foo");
// Drop local `foo` so only `refs/remotes/origin/foo` remains.
repo.run_git(&["checkout", "main"]);
repo.run_git(&["branch", "-D", "foo"]);
// Local branch literally named `origin/foo` with different history.
repo.run_git(&["checkout", "-b", "origin/foo"]);
std::fs::write(repo.root_path().join("local.txt"), "local").unwrap();
repo.run_git(&["add", "."]);
repo.run_git(&["commit", "-m", "Local origin/foo"]);
repo.run_git(&["checkout", "main"]);
let output = wt_state_cmd(&repo, "ci-status", "get", &["--branch", "origin/foo"])
.output()
.unwrap();
assert!(
output.status.success(),
"command should succeed: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "no-ci");
}
#[rstest]
fn test_state_clear_ci_status_all_empty(repo: TestRepo) {
let output = wt_state_cmd(&repo, "ci-status", "clear", &["--all"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
▲ wt config state ci-status is deprecated; use wt config state cache instead
○ No CI cache entries to clear
");
}
#[rstest]
fn test_state_clear_ci_status_branch(repo: TestRepo) {
let head = repo.head_sha();
write_ci_cache(
&repo,
"main",
&format!(
r#"{{"status":{{"ci_status":"passed","source":"pr","is_stale":false}},"checked_at":{TEST_EPOCH},"head":"{head}","branch":"main"}}"#
),
);
let cache_file = ci_cache_file(&repo, "main");
assert!(cache_file.exists(), "cache file should exist before clear");
let output = wt_state_cmd(&repo, "ci-status", "clear", &[])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
▲ wt config state ci-status is deprecated; use wt config state cache instead
✓ Cleared CI cache for main
");
assert!(
!cache_file.exists(),
"cache file should be gone after clear"
);
}
#[rstest]
fn test_state_clear_ci_status_branch_not_cached(repo: TestRepo) {
let cache_file = ci_cache_file(&repo, "main");
assert!(!cache_file.exists(), "cache file should not exist");
let output = wt_state_cmd(&repo, "ci-status", "clear", &[])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
▲ wt config state ci-status is deprecated; use wt config state cache instead
○ No CI cache for main
");
assert!(!cache_file.exists(), "cache file should still not exist");
}
// ============================================================================
// marker
// ============================================================================
#[rstest]
fn test_state_get_marker(repo: TestRepo) {
// Set a marker first (using JSON format)
repo.set_marker("main", "🚧");
let output = wt_state_cmd(&repo, "marker", "get", &[]).output().unwrap();
assert!(output.status.success());
// data() writes to stdout for piping
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "🚧");
}
#[rstest]
fn test_state_get_marker_empty(repo: TestRepo) {
let output = wt_state_cmd(&repo, "marker", "get", &[]).output().unwrap();
assert!(output.status.success());
// Empty output when no marker is set
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "");
}
#[rstest]
fn test_state_get_marker_specific_branch(repo: TestRepo) {
repo.git_command()
.args(["branch", "feature"])
.run()
.unwrap();
// Set a marker for feature branch (using JSON format)
repo.set_marker("feature", "🔧");
let output = wt_state_cmd(&repo, "marker", "get", &["--branch", "feature"])
.output()
.unwrap();
assert!(output.status.success());
// data() writes to stdout for piping
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "🔧");
}
#[rstest]
fn test_state_set_marker_branch_default(repo: TestRepo) {
let output = wt_state_cmd(&repo, "marker", "set", &["🚧"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"✓ Set marker for main to 🚧");
// Verify it was set (use wt command to parse JSON storage)
let output = wt_state_cmd(&repo, "marker", "get", &[]).output().unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "🚧");
}
#[rstest]
fn test_state_set_marker_branch_specific(repo: TestRepo) {
repo.git_command()
.args(["branch", "feature"])
.run()
.unwrap();
let output = wt_state_cmd(&repo, "marker", "set", &["🔧", "--branch", "feature"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"✓ Set marker for feature to 🔧");
// Verify it was set (use wt command to parse JSON storage)
let output = wt_state_cmd(&repo, "marker", "get", &["--branch", "feature"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "🔧");
}
#[rstest]
fn test_state_clear_marker_branch_default(repo: TestRepo) {
// Set a marker first (using JSON format)
repo.set_marker("main", "🚧");
let output = wt_state_cmd(&repo, "marker", "clear", &[])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"✓ Cleared marker for main");
// Verify it was unset
let output = repo
.git_command()
.args(["config", "--get", "worktrunk.state.main.marker"])
.run()
.unwrap();
assert!(!output.status.success());
}
#[rstest]
fn test_state_clear_marker_branch_specific(repo: TestRepo) {
// Set a marker first (using JSON format)
repo.set_marker("feature", "🔧");
let output = wt_state_cmd(&repo, "marker", "clear", &["--branch", "feature"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"✓ Cleared marker for feature");
// Verify it was unset
let output = repo
.git_command()
.args(["config", "--get", "worktrunk.state.feature.marker"])
.run()
.unwrap();
assert!(!output.status.success());
}
#[rstest]
fn test_state_clear_marker_all(repo: TestRepo) {
// Set multiple markers (using JSON format)
repo.set_marker("main", "🚧");
repo.set_marker("feature", "🔧");
repo.set_marker("bugfix", "🐛");
let output = wt_state_cmd(&repo, "marker", "clear", &["--all"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"✓ Cleared 3 markers");
// Verify all were unset
let output = repo
.git_command()
.args(["config", "--get-regexp", r"^worktrunk\.state\..+\.marker$"])
.run()
.unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "");
}
#[rstest]
fn test_state_clear_marker_all_empty(repo: TestRepo) {
let output = wt_state_cmd(&repo, "marker", "clear", &["--all"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"○ No markers to clear");
}
// ============================================================================
// logs
// ============================================================================
#[rstest]
fn test_state_get_logs_empty(repo: TestRepo) {
let output = wt_state_cmd(&repo, "logs", "get", &[]).output().unwrap();
assert!(output.status.success());
state_get_settings().bind(|| {
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @"
COMMAND LOG @ <PATH>
  (none)
HOOK OUTPUT @ <PATH>
  (none)
DIAGNOSTIC @ <PATH>
  (none)
");
});
}
#[rstest]
fn test_state_get_logs_with_files(repo: TestRepo) {
// Create wt/logs directory with hook output and command log files
let git_dir = repo.root_path().join(".git");
let log_dir = git_dir.join("wt/logs");
std::fs::create_dir_all(&log_dir).unwrap();
write_log_at(
&log_dir,
&hook_log_rel_path("feature", "user", "post-start", "npm"),
"npm output here",
);
// >= 1024 bytes to exercise the `{}K` size-formatting branch in
// render_log_table (the other test files stay under 1KB to exercise `{}B`).
write_log_at(
&log_dir,
&internal_log_rel_path("bugfix", "remove"),
&"remove output\n".repeat(80),
);
std::fs::write(log_dir.join("commands.jsonl"), r#"{"ts":"2026-01-01"}"#).unwrap();
let output = wt_state_cmd(&repo, "logs", "get", &[]).output().unwrap();
assert!(output.status.success());
let mut settings = state_get_settings();
// File sizes and ages vary across environments
settings.add_filter(r"(?m)\d+[BK]\s+\S+[ \t]*$", "<SIZE> <AGE>");
settings.bind(|| {
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @"
COMMAND LOG @ <PATH>
File Size Age
────────────── ──── ──────
commands.jsonl <SIZE> <AGE>
HOOK OUTPUT @ <PATH>
File Size Age
─────────────────────────────── ──── ──────
bugfix/internal/remove.log <SIZE> <AGE>
feature/user/post-start/npm.log <SIZE> <AGE>
DIAGNOSTIC @ <PATH>
  (none)
");
});
}
#[rstest]
fn test_state_get_logs_dir_exists_no_log_files(repo: TestRepo) {
// Create wt/logs directory with non-log files (empty of actual log files)
let git_dir = repo.root_path().join(".git");
let log_dir = git_dir.join("wt/logs");
std::fs::create_dir_all(&log_dir).unwrap();
std::fs::write(log_dir.join("README.txt"), "not a log file").unwrap();
std::fs::write(log_dir.join(".gitkeep"), "").unwrap();
let output = wt_state_cmd(&repo, "logs", "get", &[]).output().unwrap();
assert!(output.status.success());
state_get_settings().bind(|| {
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @"
COMMAND LOG @ <PATH>
  (none)
HOOK OUTPUT @ <PATH>
  (none)
DIAGNOSTIC @ <PATH>
  (none)
");
});
}
#[rstest]
fn test_state_get_logs_diagnostic_files(repo: TestRepo) {
// Create wt/logs directory with diagnostic files
let git_dir = repo.root_path().join(".git");
let log_dir = git_dir.join("wt/logs");
std::fs::create_dir_all(&log_dir).unwrap();
std::fs::write(log_dir.join("trace.log"), "debug output").unwrap();
std::fs::write(log_dir.join("subprocess.log"), "raw subprocess output").unwrap();
std::fs::write(log_dir.join("diagnostic.md"), "# Diagnostic Report").unwrap();
// Also add a hook output file to verify separation
let remove_rel = internal_log_rel_path("feature", "remove");
write_log_at(&log_dir, &remove_rel, "remove output");
let output = wt_state_cmd(&repo, "logs", "get", &[]).output().unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
// Diagnostic files appear under DIAGNOSTIC, not HOOK OUTPUT
assert!(stdout.contains("DIAGNOSTIC"), "Expected DIAGNOSTIC heading");
for name in ["trace.log", "subprocess.log", "diagnostic.md"] {
assert!(stdout.contains(name), "Expected {name} in output");
}
// Hook output should have the remove log but not the diagnostic files
let hook_section = stdout
.split("DIAGNOSTIC")
.next()
.unwrap()
.rsplit("HOOK OUTPUT")
.next()
.unwrap();
let remove_display = rel_display(&remove_rel);
assert!(
hook_section.contains(&remove_display),
"Expected {remove_display} in HOOK OUTPUT: {hook_section}"
);
for name in ["trace.log", "subprocess.log"] {
assert!(
!hook_section.contains(name),
"{name} should not be in HOOK OUTPUT: {hook_section}"
);
}
}
#[rstest]
fn test_state_clear_logs_includes_diagnostic_files(repo: TestRepo) {
// diagnostic files (.log and .md) should be cleared
let git_dir = repo.root_path().join(".git");
let log_dir = git_dir.join("wt/logs");
std::fs::create_dir_all(&log_dir).unwrap();
std::fs::write(log_dir.join("trace.log"), "debug output").unwrap();
std::fs::write(log_dir.join("subprocess.log"), "raw output").unwrap();
std::fs::write(log_dir.join("diagnostic.md"), "# Report").unwrap();
let output = wt_state_cmd(&repo, "logs", "clear", &[]).output().unwrap();
assert!(output.status.success());
assert_snapshot!(
String::from_utf8_lossy(&output.stderr),
@"✓ Cleared 3 log files"
);
assert!(!log_dir.exists());
}
#[rstest]
fn test_state_clear_logs_empty(repo: TestRepo) {
let output = wt_state_cmd(&repo, "logs", "clear", &[]).output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"○ No logs to clear");
}
#[rstest]
fn test_state_clear_logs_with_files(repo: TestRepo) {
// Create wt/logs directory with hook output and command log files
let git_dir = repo.root_path().join(".git");
let log_dir = git_dir.join("wt/logs");
std::fs::create_dir_all(&log_dir).unwrap();
write_log_at(
&log_dir,
&hook_log_rel_path("feature", "user", "post-start", "npm"),
"npm output",
);
write_log_at(
&log_dir,
&internal_log_rel_path("bugfix", "remove"),
"remove output",
);
std::fs::write(log_dir.join("commands.jsonl"), "jsonl data").unwrap();
let output = wt_state_cmd(&repo, "logs", "clear", &[]).output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"✓ Cleared 3 log files");
// Verify logs are gone
assert!(!log_dir.exists());
}
#[rstest]
fn test_state_clear_logs_sweeps_legacy_flat_files(repo: TestRepo) {
// Pre-nested layout left orphan `.log` files directly under wt/logs/.
// `clear_logs` self-heals by sweeping them along with everything else.
let git_dir = repo.root_path().join(".git");
let log_dir = git_dir.join("wt/logs");
std::fs::create_dir_all(&log_dir).unwrap();
std::fs::write(log_dir.join("feature-post-start-npm.log"), "old layout").unwrap();
std::fs::write(log_dir.join("bugfix-remove.log"), "old layout").unwrap();
let output = wt_state_cmd(&repo, "logs", "clear", &[]).output().unwrap();
assert!(output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Cleared") && stderr.contains("2") && stderr.contains("log file"));
assert!(!log_dir.exists(), "log dir should be removed after sweep");
}
#[rstest]
fn test_state_clear_logs_single_file(repo: TestRepo) {
// Create wt/logs directory with one log file
let git_dir = repo.root_path().join(".git");
let log_dir = git_dir.join("wt/logs");
std::fs::create_dir_all(&log_dir).unwrap();
write_log_at(
&log_dir,
&internal_log_rel_path("feature", "remove"),
"remove output",
);
let output = wt_state_cmd(&repo, "logs", "clear", &[]).output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"✓ Cleared 1 log file");
}
// ============================================================================
// state clear (all)
// ============================================================================
fn wt_state_clear_all_cmd(repo: &TestRepo) -> std::process::Command {
let mut cmd = wt_command();
cmd.current_dir(repo.root_path());
cmd.env("CLICOLOR_FORCE", "1");
// `clear` prompts before removing markers/vars; `--yes` skips it so these
// tests exercise the clearing itself. The prompt path has its own tests.
cmd.args(["config", "state", "clear", "--yes"]);
cmd
}
#[rstest]
fn test_state_clear_all_empty(repo: TestRepo) {
// Clear when no state exists
let output = wt_state_clear_all_cmd(&repo).output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"○ No stored state to clear");
}
#[rstest]
fn test_state_clear_all_comprehensive(repo: TestRepo) {
// Set up various state
// Previous branch
repo.git_command()
.args(["config", "worktrunk.history", "feature"])
.run()
.unwrap();
// Marker (using JSON format)
repo.set_marker("main", "🚧");
// CI cache (file-based)
write_ci_cache(
&repo,
"feature",
r#"{"checked_at":1704067200,"head":"abc123","branch":"feature"}"#,
);
// Vars data
repo.git_command()
.args(["config", "worktrunk.state.main.vars.env", "staging"])
.run()
.unwrap();
// Git commands cache (SHA-keyed)
let git_dir = repo.root_path().join(".git");
let sha_cache_dir = git_dir.join("wt/cache/merge-tree-conflicts");
std::fs::create_dir_all(&sha_cache_dir).unwrap();
std::fs::write(sha_cache_dir.join("abc123-def456.json"), "true").unwrap();
// Summary cache (content-addressed filename)
write_summary_cache(&repo, "feature", "aaaaaaaaaaaaaaaa", "Short summary", 0);
// Logs
let log_dir = git_dir.join("wt/logs");
std::fs::create_dir_all(&log_dir).unwrap();
write_log_at(
&log_dir,
&internal_log_rel_path("feature", "remove"),
"output",
);
let output = wt_state_clear_all_cmd(&repo).output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
✓ Cleared previous branch
✓ Cleared 1 marker
✓ Cleared 1 CI cache entry
✓ Cleared 1 summary cache entry
✓ Cleared 1 git commands cache entry
✓ Cleared 1 variable
✓ Cleared 1 log file
");
// Verify everything was cleared
assert!(
repo.git_command()
.args(["config", "--get", "worktrunk.history"])
.run()
.unwrap()
.status
.code()
== Some(1)
); // Not found
assert!(
repo.git_command()
.args(["config", "--get", "worktrunk.state.main.marker"])
.run()
.unwrap()
.status
.code()
== Some(1)
);
assert!(
repo.git_command()
.args(["config", "--get", "worktrunk.state.main.vars.env"])
.run()
.unwrap()
.status
.code()
== Some(1),
"Vars data should be cleared"
);
// CI cache is now file-based, verify the cache file is cleared
let ci_cache_dir = git_dir.join("wt").join("cache").join("ci-status");
assert!(
!ci_cache_dir.join("feature.json").exists(),
"CI cache file should be cleared"
);
// Summary cache is file-based too, verify the per-branch dir is gone
let summary_feature_dir = git_dir.join("wt/cache/summary/feature");
assert!(
!summary_feature_dir.exists(),
"Summary cache dir for feature should be cleared"
);
assert!(!log_dir.exists());
}
#[rstest]
fn test_state_clear_all_cleans_trash(repo: TestRepo) {
// Create trash directory with stale entries (simulating failed background rm)
let git_dir = repo.root_path().join(".git");
let trash_dir = git_dir.join("wt/trash");
std::fs::create_dir_all(trash_dir.join("myproject.feature-1234567890/target")).unwrap();
std::fs::write(
trash_dir.join("myproject.feature-1234567890/target/.rustc_info.json"),
"{}",
)
.unwrap();
std::fs::create_dir_all(trash_dir.join("myproject.bugfix-9999999999")).unwrap();
// Stray file directly in trash (not inside a subdirectory) — exercises the
// non-directory branch in clear_trash's `if path.is_dir()` guard.
std::fs::write(trash_dir.join("stray-file.txt"), "stale").unwrap();
let output = wt_state_clear_all_cmd(&repo).output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"✓ Cleared 3 trash entries");
// Trash directory itself should be removed (empty after cleanup)
assert!(!trash_dir.exists(), "Trash directory should be cleaned up");
}
#[rstest]
fn test_state_clear_all_nothing_to_clear(repo: TestRepo) {
// First clear to ensure nothing exists
wt_state_clear_all_cmd(&repo).output().unwrap();
// Clear again when nothing exists
let output = wt_state_clear_all_cmd(&repo).output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"○ No stored state to clear");
}
/// Run `wt config state clear` (no `--yes`) with the given stdin, so the
/// confirmation prompt reads `input`.
fn clear_all_with_stdin(repo: &TestRepo, input: &str) -> std::process::Output {
let mut cmd = wt_command();
cmd.current_dir(repo.root_path());
cmd.env("CLICOLOR_FORCE", "1");
cmd.args(["config", "state", "clear"]);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn().unwrap();
child
.stdin
.take()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
child.wait_with_output().unwrap()
}
/// Without `--yes` and with no interactive input, the prompt declines and
/// nothing is cleared.
#[rstest]
fn test_state_clear_all_prompt_declines(repo: TestRepo) {
repo.git_command()
.args(["config", "worktrunk.history", "feature"])
.run()
.unwrap();
let mut cmd = wt_command();
cmd.current_dir(repo.root_path());
cmd.env("CLICOLOR_FORCE", "1");
cmd.args(["config", "state", "clear"]);
cmd.stdin(Stdio::null());
let output = cmd.output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
❯ Clear all stored state, including branch markers and vars? [y/N/?] ○ Clear cancelled
");
// Previous branch must survive the declined prompt.
assert!(
repo.git_command()
.args(["config", "--get", "worktrunk.history"])
.run()
.unwrap()
.status
.success()
);
}
/// Answering `y` at the prompt clears everything (covers the accepted branch).
#[rstest]
fn test_state_clear_all_prompt_accepts(repo: TestRepo) {
repo.git_command()
.args(["config", "worktrunk.history", "feature"])
.run()
.unwrap();
let output = clear_all_with_stdin(&repo, "y\n");
assert!(output.status.success());
assert!(
String::from_utf8_lossy(&output.stderr).contains("Cleared previous branch"),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
!repo
.git_command()
.args(["config", "--get", "worktrunk.history"])
.run()
.unwrap()
.status
.success(),
"previous branch should be cleared after accepting"
);
}
// ============================================================================
// cache
// ============================================================================
/// `wt config state cache` (no subcommand) defaults to `get`.
#[rstest]
fn test_state_cache_bare_get_empty(repo: TestRepo) {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args(["config", "state", "cache"]);
cmd.current_dir(repo.root_path());
let output = cmd.output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @"
PREVIOUS BRANCH
  (none)
CI STATUS CACHE
  (none)
SUMMARY CACHE
  (none)
GIT COMMANDS CACHE
  (none)
HINTS
  (none)
");
}
#[rstest]
fn test_state_cache_get_json_empty(repo: TestRepo) {
let output = wt_state_cmd(&repo, "cache", "get", &["--format=json"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @r#"
{
"ci_status": [],
"git_commands_cache": 0,
"hints": [],
"max_pr_number": null,
"previous_branch": null,
"summaries": []
}
"#);
}
#[rstest]
fn test_state_cache_clear_empty(repo: TestRepo) {
let output = wt_state_cmd(&repo, "cache", "clear", &[]).output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"○ No cache to clear");
}
/// The PR-number width ratchet surfaces in the CI cache category: shown by
/// `cache get` (table and JSON) even with no branch entries, and counted by
/// `cache clear`.
#[rstest]
fn test_state_cache_pr_number_ratchet(repo: TestRepo) {
let dir = repo.root_path().join(".git/wt/cache/pr-number");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("max.json"), r#"{"number":3035}"#).unwrap();
let output = wt_state_cmd(&repo, "cache", "get", &[]).output().unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let ci_section = stdout
.split("\n\n")
.find(|s| s.contains("CI STATUS CACHE"))
.unwrap();
assert_snapshot!(ci_section, @"
CI STATUS CACHE
  (no entries)
  largest PR/MR number: 3035
");
let output = wt_state_cmd(&repo, "cache", "get", &["--format=json"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @r#"
{
"ci_status": [],
"git_commands_cache": 0,
"hints": [],
"max_pr_number": 3035,
"previous_branch": null,
"summaries": []
}
"#);
let output = wt_state_cmd(&repo, "cache", "clear", &[]).output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"✓ Cleared 1 CI cache entry");
assert!(!dir.join("max.json").exists());
}
#[rstest]
fn test_state_cache_clear_with_entries(repo: TestRepo) {
// Seed every regenerable cache category.
repo.git_command()
.args(["config", "worktrunk.history", "feature"])
.run()
.unwrap();
write_ci_cache(
&repo,
"feature",
r#"{"checked_at":1704067200,"head":"abc123","branch":"feature"}"#,
);
write_summary_cache(&repo, "feature", "aaaaaaaaaaaaaaaa", "Short summary", 0);
let git_dir = repo.root_path().join(".git");
let sha_cache_dir = git_dir.join("wt/cache/merge-tree-conflicts");
std::fs::create_dir_all(&sha_cache_dir).unwrap();
std::fs::write(sha_cache_dir.join("abc123-def456.json"), "true").unwrap();
repo.git_command()
.args(["config", "worktrunk.hints.worktree-path", "1"])
.run()
.unwrap();
let output = wt_state_cmd(&repo, "cache", "clear", &[]).output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
✓ Cleared previous branch
✓ Cleared 1 CI cache entry
✓ Cleared 1 summary cache entry
✓ Cleared 1 git commands cache entry
✓ Cleared 1 hint
");
}
/// `cache clear` must leave authoritative state (markers, vars,
/// default-branch override) untouched — only regenerable caches are dropped.
#[rstest]
fn test_state_cache_clear_preserves_authoritative(repo: TestRepo) {
repo.set_marker("main", "🚧");
repo.git_command()
.args(["config", "worktrunk.state.main.vars.env", "staging"])
.run()
.unwrap();
repo.git_command()
.args(["config", "worktrunk.history", "feature"])
.run()
.unwrap();
let output = wt_state_cmd(&repo, "cache", "clear", &[]).output().unwrap();
assert!(output.status.success());
// Regenerable: previous branch is cleared.
assert!(
!repo
.git_command()
.args(["config", "--get", "worktrunk.history"])
.run()
.unwrap()
.status
.success(),
"previous branch (cache) should be cleared"
);
// Authoritative: marker and var survive.
assert!(
repo.git_command()
.args(["config", "--get", "worktrunk.state.main.marker"])
.run()
.unwrap()
.status
.success(),
"marker should survive cache clear"
);
assert!(
repo.git_command()
.args(["config", "--get", "worktrunk.state.main.vars.env"])
.run()
.unwrap()
.status
.success(),
"var should survive cache clear"
);
}
/// The subcommands folded into `cache` (ci-status, hints, previous-branch)
/// still work but print a deprecation notice on stderr.
#[rstest]
fn test_state_deprecated_subcommands_warn(repo: TestRepo) {
for name in ["ci-status", "hints", "previous-branch"] {
let output = wt_state_cmd(&repo, name, "get", &[]).output().unwrap();
assert!(output.status.success(), "{name} get should succeed");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains(&format!("wt config state {name} is deprecated; use"))
&& stderr.contains("wt config state cache"),
"{name} should warn: {stderr}"
);
}
}
// ============================================================================
// state get
// ============================================================================
#[rstest]
fn test_state_get_empty(repo: TestRepo) {
let output = wt_state_get_cmd(&repo).output().unwrap();
assert!(output.status.success());
state_get_settings().bind(|| {
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @"
DEFAULT BRANCH
  (none)
PREVIOUS BRANCH
  (none)
BRANCH MARKERS
  (none)
VARS
  (none)
CI STATUS CACHE
  (none)
SUMMARY CACHE
  (none)
GIT COMMANDS CACHE
  (none)
HINTS
  (none)
COMMAND LOG @ <PATH>
  (none)
HOOK OUTPUT @ <PATH>
  (none)
DIAGNOSTIC @ <PATH>
  (none)
TRASH @ _REPO_/.git/wt/trash
  (none)
");
});
}
#[rstest]
fn test_state_get_with_ci_entries(repo: TestRepo) {
// Add CI cache entries - use TEST_EPOCH for deterministic age=0s in
// snapshots. The heads are placeholders, so what the snapshot pins is the
// table's shape; that the width is git's own is pinned separately by
// `test_state_get_ci_head_uses_git_abbreviation`.
write_ci_cache(
&repo,
"feature",
&format!(
r#"{{"status":{{"ci_status":"passed","source":"pr","is_stale":false}},"checked_at":{TEST_EPOCH},"head":"abc12345","branch":"feature"}}"#
),
);
write_ci_cache(
&repo,
"bugfix",
&format!(
r#"{{"status":{{"ci_status":"failed","source":"branch","is_stale":true}},"checked_at":{TEST_EPOCH},"head":"11122233","branch":"bugfix"}}"#
),
);
write_ci_cache(
&repo,
"main",
&format!(
r#"{{"status":null,"checked_at":{TEST_EPOCH},"head":"deadbeef","branch":"main"}}"#
),
);
let output = wt_state_get_cmd(&repo).output().unwrap();
assert!(output.status.success());
state_get_settings().bind(|| {
assert_snapshot!(String::from_utf8_lossy(&output.stdout));
});
}
/// The CI cache's Head column abbreviates through git, so a head reads at the
/// same width in `wt config state` as it does in `wt list`'s Commit column and
/// in the statusline — all of them `core.abbrev`, none of them a slice of their
/// own. Snapshots can't pin this: the width git picks scales with the repo's
/// object count, and the SHA a test repo commits to isn't fixed.
#[rstest]
fn test_state_get_ci_head_uses_git_abbreviation(repo: TestRepo) {
let head = repo.head_sha();
write_ci_cache(
&repo,
"main",
&format!(r#"{{"status":null,"checked_at":{TEST_EPOCH},"head":"{head}","branch":"main"}}"#),
);
let expected = String::from_utf8_lossy(
&repo
.git_command()
.args(["rev-parse", "--short", &head])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
let output = wt_state_get_cmd(&repo).output().unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let row = stdout
.lines()
.find(|l| l.contains("main") && l.contains("none"))
.unwrap_or_else(|| panic!("no CI cache row for main:\n{stdout}"));
assert!(
row.split_whitespace().any(|cell| cell == expected),
"expected the git-abbreviated head {expected:?} in the CI row: {row:?}"
);
assert!(
!stdout.contains(&head),
"the full SHA should not reach the table:\n{stdout}"
);
}
#[rstest]
fn test_state_get_comprehensive(repo: TestRepo) {
// Populate the default-branch cache (the dump reads it, never detects)
repo.git_command()
.args(["config", "worktrunk.default-branch", "main"])
.run()
.unwrap();
// Set up previous branch
repo.git_command()
.args(["config", "worktrunk.history", "feature"])
.run()
.unwrap();
// Set up branch markers (JSON format with timestamps for deterministic age)
repo.git_command()
.args([
"config",
"worktrunk.state.feature.marker",
&format!(r#"{{"marker":"🚧 WIP","set_at":{TEST_EPOCH}}}"#),
])
.run()
.unwrap();
repo.git_command()
.args([
"config",
"worktrunk.state.bugfix.marker",
&format!(r#"{{"marker":"🐛 debugging","set_at":{TEST_EPOCH}}}"#),
])
.run()
.unwrap();
// Set up vars data
repo.git_command()
.args(["config", "worktrunk.state.main.vars.env", "staging"])
.run()
.unwrap();
repo.git_command()
.args(["config", "worktrunk.state.feature.vars.port", "3000"])
.run()
.unwrap();
// Set up CI cache (file-based)
write_ci_cache(
&repo,
"feature",
&format!(
r#"{{"status":{{"ci_status":"passed","source":"pr","is_stale":false}},"checked_at":{TEST_EPOCH},"head":"abc12345","branch":"feature"}}"#
),
);
// Create log files
let git_dir = repo.root_path().join(".git");
let log_dir = git_dir.join("wt/logs");
std::fs::create_dir_all(&log_dir).unwrap();
write_log_at(
&log_dir,
&hook_log_rel_path("feature", "user", "post-start", "npm"),
"npm output",
);
write_log_at(
&log_dir,
&internal_log_rel_path("bugfix", "remove"),
"remove output",
);
// Create trash entries (staged worktree removals)
let trash_dir = git_dir.join("wt/trash");
std::fs::create_dir_all(trash_dir.join("myproject.feature-1234567890")).unwrap();
std::fs::create_dir_all(trash_dir.join("myproject.bugfix-9999999999")).unwrap();
// Create git commands cache entries (SHA-keyed caches)
let sha_cache_dir = git_dir.join("wt/cache/merge-tree-conflicts");
std::fs::create_dir_all(&sha_cache_dir).unwrap();
std::fs::write(sha_cache_dir.join("aaaa-bbbb.json"), "{}").unwrap();
std::fs::write(sha_cache_dir.join("cccc-dddd.json"), "{}").unwrap();
// Populate summary cache so the SUMMARIES CACHE section renders rows.
write_summary_cache(
&repo,
"feature",
"aaaaaaaaaaaaaaaa",
"Add constant-time token validation",
TEST_EPOCH,
);
let output = wt_state_get_cmd(&repo).output().unwrap();
assert!(output.status.success());
state_get_settings().bind(|| {
assert_snapshot!(String::from_utf8_lossy(&output.stdout));
});
}
#[rstest]
fn test_state_get_json_empty(repo: TestRepo) {
let output = wt_state_get_json_cmd(&repo).output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @r#"
{
"ci_status": [],
"command_log": [],
"default_branch": null,
"diagnostic": [],
"git_commands_cache": 0,
"hints": [],
"hook_output": [],
"markers": [],
"max_pr_number": null,
"previous_branch": null,
"remote_head_branch": null,
"summaries": [],
"trash": [],
"vars": []
}
"#);
}
#[rstest]
fn test_state_get_json_comprehensive(repo: TestRepo) {
// Populate the default-branch cache (the dump reads it, never detects)
repo.git_command()
.args(["config", "worktrunk.default-branch", "main"])
.run()
.unwrap();
// Set up previous branch
repo.git_command()
.args(["config", "worktrunk.history", "feature"])
.run()
.unwrap();
// Set up branch markers (JSON format with timestamps)
repo.git_command()
.args([
"config",
"worktrunk.state.feature.marker",
&format!(r#"{{"marker":"🚧 WIP","set_at":{TEST_EPOCH}}}"#),
])
.run()
.unwrap();
// Set up vars data
repo.git_command()
.args(["config", "worktrunk.state.main.vars.env", "staging"])
.run()
.unwrap();
// Set up CI cache (file-based)
write_ci_cache(
&repo,
"feature",
&format!(
r#"{{"status":{{"ci_status":"passed","source":"pr","is_stale":false}},"checked_at":{TEST_EPOCH},"head":"abc12345def67890","branch":"feature"}}"#
),
);
// Populate trash + git commands cache for parity coverage
let git_dir = repo.root_path().join(".git");
std::fs::create_dir_all(git_dir.join("wt/trash/myproject.feature-1234567890")).unwrap();
let sha_cache_dir = git_dir.join("wt/cache/is-ancestor");
std::fs::create_dir_all(&sha_cache_dir).unwrap();
std::fs::write(sha_cache_dir.join("aaaa-bbbb.json"), "{}").unwrap();
// Populate summary cache
write_summary_cache(
&repo,
"feature",
"aaaaaaaaaaaaaaaa",
"Add constant-time token validation",
TEST_EPOCH,
);
let output = wt_state_get_json_cmd(&repo).output().unwrap();
assert!(output.status.success());
let json_str = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let normalized = serde_json::to_string_pretty(&json).unwrap();
let mut settings = insta::Settings::clone_current();
settings.add_filter(r#""modified_at": \d+"#, r#""modified_at": "<MTIME>""#);
settings.bind(|| {
assert_snapshot!(normalized, @r#"
{
"ci_status": [
{
"branch": "feature",
"checked_at": 1735776000,
"head": "abc12345def67890",
"status": "passed"
}
],
"command_log": [],
"default_branch": "main",
"diagnostic": [],
"git_commands_cache": 1,
"hints": [],
"hook_output": [],
"markers": [
{
"branch": "feature",
"marker": "🚧 WIP",
"set_at": 1735776000
}
],
"max_pr_number": null,
"previous_branch": "feature",
"remote_head_branch": null,
"summaries": [
{
"branch": "feature",
"generated_at": 1735776000,
"summary": "Add constant-time token validation"
}
],
"trash": [
{
"modified_at": "<MTIME>",
"name": "myproject.feature-1234567890",
"path": "_REPO_/.git/wt/trash/myproject.feature-1234567890"
}
],
"vars": [
{
"branch": "main",
"key": "env",
"value": "staging"
}
]
}
"#);
});
}
#[rstest]
fn test_state_get_json_with_logs(repo: TestRepo) {
// Create hook output and command log files
let git_dir = repo.root_path().join(".git");
let log_dir = git_dir.join("wt/logs");
std::fs::create_dir_all(&log_dir).unwrap();
write_log_at(
&log_dir,
&hook_log_rel_path("feature", "user", "post-start", "npm"),
"npm output",
);
write_log_at(
&log_dir,
&internal_log_rel_path("bugfix", "remove"),
"remove log output",
);
std::fs::write(log_dir.join("commands.jsonl"), r#"{"ts":"2026-01-01"}"#).unwrap();
let output = wt_state_get_json_cmd(&repo).output().unwrap();
assert!(output.status.success());
let json_str = String::from_utf8_lossy(&output.stdout);
let mut json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
// Sort log arrays by filename (mtime ties produce platform-dependent order)
for key in ["command_log", "hook_output"] {
if let Some(arr) = json.get_mut(key).and_then(|v| v.as_array_mut()) {
arr.sort_by(|a, b| a["file"].as_str().cmp(&b["file"].as_str()));
}
}
// Normalize dynamic fields before snapshotting
let normalized = serde_json::to_string_pretty(&json).unwrap();
let mut settings = insta::Settings::clone_current();
settings.add_filter(r#""modified_at": \d+"#, r#""modified_at": "<MTIME>""#);
settings.add_filter(r#""size": \d+"#, r#""size": "<SIZE>""#);
settings.bind(|| {
assert_snapshot!(normalized, @r#"
{
"ci_status": [],
"command_log": [
{
"file": "commands.jsonl",
"modified_at": "<MTIME>",
"path": "_REPO_/.git/wt/logs/commands.jsonl",
"size": "<SIZE>"
}
],
"default_branch": null,
"diagnostic": [],
"git_commands_cache": 0,
"hints": [],
"hook_output": [
{
"branch": "bugfix",
"file": "bugfix/internal/remove.log",
"hook_type": null,
"modified_at": "<MTIME>",
"name": "remove",
"path": "_REPO_/.git/wt/logs/bugfix/internal/remove.log",
"size": "<SIZE>",
"source": "internal"
},
{
"branch": "feature",
"file": "feature/user/post-start/npm.log",
"hook_type": "post-start",
"modified_at": "<MTIME>",
"name": "npm",
"path": "_REPO_/.git/wt/logs/feature/user/post-start/npm.log",
"size": "<SIZE>",
"source": "user"
}
],
"markers": [],
"max_pr_number": null,
"previous_branch": null,
"remote_head_branch": null,
"summaries": [],
"trash": [],
"vars": []
}
"#);
});
}
// ============================================================================
// Additional coverage tests for uncovered user messages
// ============================================================================
#[rstest]
fn test_state_clear_ci_status_all_with_entries(repo: TestRepo) {
// Add file-based CI cache entries (the format used by CachedCiStatus::clear_all)
write_ci_cache(
&repo,
"feature",
&format!(
r#"{{"status":{{"ci_status":"passed","source":"pr","is_stale":false}},"checked_at":{TEST_EPOCH},"head":"abc12345","branch":"feature"}}"#
),
);
write_ci_cache(
&repo,
"bugfix",
&format!(
r#"{{"status":{{"ci_status":"failed","source":"branch","is_stale":false}},"checked_at":{TEST_EPOCH},"head":"def67890","branch":"bugfix"}}"#
),
);
let output = wt_state_cmd(&repo, "ci-status", "clear", &["--all"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
▲ wt config state ci-status is deprecated; use wt config state cache instead
✓ Cleared 2 CI cache entries
");
}
#[rstest]
fn test_state_clear_ci_status_all_single_entry(repo: TestRepo) {
// Test singular form "entry" vs "entries"
write_ci_cache(
&repo,
"feature",
&format!(
r#"{{"status":{{"ci_status":"passed","source":"pr","is_stale":false}},"checked_at":{TEST_EPOCH},"head":"abc12345","branch":"feature"}}"#
),
);
let output = wt_state_cmd(&repo, "ci-status", "clear", &["--all"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
▲ wt config state ci-status is deprecated; use wt config state cache instead
✓ Cleared 1 CI cache entry
");
}
#[rstest]
fn test_state_clear_ci_status_specific_branch(repo: TestRepo) {
// Create a feature branch
repo.git_command()
.args(["branch", "feature"])
.run()
.unwrap();
let head = repo.head_sha();
write_ci_cache(
&repo,
"feature",
&format!(
r#"{{"status":{{"ci_status":"passed","source":"pr","is_stale":false}},"checked_at":{TEST_EPOCH},"head":"{head}","branch":"feature"}}"#
),
);
let cache_file = ci_cache_file(&repo, "feature");
assert!(cache_file.exists(), "cache file should exist before clear");
let output = wt_state_cmd(&repo, "ci-status", "clear", &["--branch", "feature"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
▲ wt config state ci-status is deprecated; use wt config state cache instead
✓ Cleared CI cache for feature
");
assert!(
!cache_file.exists(),
"cache file should be gone after clear"
);
}
#[rstest]
fn test_state_clear_ci_status_specific_branch_not_cached(repo: TestRepo) {
// Create a feature branch without any CI cache
repo.git_command()
.args(["branch", "feature"])
.run()
.unwrap();
let cache_file = ci_cache_file(&repo, "feature");
assert!(!cache_file.exists(), "cache file should not exist");
let output = wt_state_cmd(&repo, "ci-status", "clear", &["--branch", "feature"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
▲ wt config state ci-status is deprecated; use wt config state cache instead
○ No CI cache for feature
");
assert!(!cache_file.exists(), "cache file should still not exist");
}
#[rstest]
fn test_state_clear_marker_specific_branch(repo: TestRepo) {
// Create a feature branch and set marker
repo.git_command()
.args(["branch", "feature"])
.run()
.unwrap();
repo.set_marker("feature", "🔧");
let output = wt_state_cmd(&repo, "marker", "clear", &["--branch", "feature"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"✓ Cleared marker for feature");
}
#[rstest]
fn test_state_clear_marker_specific_branch_not_set(repo: TestRepo) {
// Create a feature branch without any marker
repo.git_command()
.args(["branch", "feature"])
.run()
.unwrap();
let output = wt_state_cmd(&repo, "marker", "clear", &["--branch", "feature"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"○ No marker set for feature");
}
#[rstest]
fn test_state_clear_marker_current_branch_not_set(repo: TestRepo) {
// Clear marker on current branch (main) when none is set
let output = wt_state_cmd(&repo, "marker", "clear", &[])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"○ No marker set for main");
}
#[rstest]
fn test_state_clear_marker_all_single(repo: TestRepo) {
// Test singular form "marker" vs "markers"
repo.set_marker("main", "🚧");
let output = wt_state_cmd(&repo, "marker", "clear", &["--all"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"✓ Cleared 1 marker");
}
// ============================================================================
// hints
// ============================================================================
#[rstest]
fn test_state_hints_get_empty(repo: TestRepo) {
let output = wt_state_cmd(&repo, "hints", "get", &[]).output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
▲ wt config state hints is deprecated; use wt config state cache instead
○ No hints have been shown
");
}
#[rstest]
fn test_state_hints_get_with_hints(repo: TestRepo) {
// Set hints via git config (as the code stores them)
repo.git_command()
.args(["config", "worktrunk.hints.worktree-path", "true"])
.run()
.unwrap();
repo.git_command()
.args(["config", "worktrunk.hints.another-hint", "true"])
.run()
.unwrap();
let output = wt_state_cmd(&repo, "hints", "get", &[]).output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @"
another-hint
worktree-path
");
}
#[rstest]
fn test_state_hints_clear_empty(repo: TestRepo) {
let output = wt_state_cmd(&repo, "hints", "clear", &[]).output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
▲ wt config state hints is deprecated; use wt config state cache instead
○ No hints to clear
");
}
#[rstest]
fn test_state_hints_clear_all(repo: TestRepo) {
// Set hints
repo.git_command()
.args(["config", "worktrunk.hints.worktree-path", "true"])
.run()
.unwrap();
repo.git_command()
.args(["config", "worktrunk.hints.another-hint", "true"])
.run()
.unwrap();
let output = wt_state_cmd(&repo, "hints", "clear", &[]).output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
▲ wt config state hints is deprecated; use wt config state cache instead
✓ Cleared 2 hints
");
// Verify hints were cleared
let output = repo
.git_command()
.args(["config", "--get-regexp", r"^worktrunk\.hints\."])
.run()
.unwrap();
assert!(!output.status.success()); // No matches
}
#[rstest]
fn test_state_hints_clear_single(repo: TestRepo) {
// Set a single hint
repo.git_command()
.args(["config", "worktrunk.hints.worktree-path", "true"])
.run()
.unwrap();
let output = wt_state_cmd(&repo, "hints", "clear", &[]).output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
▲ wt config state hints is deprecated; use wt config state cache instead
✓ Cleared 1 hint
");
}
#[rstest]
fn test_state_hints_clear_specific(repo: TestRepo) {
// Set hints
repo.git_command()
.args(["config", "worktrunk.hints.worktree-path", "true"])
.run()
.unwrap();
repo.git_command()
.args(["config", "worktrunk.hints.another-hint", "true"])
.run()
.unwrap();
let output = wt_state_cmd(&repo, "hints", "clear", &["worktree-path"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
▲ wt config state hints is deprecated; use wt config state cache instead
✓ Cleared hint worktree-path
");
// Verify only that hint was cleared
let output = repo
.git_command()
.args(["config", "--get", "worktrunk.hints.worktree-path"])
.run()
.unwrap();
assert!(!output.status.success()); // Cleared
let output = repo
.git_command()
.args(["config", "--get", "worktrunk.hints.another-hint"])
.run()
.unwrap();
assert!(output.status.success()); // Still there
}
#[rstest]
fn test_state_hints_clear_specific_not_set(repo: TestRepo) {
let output = wt_state_cmd(&repo, "hints", "clear", &["nonexistent"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
▲ wt config state hints is deprecated; use wt config state cache instead
○ Hint nonexistent was not set
");
}
// ============================================================================
// vars
// ============================================================================
#[rstest]
fn test_vars_set_and_get(repo: TestRepo) {
// Set a value
let output = wt_state_cmd(&repo, "vars", "set", &["env=staging"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"✓ Set env for main");
// Get the value
let output = wt_state_cmd(&repo, "vars", "get", &["env"])
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "staging");
}
#[rstest]
fn test_vars_set_json_value(repo: TestRepo) {
let json = r#"{"port":3000,"debug":true}"#;
let output = wt_state_cmd(&repo, "vars", "set", &[&format!("config={json}")])
.output()
.unwrap();
assert!(output.status.success());
let output = wt_state_cmd(&repo, "vars", "get", &["config"])
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), json);
}
#[rstest]
fn test_vars_get_missing_key(repo: TestRepo) {
let output = wt_state_cmd(&repo, "vars", "get", &["nonexistent"])
.output()
.unwrap();
assert!(output.status.success());
// Empty output for missing keys
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "");
}
#[rstest]
fn test_vars_list(repo: TestRepo) {
// Set multiple values
wt_state_cmd(&repo, "vars", "set", &["env=staging"])
.output()
.unwrap();
wt_state_cmd(&repo, "vars", "set", &["port=3000"])
.output()
.unwrap();
let output = wt_state_cmd(&repo, "vars", "list", &[]).output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @"
env staging
port 3000
");
}
#[rstest]
fn test_vars_list_empty(repo: TestRepo) {
let output = wt_state_cmd(&repo, "vars", "list", &[]).output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"○ No variables for main");
}
#[rstest]
fn test_vars_clear_single_key(repo: TestRepo) {
// Set and clear
wt_state_cmd(&repo, "vars", "set", &["env=staging"])
.output()
.unwrap();
let output = wt_state_cmd(&repo, "vars", "clear", &["env"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"✓ Cleared env for main");
// Verify it's gone
let output = wt_state_cmd(&repo, "vars", "get", &["env"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "");
}
#[rstest]
fn test_vars_clear_all(repo: TestRepo) {
// Set multiple values
wt_state_cmd(&repo, "vars", "set", &["env=staging"])
.output()
.unwrap();
wt_state_cmd(&repo, "vars", "set", &["port=3000"])
.output()
.unwrap();
let output = wt_state_cmd(&repo, "vars", "clear", &["--all"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"✓ Cleared 2 variables for main");
// Verify all gone
let output = wt_state_cmd(&repo, "vars", "list", &[]).output().unwrap();
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"○ No variables for main");
}
#[rstest]
fn test_vars_invalid_key(repo: TestRepo) {
let output = wt_state_cmd(&repo, "vars", "set", &["foo.bar=value"])
.output()
.unwrap();
assert!(!output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @r#"✗ Invalid key "foo.bar": keys must contain only letters, digits, and hyphens"#);
}
#[rstest]
fn test_vars_branch_flag(repo: TestRepo) {
// Create a branch
repo.run_git(&["branch", "feature"]);
// Set vars on a different branch
let output = wt_state_cmd(
&repo,
"vars",
"set",
&["env=production", "--branch=feature"],
)
.output()
.unwrap();
assert!(output.status.success());
// Get from that branch
let output = wt_state_cmd(&repo, "vars", "get", &["env", "--branch=feature"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "production");
// Current branch should not have the value
let output = wt_state_cmd(&repo, "vars", "get", &["env"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "");
}
#[rstest]
fn test_vars_value_with_spaces(repo: TestRepo) {
let output = wt_state_cmd(&repo, "vars", "set", &["note=hello world foo"])
.output()
.unwrap();
assert!(output.status.success());
let output = wt_state_cmd(&repo, "vars", "get", &["note"])
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
"hello world foo"
);
}
#[rstest]
fn test_vars_value_containing_equals(repo: TestRepo) {
let url = "postgres://user:pass@host/db?sslmode=require";
let output = wt_state_cmd(&repo, "vars", "set", &[&format!("db-url={url}")])
.output()
.unwrap();
assert!(output.status.success());
let output = wt_state_cmd(&repo, "vars", "get", &["db-url"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), url);
}
#[rstest]
fn test_vars_empty_value(repo: TestRepo) {
let output = wt_state_cmd(&repo, "vars", "set", &["key="])
.output()
.unwrap();
assert!(output.status.success());
let output = wt_state_cmd(&repo, "vars", "get", &["key"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "");
}
#[rstest]
fn test_vars_overwrite(repo: TestRepo) {
wt_state_cmd(&repo, "vars", "set", &["env=staging"])
.output()
.unwrap();
wt_state_cmd(&repo, "vars", "set", &["env=production"])
.output()
.unwrap();
let output = wt_state_cmd(&repo, "vars", "get", &["env"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "production");
}
#[rstest]
fn test_vars_in_json_output(repo: TestRepo) {
// Set vars data
repo.git_command()
.args(["config", "worktrunk.state.main.vars.env", "staging"])
.run()
.unwrap();
repo.git_command()
.args(["config", "worktrunk.state.main.vars.port", "3000"])
.run()
.unwrap();
let output = repo
.wt_command()
.args(["list", "--format=json"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let items = json.as_array().unwrap();
assert!(!items.is_empty());
let main_item = &items[0];
assert_eq!(main_item["vars"]["env"], "staging");
assert_eq!(main_item["vars"]["port"], "3000");
}
#[rstest]
fn test_vars_absent_in_json_when_empty(repo: TestRepo) {
// No vars data set — vars field should be absent from JSON
let output = repo
.wt_command()
.args(["list", "--format=json"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let items = json.as_array().unwrap();
assert!(!items.is_empty());
// vars should not be present when empty (skip_serializing_if)
assert!(items[0].get("vars").is_none());
}
#[rstest]
fn test_vars_clear_nonexistent_key(repo: TestRepo) {
// Clear a key that was never set
let output = wt_state_cmd(&repo, "vars", "clear", &["nonexistent"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"○ No variable nonexistent for main");
}
#[rstest]
fn test_vars_clear_all_empty(repo: TestRepo) {
// Clear --all when no vars data exists
let output = wt_state_cmd(&repo, "vars", "clear", &["--all"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"○ No variables for main");
}
#[rstest]
fn test_vars_list_with_branch_flag(repo: TestRepo) {
// Create a branch and set vars data
repo.run_git(&["branch", "feature"]);
repo.git_command()
.args(["config", "worktrunk.state.feature.vars.env", "production"])
.run()
.unwrap();
// List vars for specific branch
let output = wt_state_cmd(&repo, "vars", "list", &["--branch=feature"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @"env production");
}
#[rstest]
fn test_vars_clear_with_branch_flag(repo: TestRepo) {
// Create a branch and set vars data
repo.run_git(&["branch", "feature"]);
repo.git_command()
.args(["config", "worktrunk.state.feature.vars.env", "production"])
.run()
.unwrap();
// Clear vars for specific branch
let output = wt_state_cmd(&repo, "vars", "clear", &["env", "--branch=feature"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"✓ Cleared env for feature");
}
#[rstest]
fn test_vars_branch_with_dots_in_name(repo: TestRepo) {
// Branch names with dots are common (e.g., "feature.auth") and contain
// regex metacharacters. Vars must round-trip correctly despite the dots.
repo.run_git(&["branch", "feature.auth"]);
let output = wt_state_cmd(
&repo,
"vars",
"set",
&["port=4000", "--branch=feature.auth"],
)
.output()
.unwrap();
assert!(output.status.success());
let output = wt_state_cmd(&repo, "vars", "get", &["port", "--branch=feature.auth"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "4000");
// Also verify the branch doesn't bleed into a similarly-named branch.
// "feature.auth" unescaped in regex would match "featurexauth" too.
repo.run_git(&["branch", "featurexauth"]);
repo.git_command()
.args(["config", "worktrunk.state.featurexauth.vars.port", "9999"])
.run()
.unwrap();
// feature.auth should still return 4000, not 9999
let output = wt_state_cmd(&repo, "vars", "get", &["port", "--branch=feature.auth"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "4000");
}
#[rstest]
fn test_vars_json_branch_with_vars_in_name(repo: TestRepo) {
// Regression: branch names containing ".vars." must not confuse the
// all_vars_entries parser (which splits on ".vars." to find the separator).
let wt_path = repo.root_path().join("..").join("fix-vars-cleanup-wt");
repo.run_git(&[
"worktree",
"add",
"-b",
"fix.vars.cleanup",
wt_path.to_str().unwrap(),
]);
repo.git_command()
.args([
"config",
"worktrunk.state.fix.vars.cleanup.vars.port",
"5000",
])
.run()
.unwrap();
let output = repo
.wt_command()
.args(["list", "--format=json"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let items = json.as_array().unwrap();
let branch_item = items
.iter()
.find(|item| item["branch"] == "fix.vars.cleanup")
.expect("branch fix.vars.cleanup should be in JSON output");
assert_eq!(
branch_item["vars"]["port"], "5000",
"vars key should be 'port', not a mangled key from bad split"
);
}
// ============================================================================
// --format=json on individual subcommands
// ============================================================================
#[rstest]
fn test_logs_get_json_empty(repo: TestRepo) {
let output = wt_state_cmd(&repo, "logs", "get", &["--format=json"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @r#"
{
"command_log": [],
"diagnostic": [],
"hook_output": []
}
"#);
}
/// `--format=json` on the bareword subcommand (no `get`) routes to the
/// same list view. `--format` is `global = true` on the parent, so all three
/// call shapes — `logs --format=json`, `logs --format=json get`,
/// `logs get --format=json` — produce JSON.
#[rstest]
fn test_logs_bare_format_json(repo: TestRepo) {
let mut cmd = repo.wt_command();
cmd.args(["config", "state", "logs", "--format=json"]);
cmd.current_dir(repo.root_path());
let output = cmd.output().unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @r#"
{
"command_log": [],
"diagnostic": [],
"hook_output": []
}
"#);
}
#[rstest]
fn test_logs_get_json_with_files(repo: TestRepo) {
let log_dir = repo.root_path().join(".git/wt/logs");
std::fs::create_dir_all(&log_dir).unwrap();
std::fs::write(log_dir.join("commands.jsonl"), "{}").unwrap();
std::fs::write(log_dir.join("diagnostic.md"), "# report").unwrap();
write_log_at(
&log_dir,
&hook_log_rel_path("main", "user", "post-start", "server"),
"output",
);
let output = wt_state_cmd(&repo, "logs", "get", &["--format=json"])
.output()
.unwrap();
assert!(output.status.success());
// Redact dynamic timestamps and sizes. "main" and "server" are already
// filename-safe, so sanitize_for_filename passes them through unchanged —
// no hash suffixes appear and no redaction is needed.
let mut settings = insta::Settings::clone_current();
settings.add_filter(r#""modified_at": \d+"#, r#""modified_at": "<TIMESTAMP>""#);
settings.add_filter(r#""size": \d+"#, r#""size": "<SIZE>""#);
settings.bind(|| {
assert_snapshot!(String::from_utf8_lossy(&output.stdout));
});
}
/// Internal-op entries get `source: "internal"`, `hook_type: null`, and the
/// op goes in `name` — so jq filters like `select(.source == "internal")`
/// work the same as for user/project hooks.
#[rstest]
fn test_logs_get_json_internal_op_structure(repo: TestRepo) {
let log_dir = repo.root_path().join(".git/wt/logs");
std::fs::create_dir_all(&log_dir).unwrap();
write_log_at(
&log_dir,
&internal_log_rel_path("feature", "remove"),
"remove output",
);
let output = wt_state_cmd(&repo, "logs", "get", &["--format=json"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let hook = &parsed["hook_output"][0];
assert_eq!(hook["source"], "internal");
assert_eq!(hook["hook_type"], serde_json::Value::Null);
assert_eq!(hook["name"], "remove");
assert!(hook["branch"].as_str().unwrap().starts_with("feature"));
}
/// Repo-wide internal logs are top-level shared files, not branch subtrees.
/// They must surface under `diagnostic` rather than being silently dropped.
#[rstest]
fn test_logs_get_json_shared_internal_log_is_not_hook_output(repo: TestRepo) {
let log_dir = repo.root_path().join(".git/wt/logs");
let relative = shared_internal_log_rel_path("trash-sweep");
write_log_at(&log_dir, &relative, "sweep output");
let output = wt_state_cmd(&repo, "logs", "get", &["--format=json"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(
parsed["hook_output"].as_array().unwrap().is_empty(),
"shared internal log must not be reported as branch hook output: {stdout}"
);
assert!(
!log_dir.join("internal").exists(),
"shared internal log must not create a branch-like internal directory"
);
let diagnostic = parsed["diagnostic"].as_array().unwrap();
assert_eq!(
diagnostic.len(),
1,
"shared internal log must surface as a diagnostic: {stdout}"
);
assert_eq!(diagnostic[0]["file"], "internal-trash-sweep.log");
}
/// Log files that don't match the expected branch subtree layout (`{branch}/{source}/{hook_type}/{name}.log`
/// or `{branch}/internal/{op}.log`) still appear in the JSON listing — just
/// without structured filter fields. Guards the defensive `_ => None` arm in
/// `parse_hook_structure` against future path-layout regressions.
#[rstest]
fn test_logs_get_json_unknown_layout_has_no_structure(repo: TestRepo) {
let log_dir = repo.root_path().join(".git/wt/logs");
// 2-segment layout: branch/file.log (missing source & hook_type).
let relative = PathBuf::from("main").join("stray.log");
write_log_at(&log_dir, &relative, "stray output");
let output = wt_state_cmd(&repo, "logs", "get", &["--format=json"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let hook = &parsed["hook_output"][0];
// Entry appears with `file` and `path`, but structured fields are omitted.
assert_eq!(hook["file"], "main/stray.log");
assert!(hook["branch"].is_null());
assert!(hook["source"].is_null());
assert!(hook["hook_type"].is_null());
assert!(hook["name"].is_null());
}
#[rstest]
fn test_ci_status_get_json(repo: TestRepo) {
let output = wt_state_cmd(&repo, "ci-status", "get", &["--format=json"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @"null");
}
#[rstest]
fn test_ci_status_get_json_with_cached_data(repo: TestRepo) {
repo.commit("initial");
let head = repo.head_sha();
write_ci_cache(
&repo,
"main",
&format!(
r#"{{"status":{{"ci_status":"passed","source":"pr","is_stale":false}},"checked_at":{TEST_EPOCH},"head":"{head}","branch":"main"}}"#
),
);
let output = wt_state_cmd(&repo, "ci-status", "get", &["--format=json"])
.output()
.unwrap();
assert!(output.status.success());
let mut settings = insta::Settings::clone_current();
settings.add_filter(&head, "<SHA>");
settings.bind(|| {
assert_snapshot!(String::from_utf8_lossy(&output.stdout));
});
}
#[rstest]
fn test_marker_get_json_empty(repo: TestRepo) {
let output = wt_state_cmd(&repo, "marker", "get", &["--format=json"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @"null");
}
#[rstest]
fn test_marker_get_json_with_value(repo: TestRepo) {
repo.run_git(&[
"config",
"worktrunk.state.main.marker",
&format!(r#"{{"marker":"🚧 WIP","set_at":{TEST_EPOCH}}}"#),
]);
let output = wt_state_cmd(&repo, "marker", "get", &["--format=json"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @r#"
{
"branch": "main",
"marker": "🚧 WIP",
"set_at": 1735776000
}
"#);
}
#[rstest]
fn test_vars_list_json_empty(repo: TestRepo) {
let output = wt_state_cmd(&repo, "vars", "list", &["--format=json"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @"{}");
}
#[rstest]
fn test_vars_list_json_with_values(repo: TestRepo) {
repo.run_git(&["config", "worktrunk.state.main.vars.env", "staging"]);
repo.run_git(&["config", "worktrunk.state.main.vars.port", "3000"]);
let output = wt_state_cmd(&repo, "vars", "list", &["--format=json"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @r#"
{
"env": "staging",
"port": "3000"
}
"#);
}
#[rstest]
fn test_hints_get_json_empty(repo: TestRepo) {
let output = wt_state_cmd(&repo, "hints", "get", &["--format=json"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @"[]");
}
#[rstest]
fn test_hints_get_json_with_values(repo: TestRepo) {
repo.run_git(&["config", "worktrunk.hints.worktree-path", "true"]);
let output = wt_state_cmd(&repo, "hints", "get", &["--format=json"])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @r#"
[
"worktree-path"
]
"#);
}
// ============================================================================
// --format rejected on write actions (set/clear)
// ============================================================================
/// Build `wt config state <key> [args...]` without injecting an action name.
/// Unlike `wt_state_cmd`, this lets tests pass `--format=json` *before* the
/// action to exercise the `global = true` propagation path that silently
/// accepted the flag prior to gating.
fn wt_state_raw_cmd(repo: &TestRepo, key: &str, args: &[&str]) -> Command {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args(["config", "state", key]);
cmd.args(args);
cmd.current_dir(repo.root_path());
cmd
}
#[rstest]
#[case::logs_clear_flag_after("logs", &["clear", "--format=json"], "clear")]
#[case::logs_clear_flag_before("logs", &["--format=json", "clear"], "clear")]
#[case::hints_clear_flag_after("hints", &["clear", "--format=json"], "clear")]
#[case::hints_clear_flag_before("hints", &["--format=json", "clear"], "clear")]
#[case::marker_set_flag_after("marker", &["set", "foo", "--format=json"], "set")]
#[case::marker_set_flag_before("marker", &["--format=json", "set", "foo"], "set")]
#[case::marker_clear_flag_after("marker", &["clear", "--format=json"], "clear")]
#[case::marker_clear_flag_before("marker", &["--format=json", "clear"], "clear")]
#[case::ci_status_clear_flag_after("ci-status", &["clear", "--format=json"], "clear")]
#[case::ci_status_clear_flag_before("ci-status", &["--format=json", "clear"], "clear")]
fn test_format_rejected_on_write_actions(
repo: TestRepo,
#[case] key: &str,
#[case] args: &[&str],
#[case] action: &str,
) {
let output = wt_state_raw_cmd(&repo, key, args).output().unwrap();
assert!(
!output.status.success(),
"expected failure for {key} {args:?}"
);
assert_eq!(output.status.code(), Some(2));
// Tolerate the ANSI `invalid` styling clap wraps around `--format <FORMAT>`
// and the action name by checking the fixed substrings between/around them.
let stderr = String::from_utf8_lossy(&output.stderr);
for needle in ["--format <FORMAT>", "cannot be used with", action] {
assert!(
stderr.contains(needle),
"stderr missing {needle:?}: {stderr}"
);
}
}
#[rstest]
fn test_format_rejected_on_write_action_writes_verbose_diagnostic(repo: TestRepo) {
let output = repo
.wt_command()
.args([
"-vv",
"config",
"state",
"marker",
"set",
"foo",
"--format=json",
])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("--format <FORMAT>"),
"stderr should include the clap conflict: {stderr}"
);
assert!(
stderr.contains("Diagnostics and performance profile saved"),
"stderr should mention the verbose diagnostic: {stderr}"
);
let diagnostic_path = repo.root_path().join(".git/wt/logs/diagnostic.md");
assert!(
diagnostic_path.exists(),
"diagnostic should be written for post-dispatch clap errors"
);
}
/// `--branch` takes a selector, so `@` means the current branch rather than a
/// state key literally named `@` — the state commands share one vocabulary with
/// the rest of wt instead of storing whatever token was typed.
#[rstest]
fn state_branch_flag_resolves_selectors(mut repo: TestRepo) {
let worktree = repo.add_worktree("feature");
let set = repo
.wt_command()
.current_dir(&worktree)
.args(["config", "state", "marker", "set", "wip", "--branch", "@"])
.output()
.unwrap();
assert!(
set.status.success(),
"setting a marker via @ should succeed: {}",
String::from_utf8_lossy(&set.stderr)
);
// Read it back from elsewhere, naming the same branch by its worktree path.
let get = repo
.wt_command()
.args([
"config",
"state",
"marker",
"get",
"--branch",
worktree.to_str().unwrap(),
])
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&get.stdout).trim(),
"wip",
"the marker set via @ should read back via the worktree's path: {}",
String::from_utf8_lossy(&get.stderr)
);
}