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
}
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: String = branch
.chars()
.map(|c| if c == '/' || c == '\\' { '-' } else { c })
.collect();
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());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Cannot determine default branch"),
"Expected error message about cannot determine default branch, got: {}",
stderr
);
}
#[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_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());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("not found") || stderr.contains("nonexistent"));
}
#[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());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("COMMAND LOG"),
"Expected COMMAND LOG heading: {stderr}"
);
assert!(
stderr.contains("HOOK OUTPUT"),
"Expected HOOK OUTPUT heading: {stderr}"
);
assert!(
stderr.contains(".git/wt/logs") || stderr.contains(".git\\wt\\logs"),
"Expected .git/wt/logs or .git\\wt\\logs in output: {stderr}"
);
}
#[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 stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("COMMAND LOG"),
"Expected COMMAND LOG heading: {stderr}"
);
assert!(
stderr.contains("HOOK OUTPUT"),
"Expected HOOK OUTPUT heading: {stderr}"
);
assert!(stderr.contains("commands.jsonl"));
assert!(stderr.contains("feature-post-start-npm.log"));
assert!(stderr.contains("bugfix-remove.log"));
}
#[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());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("COMMAND LOG"),
"Expected COMMAND LOG heading: {stderr}"
);
assert!(
stderr.contains("HOOK OUTPUT"),
"Expected HOOK OUTPUT heading: {stderr}"
);
let none_count = stderr.matches("(none)").count();
assert_eq!(none_count, 2, "Expected two (none) entries: {stderr}");
}
#[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 all stored state[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_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)
");
});
}
#[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());
let json_str = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(json["default_branch"], "main");
assert_eq!(json["previous_branch"], serde_json::Value::Null);
assert_eq!(json["markers"], serde_json::json!([]));
assert_eq!(json["ci_status"], serde_json::json!([]));
assert_eq!(json["hints"], serde_json::json!([]));
assert_eq!(json["command_log"], serde_json::json!([]));
assert_eq!(json["hook_output"], serde_json::json!([]));
}
#[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());
let json_str = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(json["default_branch"], "main");
assert_eq!(json["previous_branch"], "feature");
let markers = json["markers"].as_array().unwrap();
assert_eq!(markers.len(), 1);
assert_eq!(markers[0]["branch"], "feature");
assert_eq!(markers[0]["marker"], "🚧 WIP");
assert_eq!(markers[0]["set_at"], TEST_EPOCH);
let vars = json["vars"].as_array().unwrap();
assert_eq!(vars.len(), 1);
assert_eq!(vars[0]["branch"], "main");
assert_eq!(vars[0]["key"], "env");
assert_eq!(vars[0]["value"], "staging");
let ci_status = json["ci_status"].as_array().unwrap();
assert_eq!(ci_status.len(), 1);
assert_eq!(ci_status[0]["branch"], "feature");
assert_eq!(ci_status[0]["status"], "passed");
assert_eq!(ci_status[0]["checked_at"], TEST_EPOCH);
assert_eq!(ci_status[0]["head"], "abc12345def67890");
}
#[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 json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let command_log = json["command_log"].as_array().unwrap();
assert_eq!(command_log.len(), 1);
assert_eq!(command_log[0]["file"], "commands.jsonl");
let hook_output = json["hook_output"].as_array().unwrap();
assert_eq!(hook_output.len(), 2);
let log_files: Vec<&str> = hook_output
.iter()
.map(|l| l["file"].as_str().unwrap())
.collect();
assert!(log_files.contains(&"feature-post-start-npm.log"));
assert!(log_files.contains(&"bugfix-remove.log"));
for entry in command_log.iter().chain(hook_output.iter()) {
assert!(entry.get("file").is_some());
assert!(entry.get("size").is_some());
assert!(entry.get("modified_at").is_some());
}
}
#[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());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("worktree-path"));
assert!(stdout.contains("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());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("No log file matches") && stderr.contains("user:post-start:server"),
"Expected spec echo in error: {}",
stderr
);
let expected_filename = hook_log_filename("main", "user", "post-start", "server");
assert!(
stderr.contains(&format!("Expected: {expected_filename}")),
"Expected filename in error: {}",
stderr
);
assert!(
stderr.contains("Available:"),
"Expected list of available logs: {}",
stderr
);
}
#[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());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("No log directory exists"),
"Expected 'No log directory exists' error: {}",
stderr
);
}
#[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());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("No log files for branch"),
"Expected 'No log files for branch' error: {}",
stderr
);
}
#[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());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Invalid log spec"),
"Expected 'Invalid log spec' error: {}",
stderr
);
}
#[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());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Invalid log spec"),
"Colons in hook names should be rejected: {}",
stderr
);
}
#[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());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Unknown source"),
"Expected 'Unknown source' error: {}",
stderr
);
}
#[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());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Unknown hook type"),
"Expected 'Unknown hook type' error: {}",
stderr
);
}
#[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());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Set"), "Expected success message: {stderr}");
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());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("env\tstaging"),
"Expected env key: {stdout}"
);
assert!(stdout.contains("port\t3000"), "Expected port key: {stdout}");
}
#[rstest]
fn test_vars_list_empty(repo: TestRepo) {
let output = wt_state_cmd(&repo, "vars", "list", &[]).output().unwrap();
assert!(output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("No variables"),
"Expected empty message: {stderr}"
);
}
#[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());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Cleared"),
"Expected clear message: {stderr}"
);
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());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Cleared") && stderr.contains("2"),
"Expected clear 2 entries: {stderr}"
);
let output = wt_state_cmd(&repo, "vars", "list", &[]).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("No variables"));
}
#[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());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Invalid key"),
"Expected invalid key error: {stderr}"
);
}
#[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());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("No variable"),
"Expected 'No variable' message: {stderr}"
);
}
#[rstest]
fn test_vars_clear_all_empty(repo: TestRepo) {
let output = wt_state_cmd(&repo, "vars", "clear", &["--all"])
.output()
.unwrap();
assert!(output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("No variables"),
"Expected 'No variables' message: {stderr}"
);
}
#[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());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("env\tproduction"),
"Expected vars entry: {stdout}"
);
}
#[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());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Cleared"),
"Expected clear message: {stderr}"
);
}
#[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"
);
}