mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
bfbc2e2899
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_
3266 lines
111 KiB
Rust
3266 lines
111 KiB
Rust
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), @"[31m✗[39m [31mCannot determine default branch. To configure, run [1mwt config state default-branch set BRANCH[22m[39m");
|
||
}
|
||
|
||
#[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), @"
|
||
[33m▲[39m [33mBranch [1mdevelop[22m does not exist locally[39m
|
||
[32m✓[39m [32mSet default branch to [1mdevelop[22m[39m
|
||
");
|
||
|
||
// 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), @"[32m✓[39m [32mCleared default branch cache[39m");
|
||
|
||
// 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), @"[2m○[22m 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), @"
|
||
[33m▲[39m [33mwt config state previous-branch is deprecated; use [1mwt config state cache[22m instead[39m
|
||
[32m✓[39m [32mSet previous branch to [1mfeature[22m[39m
|
||
");
|
||
|
||
// 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), @"
|
||
[33m▲[39m [33mwt config state previous-branch is deprecated; use [1mwt config state cache[22m instead[39m
|
||
[32m✓[39m [32mCleared previous branch[39m
|
||
");
|
||
|
||
// 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), @"
|
||
[33m▲[39m [33mwt config state previous-branch is deprecated; use [1mwt config state cache[22m instead[39m
|
||
[2m○[22m 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), @"
|
||
[33m▲[39m [33mwt config state ci-status is deprecated; use [1mwt config state cache[22m instead[39m
|
||
[31m✗[39m [31mNo branch named [1mnonexistent[22m[39m
|
||
[2m↳[22m [2mTo create a new branch, run [4mwt switch --create nonexistent[24m; to list branches, run [4mwt list --branches --remotes[24m[22m
|
||
");
|
||
}
|
||
|
||
/// 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), @"
|
||
[33m▲[39m [33mwt config state ci-status is deprecated; use [1mwt config state cache[22m instead[39m
|
||
[2m○[22m 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), @"
|
||
[33m▲[39m [33mwt config state ci-status is deprecated; use [1mwt config state cache[22m instead[39m
|
||
[32m✓[39m [32mCleared CI cache for [1mmain[22m[39m
|
||
");
|
||
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), @"
|
||
[33m▲[39m [33mwt config state ci-status is deprecated; use [1mwt config state cache[22m instead[39m
|
||
[2m○[22m No CI cache for [1mmain[22m
|
||
");
|
||
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), @"[32m✓[39m [32mSet marker for [1mmain[22m to [1m🚧[22m[39m");
|
||
|
||
// 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), @"[32m✓[39m [32mSet marker for [1mfeature[22m to [1m🔧[22m[39m");
|
||
|
||
// 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), @"[32m✓[39m [32mCleared marker for [1mmain[22m[39m");
|
||
|
||
// 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), @"[32m✓[39m [32mCleared marker for [1mfeature[22m[39m");
|
||
|
||
// 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), @"[32m✓[39m [32mCleared [1m3[22m markers[39m");
|
||
|
||
// 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), @"[2m○[22m 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), @"
|
||
[36mCOMMAND LOG[39m @ <PATH>
|
||
[107m [0m (none)
|
||
|
||
[36mHOOK OUTPUT[39m @ <PATH>
|
||
[107m [0m (none)
|
||
|
||
[36mDIAGNOSTIC[39m @ <PATH>
|
||
[107m [0m (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), @"
|
||
[36mCOMMAND LOG[39m @ <PATH>
|
||
File Size Age
|
||
────────────── ──── ──────
|
||
commands.jsonl <SIZE> <AGE>
|
||
|
||
[36mHOOK OUTPUT[39m @ <PATH>
|
||
File Size Age
|
||
─────────────────────────────── ──── ──────
|
||
bugfix/internal/remove.log <SIZE> <AGE>
|
||
feature/user/post-start/npm.log <SIZE> <AGE>
|
||
|
||
[36mDIAGNOSTIC[39m @ <PATH>
|
||
[107m [0m (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), @"
|
||
[36mCOMMAND LOG[39m @ <PATH>
|
||
[107m [0m (none)
|
||
|
||
[36mHOOK OUTPUT[39m @ <PATH>
|
||
[107m [0m (none)
|
||
|
||
[36mDIAGNOSTIC[39m @ <PATH>
|
||
[107m [0m (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),
|
||
@"[32m✓[39m [32mCleared [1m3[22m log files[39m"
|
||
);
|
||
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), @"[2m○[22m 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), @"[32m✓[39m [32mCleared [1m3[22m log files[39m");
|
||
|
||
// 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), @"[32m✓[39m [32mCleared [1m1[22m log file[39m");
|
||
}
|
||
|
||
// ============================================================================
|
||
// 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), @"[2m○[22m 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), @"
|
||
[32m✓[39m [32mCleared previous branch[39m
|
||
[32m✓[39m [32mCleared [1m1[22m marker[39m
|
||
[32m✓[39m [32mCleared [1m1[22m CI cache entry[39m
|
||
[32m✓[39m [32mCleared [1m1[22m summary cache entry[39m
|
||
[32m✓[39m [32mCleared [1m1[22m git commands cache entry[39m
|
||
[32m✓[39m [32mCleared [1m1[22m variable[39m
|
||
[32m✓[39m [32mCleared [1m1[22m log file[39m
|
||
");
|
||
|
||
// 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), @"[32m✓[39m [32mCleared [1m3[22m trash entries[39m");
|
||
|
||
// 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), @"[2m○[22m 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), @"
|
||
|
||
[36m❯[39m Clear all stored state, including branch markers and vars? [1m[y/N/?][22m [2m○[22m 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), @"
|
||
[36mPREVIOUS BRANCH[39m
|
||
[107m [0m (none)
|
||
|
||
[36mCI STATUS CACHE[39m
|
||
[107m [0m (none)
|
||
|
||
[36mSUMMARY CACHE[39m
|
||
[107m [0m (none)
|
||
|
||
[36mGIT COMMANDS CACHE[39m
|
||
[107m [0m (none)
|
||
|
||
[36mHINTS[39m
|
||
[107m [0m (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), @"[2m○[22m 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, @"
|
||
[36mCI STATUS CACHE[39m
|
||
[107m [0m (no entries)
|
||
[107m [0m 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), @"[32m✓[39m [32mCleared [1m1[22m CI cache entry[39m");
|
||
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), @"
|
||
[32m✓[39m [32mCleared previous branch[39m
|
||
[32m✓[39m [32mCleared [1m1[22m CI cache entry[39m
|
||
[32m✓[39m [32mCleared [1m1[22m summary cache entry[39m
|
||
[32m✓[39m [32mCleared [1m1[22m git commands cache entry[39m
|
||
[32m✓[39m [32mCleared [1m1[22m hint[39m
|
||
");
|
||
}
|
||
|
||
/// `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), @"
|
||
[36mDEFAULT BRANCH[39m
|
||
[107m [0m (none)
|
||
|
||
[36mPREVIOUS BRANCH[39m
|
||
[107m [0m (none)
|
||
|
||
[36mBRANCH MARKERS[39m
|
||
[107m [0m (none)
|
||
|
||
[36mVARS[39m
|
||
[107m [0m (none)
|
||
|
||
[36mCI STATUS CACHE[39m
|
||
[107m [0m (none)
|
||
|
||
[36mSUMMARY CACHE[39m
|
||
[107m [0m (none)
|
||
|
||
[36mGIT COMMANDS CACHE[39m
|
||
[107m [0m (none)
|
||
|
||
[36mHINTS[39m
|
||
[107m [0m (none)
|
||
|
||
[36mCOMMAND LOG[39m @ <PATH>
|
||
[107m [0m (none)
|
||
|
||
[36mHOOK OUTPUT[39m @ <PATH>
|
||
[107m [0m (none)
|
||
|
||
[36mDIAGNOSTIC[39m @ <PATH>
|
||
[107m [0m (none)
|
||
|
||
[36mTRASH[39m @ _REPO_/.git/wt/trash
|
||
[107m [0m (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), @"
|
||
[33m▲[39m [33mwt config state ci-status is deprecated; use [1mwt config state cache[22m instead[39m
|
||
[32m✓[39m [32mCleared [1m2[22m CI cache entries[39m
|
||
");
|
||
}
|
||
|
||
#[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), @"
|
||
[33m▲[39m [33mwt config state ci-status is deprecated; use [1mwt config state cache[22m instead[39m
|
||
[32m✓[39m [32mCleared [1m1[22m CI cache entry[39m
|
||
");
|
||
}
|
||
|
||
#[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), @"
|
||
[33m▲[39m [33mwt config state ci-status is deprecated; use [1mwt config state cache[22m instead[39m
|
||
[32m✓[39m [32mCleared CI cache for [1mfeature[22m[39m
|
||
");
|
||
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), @"
|
||
[33m▲[39m [33mwt config state ci-status is deprecated; use [1mwt config state cache[22m instead[39m
|
||
[2m○[22m No CI cache for [1mfeature[22m
|
||
");
|
||
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), @"[32m✓[39m [32mCleared marker for [1mfeature[22m[39m");
|
||
}
|
||
|
||
#[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), @"[2m○[22m No marker set for [1mfeature[22m");
|
||
}
|
||
|
||
#[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), @"[2m○[22m No marker set for [1mmain[22m");
|
||
}
|
||
|
||
#[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), @"[32m✓[39m [32mCleared [1m1[22m marker[39m");
|
||
}
|
||
|
||
// ============================================================================
|
||
// 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), @"
|
||
[33m▲[39m [33mwt config state hints is deprecated; use [1mwt config state cache[22m instead[39m
|
||
[2m○[22m 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), @"
|
||
[33m▲[39m [33mwt config state hints is deprecated; use [1mwt config state cache[22m instead[39m
|
||
[2m○[22m 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), @"
|
||
[33m▲[39m [33mwt config state hints is deprecated; use [1mwt config state cache[22m instead[39m
|
||
[32m✓[39m [32mCleared [1m2[22m hints[39m
|
||
");
|
||
|
||
// 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), @"
|
||
[33m▲[39m [33mwt config state hints is deprecated; use [1mwt config state cache[22m instead[39m
|
||
[32m✓[39m [32mCleared [1m1[22m hint[39m
|
||
");
|
||
}
|
||
|
||
#[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), @"
|
||
[33m▲[39m [33mwt config state hints is deprecated; use [1mwt config state cache[22m instead[39m
|
||
[32m✓[39m [32mCleared hint [1mworktree-path[22m[39m
|
||
");
|
||
|
||
// 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), @"
|
||
[33m▲[39m [33mwt config state hints is deprecated; use [1mwt config state cache[22m instead[39m
|
||
[2m○[22m Hint [1mnonexistent[22m 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), @"[32m✓[39m [32mSet [1menv[22m for [1mmain[22m[39m");
|
||
|
||
// 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), @"[2m○[22m No variables for [1mmain[22m");
|
||
}
|
||
|
||
#[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), @"[32m✓[39m [32mCleared [1menv[22m for [1mmain[22m[39m");
|
||
|
||
// 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), @"[32m✓[39m [32mCleared [1m2[22m variables for [1mmain[22m[39m");
|
||
|
||
// Verify all gone
|
||
let output = wt_state_cmd(&repo, "vars", "list", &[]).output().unwrap();
|
||
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"[2m○[22m No variables for [1mmain[22m");
|
||
}
|
||
|
||
#[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#"[31m✗[39m [31mInvalid key "foo.bar": keys must contain only letters, digits, and hyphens[39m"#);
|
||
}
|
||
|
||
#[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), @"[2m○[22m No variable [1mnonexistent[22m for [1mmain[22m");
|
||
}
|
||
|
||
#[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), @"[2m○[22m No variables for [1mmain[22m");
|
||
}
|
||
|
||
#[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), @"[32m✓[39m [32mCleared [1menv[22m for [1mfeature[22m[39m");
|
||
}
|
||
|
||
#[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)
|
||
);
|
||
}
|