use crate::common::{TEST_EPOCH, TestRepo, repo, wt_command};
use insta::assert_snapshot;
use rstest::rstest;
use std::process::Command;
use worktrunk::path::sanitize_for_filename;
fn hook_log_filename(branch: &str, source: &str, hook_type: &str, name: &str) -> String {
let safe_branch = sanitize_for_filename(branch);
let safe_name = sanitize_for_filename(name);
format!("{safe_branch}-{source}-{hook_type}-{safe_name}.log")
}
fn internal_log_filename(branch: &str, op: &str) -> String {
let safe_branch = sanitize_for_filename(branch);
format!("{safe_branch}-{op}.log")
}
fn state_get_settings() -> insta::Settings {
let mut settings = insta::Settings::clone_current();
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
}
fn write_ci_cache(repo: &TestRepo, branch: &str, json: &str) {
let git_dir = repo.root_path().join(".git");
let cache_dir = git_dir.join("wt").join("cache").join("ci-status");
std::fs::create_dir_all(&cache_dir).unwrap();
let safe_branch = sanitize_for_filename(branch);
let cache_file = cache_dir.join(format!("{safe_branch}.json"));
std::fs::write(&cache_file, json).unwrap();
}
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
}
#[rstest]
fn test_state_get_default_branch(repo: TestRepo) {
let output = wt_state_cmd(&repo, "default-branch", "get", &[])
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "main");
}
#[rstest]
fn test_state_get_default_branch_no_remote(repo: TestRepo) {
let output = wt_state_cmd(&repo, "default-branch", "get", &[])
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "main");
}
#[rstest]
fn test_state_get_default_branch_fails_when_undetermined(repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
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();
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
");
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) {
repo.setup_remote("main");
let _ = wt_state_cmd(&repo, "default-branch", "get", &[])
.output()
.unwrap();
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");
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) {
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_get_previous_branch(repo: TestRepo) {
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), @"[32m✓[39m [32mSet previous branch to [1mfeature[22m[39m");
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) {
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), @"[32m✓[39m [32mCleared previous branch[39m");
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) {
let output = wt_state_cmd(&repo, "previous-branch", "clear", &[])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"[2m○[22m No previous branch to clear");
}
#[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");
}
#[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());
}
#[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());
}
#[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());
}
#[rstest]
fn test_state_get_ci_status(repo: TestRepo) {
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();
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) {
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), @"
[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
");
}
#[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), @"[2m○[22m No CI cache entries to clear");
}
#[rstest]
fn test_state_clear_ci_status_branch(repo: TestRepo) {
repo.git_command().args([
"config",
"worktrunk.state.main.ci-status",
&format!(r#"{{"status":{{"ci_status":"passed","source":"pull-request","is_stale":false}},"checked_at":{TEST_EPOCH},"head":"abc12345"}}"#),
])
.run()
.unwrap();
let output = wt_state_cmd(&repo, "ci-status", "clear", &[])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"[32m✓[39m [32mCleared CI cache for [1mmain[22m[39m");
}
#[rstest]
fn test_state_clear_ci_status_branch_not_cached(repo: TestRepo) {
let output = wt_state_cmd(&repo, "ci-status", "clear", &[])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"[2m○[22m No CI cache for [1mmain[22m");
}
#[rstest]
fn test_state_get_marker(repo: TestRepo) {
repo.set_marker("main", "🚧");
let output = wt_state_cmd(&repo, "marker", "get", &[]).output().unwrap();
assert!(output.status.success());
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());
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();
repo.set_marker("feature", "🔧");
let output = wt_state_cmd(&repo, "marker", "get", &["--branch", "feature"])
.output()
.unwrap();
assert!(output.status.success());
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");
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");
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) {
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");
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) {
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");
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) {
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");
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");
}
#[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.stderr), @"
[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) {
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"),
"npm output here",
)
.unwrap();
std::fs::write(log_dir.join("bugfix-remove.log"), "remove output").unwrap();
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();
settings.add_filter(r"(?m)\d+[BK]\s+\S+[ \t]*$", "<SIZE> <AGE>");
settings.bind(|| {
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
[36mCOMMAND LOG[39m @ <PATH>
File Size Age
────────────── ──── ──────
commands.jsonl <SIZE> <AGE>
[36mHOOK OUTPUT[39m @ <PATH>
File Size Age
────────────────────────── ──── ──────
bugfix-remove.log <SIZE> <AGE>
feature-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) {
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.stderr), @"
[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) {
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("verbose.log"), "debug output").unwrap();
std::fs::write(log_dir.join("diagnostic.md"), "# Diagnostic Report").unwrap();
std::fs::write(log_dir.join("feature-remove.log"), "remove output").unwrap();
let output = wt_state_cmd(&repo, "logs", "get", &[]).output().unwrap();
assert!(output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("DIAGNOSTIC"), "Expected DIAGNOSTIC heading");
assert!(
stderr.contains("verbose.log"),
"Expected verbose.log in output"
);
assert!(
stderr.contains("diagnostic.md"),
"Expected diagnostic.md in output"
);
let hook_section = stderr
.split("DIAGNOSTIC")
.next()
.unwrap()
.rsplit("HOOK OUTPUT")
.next()
.unwrap();
assert!(
hook_section.contains("feature-remove.log"),
"Expected feature-remove.log in HOOK OUTPUT: {hook_section}"
);
assert!(
!hook_section.contains("verbose.log"),
"verbose.log should not be in HOOK OUTPUT: {hook_section}"
);
}
#[rstest]
fn test_state_clear_logs_includes_diagnostic_files(repo: TestRepo) {
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("verbose.log"), "debug 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 [1m2[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) {
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"), "npm output").unwrap();
std::fs::write(log_dir.join("bugfix-remove.log"), "remove output").unwrap();
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");
assert!(!log_dir.exists());
}
#[rstest]
fn test_state_clear_logs_single_file(repo: TestRepo) {
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-remove.log"), "remove output").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 [1m1[22m log file[39m");
}
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");
cmd.args(["config", "state", "clear"]);
cmd
}
#[rstest]
fn test_state_clear_all_empty(repo: TestRepo) {
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) {
repo.git_command()
.args(["config", "worktrunk.history", "feature"])
.run()
.unwrap();
repo.set_marker("main", "🚧");
write_ci_cache(
&repo,
"feature",
r#"{"checked_at":1704067200,"head":"abc123","branch":"feature"}"#,
);
repo.git_command()
.args(["config", "worktrunk.state.main.vars.env", "staging"])
.run()
.unwrap();
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-remove.log"), "output").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 previous branch[39m
[32m✓[39m [32mCleared [1m1[22m marker[39m
[32m✓[39m [32mCleared [1m1[22m CI cache entry[39m
[32m✓[39m [32mCleared [1m1[22m variable[39m
[32m✓[39m [32mCleared [1m1[22m log file[39m
");
assert!(
repo.git_command()
.args(["config", "--get", "worktrunk.history"])
.run()
.unwrap()
.status
.code()
== Some(1)
); 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"
);
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"
);
assert!(!log_dir.exists());
}
#[rstest]
fn test_state_clear_all_cleans_trash(repo: TestRepo) {
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();
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");
assert!(!trash_dir.exists(), "Trash directory should be cleaned up");
}
#[rstest]
fn test_state_clear_all_nothing_to_clear(repo: TestRepo) {
wt_state_clear_all_cmd(&repo).output().unwrap();
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_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.stderr), @"
[36mDEFAULT BRANCH[39m
[107m [0m main
[36mPREVIOUS BRANCH[39m
[107m [0m (none)
[36mBRANCH MARKERS[39m
[107m [0m (none)
[36mVARS[39m
[107m [0m (none)
[36mCI STATUS 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)
");
});
}
#[rstest]
fn test_state_get_with_ci_entries(repo: TestRepo) {
write_ci_cache(
&repo,
"feature",
&format!(
r#"{{"status":{{"ci_status":"passed","source":"pull-request","is_stale":false}},"checked_at":{TEST_EPOCH},"head":"abc12345def67890","branch":"feature"}}"#
),
);
write_ci_cache(
&repo,
"bugfix",
&format!(
r#"{{"status":{{"ci_status":"failed","source":"branch","is_stale":true}},"checked_at":{TEST_EPOCH},"head":"111222333444555","branch":"bugfix"}}"#
),
);
write_ci_cache(
&repo,
"main",
&format!(
r#"{{"status":null,"checked_at":{TEST_EPOCH},"head":"deadbeef12345678","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.stderr));
});
}
#[rstest]
fn test_state_get_comprehensive(repo: TestRepo) {
repo.git_command()
.args(["config", "worktrunk.history", "feature"])
.run()
.unwrap();
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();
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();
write_ci_cache(
&repo,
"feature",
&format!(
r#"{{"status":{{"ci_status":"passed","source":"pull-request","is_stale":false}},"checked_at":{TEST_EPOCH},"head":"abc12345def67890","branch":"feature"}}"#
),
);
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"), "npm output").unwrap();
std::fs::write(log_dir.join("bugfix-remove.log"), "remove output").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.stderr));
});
}
#[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": "main",
"diagnostic": [],
"hints": [],
"hook_output": [],
"markers": [],
"previous_branch": null,
"vars": []
}
"#);
}
#[rstest]
fn test_state_get_json_comprehensive(repo: TestRepo) {
repo.git_command()
.args(["config", "worktrunk.history", "feature"])
.run()
.unwrap();
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.main.vars.env", "staging"])
.run()
.unwrap();
write_ci_cache(
&repo,
"feature",
&format!(
r#"{{"status":{{"ci_status":"passed","source":"pull-request","is_stale":false}},"checked_at":{TEST_EPOCH},"head":"abc12345def67890","branch":"feature"}}"#
),
);
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": [
{
"branch": "feature",
"checked_at": 1735776000,
"head": "abc12345def67890",
"status": "passed"
}
],
"command_log": [],
"default_branch": "main",
"diagnostic": [],
"hints": [],
"hook_output": [],
"markers": [
{
"branch": "feature",
"marker": "🚧 WIP",
"set_at": 1735776000
}
],
"previous_branch": "feature",
"vars": [
{
"branch": "main",
"key": "env",
"value": "staging"
}
]
}
"#);
}
#[rstest]
fn test_state_get_json_with_logs(repo: TestRepo) {
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"), "npm output").unwrap();
std::fs::write(log_dir.join("bugfix-remove.log"), "remove log output").unwrap();
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();
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()));
}
}
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>",
"size": "<SIZE>"
}
],
"default_branch": "main",
"diagnostic": [],
"hints": [],
"hook_output": [
{
"file": "bugfix-remove.log",
"modified_at": "<MTIME>",
"size": "<SIZE>"
},
{
"file": "feature-post-start-npm.log",
"modified_at": "<MTIME>",
"size": "<SIZE>"
}
],
"markers": [],
"previous_branch": null,
"vars": []
}
"#);
});
}
#[rstest]
fn test_state_clear_ci_status_all_with_entries(repo: TestRepo) {
write_ci_cache(
&repo,
"feature",
&format!(
r#"{{"status":{{"ci_status":"passed","source":"pull-request","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), @"[32m✓[39m [32mCleared [1m2[22m CI cache entries[39m");
}
#[rstest]
fn test_state_clear_ci_status_all_single_entry(repo: TestRepo) {
write_ci_cache(
&repo,
"feature",
&format!(
r#"{{"status":{{"ci_status":"passed","source":"pull-request","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), @"[32m✓[39m [32mCleared [1m1[22m CI cache entry[39m");
}
#[rstest]
fn test_state_clear_ci_status_specific_branch(repo: TestRepo) {
repo.git_command()
.args(["branch", "feature"])
.run()
.unwrap();
repo.git_command().args([
"config",
"worktrunk.state.feature.ci-status",
&format!(r#"{{"status":{{"ci_status":"passed","source":"pull-request","is_stale":false}},"checked_at":{TEST_EPOCH},"head":"abc12345"}}"#),
])
.run()
.unwrap();
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), @"[32m✓[39m [32mCleared CI cache for [1mfeature[22m[39m");
}
#[rstest]
fn test_state_clear_ci_status_specific_branch_not_cached(repo: TestRepo) {
repo.git_command()
.args(["branch", "feature"])
.run()
.unwrap();
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), @"[2m○[22m No CI cache for [1mfeature[22m");
}
#[rstest]
fn test_state_clear_marker_specific_branch(repo: TestRepo) {
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) {
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) {
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) {
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");
}
#[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), @"[2m○[22m No hints have been shown");
}
#[rstest]
fn test_state_hints_get_with_hints(repo: TestRepo) {
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), @"
worktree-path
another-hint
");
}
#[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), @"[2m○[22m No hints to clear");
}
#[rstest]
fn test_state_hints_clear_all(repo: TestRepo) {
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), @"[32m✓[39m [32mCleared [1m2[22m hints[39m");
let output = repo
.git_command()
.args(["config", "--get-regexp", r"^worktrunk\.hints\."])
.run()
.unwrap();
assert!(!output.status.success()); }
#[rstest]
fn test_state_hints_clear_single(repo: TestRepo) {
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), @"[32m✓[39m [32mCleared [1m1[22m hint[39m");
}
#[rstest]
fn test_state_hints_clear_specific(repo: TestRepo) {
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), @"[32m✓[39m [32mCleared hint [1mworktree-path[22m[39m");
let output = repo
.git_command()
.args(["config", "--get", "worktrunk.hints.worktree-path"])
.run()
.unwrap();
assert!(!output.status.success());
let output = repo
.git_command()
.args(["config", "--get", "worktrunk.hints.another-hint"])
.run()
.unwrap();
assert!(output.status.success()); }
#[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), @"[2m○[22m Hint [1mnonexistent[22m was not set");
}
#[rstest]
fn test_state_logs_get_hook_returns_path(repo: TestRepo) {
let git_dir = repo.root_path().join(".git");
let log_dir = git_dir.join("wt/logs");
std::fs::create_dir_all(&log_dir).unwrap();
let filename = hook_log_filename("main", "user", "post-start", "server");
let log_file = log_dir.join(&filename);
std::fs::write(&log_file, "server output here").unwrap();
let output = wt_state_cmd(&repo, "logs", "get", &["--hook=user:post-start:server"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(&filename),
"Expected log path in stdout: {}",
stdout
);
}
#[rstest]
fn test_state_logs_get_hook_project_source(repo: TestRepo) {
let git_dir = repo.root_path().join(".git");
let log_dir = git_dir.join("wt/logs");
std::fs::create_dir_all(&log_dir).unwrap();
let filename = hook_log_filename("main", "project", "post-start", "build");
let log_file = log_dir.join(&filename);
std::fs::write(&log_file, "build output here").unwrap();
let output = wt_state_cmd(&repo, "logs", "get", &["--hook=project:post-start:build"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(&filename),
"Expected log path in stdout: {}",
stdout
);
}
#[rstest]
fn test_state_logs_get_hook_internal_op(repo: TestRepo) {
let git_dir = repo.root_path().join(".git");
let log_dir = git_dir.join("wt/logs");
std::fs::create_dir_all(&log_dir).unwrap();
let filename = internal_log_filename("main", "remove");
let log_file = log_dir.join(&filename);
std::fs::write(&log_file, "remove output").unwrap();
let output = wt_state_cmd(&repo, "logs", "get", &["--hook=internal:remove"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(&filename),
"Expected log path in stdout: {}",
stdout
);
}
#[rstest]
fn test_state_logs_get_hook_not_found(repo: TestRepo) {
let git_dir = repo.root_path().join(".git");
let log_dir = git_dir.join("wt/logs");
std::fs::create_dir_all(&log_dir).unwrap();
let other_filename = hook_log_filename("main", "user", "post-start", "other");
std::fs::write(log_dir.join(&other_filename), "other output").unwrap();
let output = wt_state_cmd(&repo, "logs", "get", &["--hook=user:post-start:server"])
.output()
.unwrap();
assert!(!output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"
[31m✗[39m [31mNo log file matches [1muser:post-start:server[22m for branch [1mmain[22m[39m
[107m [0m Expected: main-vfz-user-post-start-server-f4t.log
[107m [0m Available: main-vfz-user-post-start-other-4n1.log
");
}
#[rstest]
fn test_state_logs_get_hook_no_logs_dir(repo: TestRepo) {
let output = wt_state_cmd(&repo, "logs", "get", &["--hook=user:post-start:server"])
.output()
.unwrap();
assert!(!output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"[31m✗[39m [31mNo log directory exists. Run a background hook first to create logs.[39m");
}
#[rstest]
fn test_state_logs_get_hook_no_logs_for_branch(repo: TestRepo) {
let git_dir = repo.root_path().join(".git");
let log_dir = git_dir.join("wt/logs");
std::fs::create_dir_all(&log_dir).unwrap();
let other_branch_filename = hook_log_filename("other-branch", "user", "post-start", "server");
std::fs::write(log_dir.join(&other_branch_filename), "other output").unwrap();
let output = wt_state_cmd(&repo, "logs", "get", &["--hook=user:post-start:server"])
.output()
.unwrap();
assert!(!output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"[31m✗[39m [31mNo log files for branch [1mmain[22m. Run a background hook first.[39m");
}
#[rstest]
fn test_state_logs_get_hook_with_branch_flag(repo: TestRepo) {
repo.git_command()
.args(["branch", "feature"])
.run()
.unwrap();
let git_dir = repo.root_path().join(".git");
let log_dir = git_dir.join("wt/logs");
std::fs::create_dir_all(&log_dir).unwrap();
let filename = hook_log_filename("feature", "user", "post-start", "dev");
std::fs::write(log_dir.join(&filename), "dev output").unwrap();
let output = wt_state_cmd(
&repo,
"logs",
"get",
&["--hook=user:post-start:dev", "--branch=feature"],
)
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(&filename),
"Expected log path in stdout: {}",
stdout
);
}
#[rstest]
fn test_state_logs_get_hook_invalid_format(repo: TestRepo) {
let output = wt_state_cmd(&repo, "logs", "get", &["--hook=user"])
.output()
.unwrap();
assert!(!output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"[31m✗[39m [31mInvalid log spec: [1muser[22m. Format: source:hook-type:name or internal:op[39m");
}
#[rstest]
fn test_state_logs_get_hook_rejects_colons_in_name(repo: TestRepo) {
let output = wt_state_cmd(&repo, "logs", "get", &["--hook=user:post-start:my:server"])
.output()
.unwrap();
assert!(!output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"[31m✗[39m [31mInvalid log spec: [1muser:post-start:my:server[22m. Format: source:hook-type:name or internal:op[39m");
}
#[rstest]
fn test_state_logs_get_hook_invalid_source(repo: TestRepo) {
let output = wt_state_cmd(&repo, "logs", "get", &["--hook=invalid:post-start:server"])
.output()
.unwrap();
assert!(!output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"[31m✗[39m [31mUnknown source: [1minvalid[22m. Valid: user, project[39m");
}
#[rstest]
fn test_state_logs_get_hook_invalid_hook_type(repo: TestRepo) {
let output = wt_state_cmd(&repo, "logs", "get", &["--hook=user:invalid:server"])
.output()
.unwrap();
assert!(!output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"[31m✗[39m [31mUnknown hook type: [1minvalid[22m. Valid: pre-switch, post-switch, pre-start, post-start, pre-commit, post-commit, pre-merge, post-merge, pre-remove, post-remove[39m");
}
#[rstest]
fn test_vars_set_and_get(repo: TestRepo) {
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");
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());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "");
}
#[rstest]
fn test_vars_list(repo: TestRepo) {
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) {
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");
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) {
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");
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, hyphens, and underscores[39m"#);
}
#[rstest]
fn test_vars_branch_flag(repo: TestRepo) {
repo.run_git(&["branch", "feature"]);
let output = wt_state_cmd(
&repo,
"vars",
"set",
&["env=production", "--branch=feature"],
)
.output()
.unwrap();
assert!(output.status.success());
let output = wt_state_cmd(&repo, "vars", "get", &["env", "--branch=feature"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "production");
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) {
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) {
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());
assert!(items[0].get("vars").is_none());
}
#[rstest]
fn test_vars_clear_nonexistent_key(repo: TestRepo) {
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) {
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) {
repo.run_git(&["branch", "feature"]);
repo.git_command()
.args(["config", "worktrunk.state.feature.vars.env", "production"])
.run()
.unwrap();
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) {
repo.run_git(&["branch", "feature"]);
repo.git_command()
.args(["config", "worktrunk.state.feature.vars.env", "production"])
.run()
.unwrap();
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) {
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");
repo.run_git(&["branch", "featurexauth"]);
repo.git_command()
.args(["config", "worktrunk.state.featurexauth.vars.port", "9999"])
.run()
.unwrap();
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) {
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"
);
}
#[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": []
}
"#);
}
#[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("main-user-post-start-server.log"), "output").unwrap();
let output = wt_state_cmd(&repo, "logs", "get", &["--format=json"])
.output()
.unwrap();
assert!(output.status.success());
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));
});
}
#[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":"pull-request","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"
]
"#);
}