mod common;
use assert_cmd::Command;
use common::init_repo;
use predicates::prelude::*;
use std::fs;
use std::path::{Path, PathBuf};
#[test]
fn help_prints_subcommands() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.arg("--help");
cmd
.assert()
.success()
.stdout(predicate::str::contains(" init "))
.stdout(predicate::str::contains(" list "))
.stdout(predicate::str::contains(" create "))
.stdout(predicate::str::contains(" new "))
.stdout(predicate::str::contains(" pr "))
.stdout(predicate::str::contains(" review "))
.stdout(predicate::str::contains(" path "))
.stdout(predicate::str::contains("[aliases: cd]"))
.stdout(predicate::str::contains(" bootstrap "))
.stdout(predicate::str::contains(" sync "))
.stdout(predicate::str::contains(" prune "))
.stdout(predicate::str::contains(" completions "))
.stdout(predicate::str::contains(" shell-init "))
.stdout(predicate::str::contains(" switch "))
.stdout(predicate::str::contains(" tmux "))
.stdout(predicate::str::contains(" zellij "))
.stdout(predicate::str::contains(" doctor "))
.stdout(predicate::str::contains(" link "))
.stdout(predicate::str::contains(" unlink "))
.stdout(predicate::str::contains(" open "))
.stdout(predicate::str::contains(" status "))
.stdout(predicate::str::contains(" labels "))
.stdout(predicate::str::contains(" milestones "))
.stdout(predicate::str::contains(" trust "))
.stdout(predicate::str::contains(" aliases "))
.stdout(predicate::str::contains(" config "))
.stdout(predicate::str::contains(" commit-prefix "))
.stdout(predicate::str::contains(" hooks "))
.stdout(predicate::str::contains(" undo "))
.stdout(predicate::str::contains(" history "))
.stdout(predicate::str::contains(" tui "))
.stdout(predicate::str::contains(" daemon "))
.stdout(predicate::str::contains(" statusline "))
.stdout(predicate::str::contains(" exec "))
.stdout(predicate::str::contains(" clean "));
}
#[test]
fn commit_prefix_resolves_branch_to_shortcode_form() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["commit-prefix", "--branch", "feat/#41-foo"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains(":sparkles: feat(#41):"));
}
#[test]
fn commit_prefix_unicode_emits_real_emoji() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["commit-prefix", "--branch", "feat/#41-foo", "--unicode"]);
cmd.assert().success().stdout(predicate::str::contains("✨ feat(#41):"));
}
#[test]
fn commit_prefix_for_fix_branch_uses_bug_emoji() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["commit-prefix", "--branch", "fix/#10-bar"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains(":bug: fix(#10):"));
}
#[test]
fn commit_prefix_with_explicit_branch_honours_repo_gitmoji_overrides() {
let (dir, _repo) = init_repo();
let cfg = "[gitmoji]\nfeat = \":rocket:\"\n";
std::fs::write(dir.path().join(".gwm.toml"), cfg).expect("seed .gwm.toml");
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["commit-prefix", "--branch", "feat/#41-foo"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains(":rocket: feat(#41):"));
}
#[test]
fn commit_prefix_on_non_gwm_branch_reports_error() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["commit-prefix", "--branch", "random"]);
cmd.assert().failure().stderr(predicate::str::contains("random"));
}
#[test]
fn commit_prefix_unicode_normalizes_known_shortcode_override() {
let (dir, _repo) = init_repo();
std::fs::write(dir.path().join(".gwm.toml"), "[gitmoji]\nfeat = \":rocket:\"\n").expect("seed .gwm.toml");
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["commit-prefix", "--branch", "feat/#1-x", "--unicode"]);
cmd.assert().success().stdout(predicate::str::contains("🚀 feat(#1):"));
}
#[test]
fn commit_prefix_unicode_leaves_unknown_shortcode_override_verbatim() {
let (dir, _repo) = init_repo();
std::fs::write(dir.path().join(".gwm.toml"), "[gitmoji]\nfeat = \":foo:\"\n").expect("seed .gwm.toml");
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["commit-prefix", "--branch", "feat/#1-x", "--unicode"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains(":foo: feat(#1):"));
}
#[test]
fn types_gitmoji_normalizes_known_shortcode_override_in_unicode_column() {
let (dir, _repo) = init_repo();
std::fs::write(dir.path().join(".gwm.toml"), "[gitmoji]\nfeat = \":rocket:\"\n").expect("seed .gwm.toml");
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.current_dir(dir.path()).args(["types", "--gitmoji"]);
let assert = cmd.assert().success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout");
let feat_row = stdout
.lines()
.find(|l| l.trim_start().starts_with("feat "))
.expect("feat row must be present in `gwm types --gitmoji` output");
assert!(
feat_row.contains("🚀"),
"feat unicode column must be normalised to 🚀, got: {feat_row:?}"
);
assert!(
feat_row.contains(":rocket:"),
"feat shortcode column must still be :rocket:, got: {feat_row:?}"
);
}
#[test]
fn types_with_gitmoji_flag_includes_emoji_columns() {
let dir = tempfile::TempDir::new().unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.current_dir(dir.path()).args(["types", "--gitmoji"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("feat"))
.stdout(predicate::str::contains(":sparkles:"))
.stdout(predicate::str::contains("✨"))
.stdout(predicate::str::contains(":bug:"))
.stdout(predicate::str::contains("🐛"));
}
#[test]
fn types_without_gitmoji_flag_does_not_include_emoji_columns() {
let dir = tempfile::TempDir::new().unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.current_dir(dir.path()).arg("types");
cmd
.assert()
.success()
.stdout(predicate::str::contains("feat"))
.stdout(predicate::str::contains(":sparkles:").not())
.stdout(predicate::str::contains("✨").not());
}
#[test]
fn hooks_install_commit_msg_creates_executable_hook() {
let (dir, _repo) = init_repo();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.current_dir(dir.path()).args(["hooks", "install", "commit-msg"]);
cmd.assert().success();
let hook = dir.path().join(".git").join("hooks").join("commit-msg");
assert!(hook.exists(), "commit-msg hook must exist after `gwm hooks install`");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&hook).expect("stat hook").permissions().mode();
assert!(mode & 0o100 != 0, "hook must be executable by owner");
}
}
#[test]
fn hooks_install_commit_msg_refuses_existing_hook_without_force() {
let (dir, _repo) = init_repo();
let hooks_dir = dir.path().join(".git").join("hooks");
std::fs::create_dir_all(&hooks_dir).expect("hooks dir");
let hook_path = hooks_dir.join("commit-msg");
std::fs::write(&hook_path, "#!/bin/sh\necho 'pre-existing hook'\n").expect("seed hook");
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.current_dir(dir.path()).args(["hooks", "install", "commit-msg"]);
cmd.assert().failure().stderr(predicate::str::contains("--force"));
let body = std::fs::read_to_string(&hook_path).expect("read seeded hook");
assert!(
body.contains("pre-existing hook"),
"seeded hook must not be overwritten on the refusal path"
);
}
#[test]
fn hooks_install_commit_msg_force_overwrites() {
let (dir, _repo) = init_repo();
let hooks_dir = dir.path().join(".git").join("hooks");
std::fs::create_dir_all(&hooks_dir).expect("hooks dir");
let hook_path = hooks_dir.join("commit-msg");
std::fs::write(&hook_path, "#!/bin/sh\necho 'old'\n").expect("seed hook");
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["hooks", "install", "commit-msg", "--force"]);
cmd.assert().success();
let body = std::fs::read_to_string(&hook_path).expect("read installed hook");
assert!(
body.contains("gwm commit-msg hook"),
"installed hook must carry the gwm marker; got {:?}",
body
);
}
#[test]
fn labels_help_lists_list_and_push() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["labels", "--help"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("list"))
.stdout(predicate::str::contains("push"));
}
#[test]
fn labels_list_outside_git_repo_fails() {
let dir = tempfile::TempDir::new().unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["labels", "list"])
.assert()
.failure()
.stderr(predicate::str::contains("not inside a git repository"));
}
#[test]
fn labels_list_with_no_declared_labels_is_a_no_op() {
let (dir, _repo) = init_repo();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["labels", "list"])
.assert()
.success()
.stdout(predicate::str::contains("0 labels declared"));
}
#[test]
fn sync_unknown_pattern_errors() {
let (dir, _repo) = init_repo();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["sync", "does-not-exist"])
.assert()
.failure()
.stderr(predicate::str::contains("does-not-exist"));
}
#[test]
fn sync_in_repo_without_upstream_reports_missing_upstream() {
let (dir, _repo) = init_repo();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.arg("sync")
.assert()
.failure()
.stderr(predicate::str::contains("upstream"));
}
#[test]
fn sync_from_subdir_names_the_worktree_root_not_the_subdir() {
use std::process::Command as Git;
fn git(dir: &Path, args: &[&str]) {
let out = Git::new("git")
.arg("-C")
.arg(dir)
.args(["-c", "commit.gpgsign=false"])
.args(args)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t")
.output()
.unwrap();
assert!(
out.status.success(),
"git {:?}: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
let td = tempfile::TempDir::new().unwrap();
let origin = td.path().join("origin");
let wt = td.path().join("my-worktree");
std::fs::create_dir_all(&origin).unwrap();
Git::new("git")
.args(["init", "--bare", "-b", "main"])
.arg(&origin)
.output()
.unwrap();
Git::new("git").args(["init", "-b", "main"]).arg(&wt).output().unwrap();
git(&wt, &["config", "user.email", "t@t"]);
git(&wt, &["config", "user.name", "t"]);
std::fs::write(wt.join("file.txt"), "base\n").unwrap();
git(&wt, &["add", "-A"]);
git(&wt, &["commit", "-m", "init"]);
git(&wt, &["remote", "add", "origin", origin.to_str().unwrap()]);
git(&wt, &["push", "-u", "origin", "main"]);
let sub = wt.join("src/deep");
std::fs::create_dir_all(&sub).unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(&sub)
.arg("sync")
.assert()
.success()
.stdout(predicate::str::contains("my-worktree"))
.stdout(predicate::str::contains("deep").not());
}
#[test]
fn labels_push_with_no_declared_labels_is_a_no_op() {
let (dir, _repo) = init_repo();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["labels", "push"])
.assert()
.success()
.stdout(predicate::str::contains("0 labels declared"));
}
#[test]
fn labels_push_dry_run_with_no_declared_labels_succeeds() {
let (dir, _repo) = init_repo();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["labels", "push", "--dry-run"])
.assert()
.success()
.stdout(predicate::str::contains("0 labels declared"));
}
#[test]
fn labels_list_surfaces_invalid_color_with_label_name() {
let (dir, _repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
r#"
[[labels]]
name = "bug"
color = "not-a-hex"
"#,
)
.unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["labels", "list"])
.assert()
.failure()
.stderr(predicate::str::contains("bug"))
.stderr(predicate::str::contains("not-a-hex"));
}
#[test]
fn milestones_help_lists_list_and_push() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["milestones", "--help"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("list"))
.stdout(predicate::str::contains("push"));
}
#[test]
fn milestones_list_outside_git_repo_fails() {
let dir = tempfile::TempDir::new().unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["milestones", "list"])
.assert()
.failure()
.stderr(predicate::str::contains("not inside a git repository"));
}
#[test]
fn milestones_list_with_no_declared_is_a_no_op() {
let (dir, _repo) = init_repo();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["milestones", "list"])
.assert()
.success()
.stdout(predicate::str::contains("0 milestones declared"));
}
#[test]
fn milestones_push_with_no_declared_is_a_no_op() {
let (dir, _repo) = init_repo();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["milestones", "push"])
.assert()
.success()
.stdout(predicate::str::contains("0 milestones declared"));
}
#[test]
fn milestones_push_dry_run_with_no_declared_succeeds() {
let (dir, _repo) = init_repo();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["milestones", "push", "--dry-run"])
.assert()
.success()
.stdout(predicate::str::contains("0 milestones declared"));
}
#[test]
fn milestones_list_surfaces_invalid_due_on_with_title() {
let (dir, _repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
r#"
[[milestones]]
title = "v0.7.0"
due_on = "not-a-date"
"#,
)
.unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["milestones", "list"])
.assert()
.failure()
.stderr(predicate::str::contains("v0.7.0"));
}
#[test]
fn milestones_list_surfaces_invalid_state_with_title() {
let (dir, _repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
r#"
[[milestones]]
title = "v0.7.0"
state = "draft"
"#,
)
.unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["milestones", "list"])
.assert()
.failure()
.stderr(predicate::str::contains("v0.7.0"))
.stderr(predicate::str::contains("draft"));
}
#[test]
fn switch_alias_s_resolves_to_switch() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["s", "--help"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("Open an interactive picker"));
}
#[test]
fn top_level_help_advertises_switch_alias() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.arg("--help");
cmd
.assert()
.success()
.stdout(predicate::str::contains("switch").and(predicate::str::contains("[aliases: s]")));
}
#[test]
fn switch_outside_git_repo_fails() {
let dir = tempfile::TempDir::new().unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.arg("switch")
.assert()
.failure()
.stderr(predicate::str::contains("not inside a git repository"));
}
#[test]
fn doctor_on_fresh_repo_prints_checks() {
let (dir, _repo) = init_repo();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.arg("doctor")
.assert()
.code(predicate::in_iter([0_i32, 1]))
.stdout(predicate::str::contains("✓"))
.stdout(predicate::str::contains(".gwm.toml"))
.stdout(predicate::str::contains("base directory writable"));
}
#[test]
fn doctor_outside_git_repo_fails() {
let dir = tempfile::TempDir::new().unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.arg("doctor")
.assert()
.failure()
.stderr(predicate::str::contains("not inside a git repository"));
}
#[test]
fn doctor_exits_one_when_review_binary_missing() {
let (dir, _repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
r#"
[review]
command = "definitely-not-on-path-review-cli {base}..{head}"
"#,
)
.unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.arg("doctor")
.assert()
.code(1)
.stdout(predicate::str::contains("definitely-not-on-path-review-cli"));
}
#[test]
fn doctor_exits_two_on_invalid_config() {
let (dir, _repo) = init_repo();
std::fs::write(dir.path().join(".gwm.toml"), "broken = [unterminated").unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.arg("doctor")
.assert()
.code(2)
.stdout(predicate::str::contains("✗"));
}
#[test]
fn version_flag() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.arg("--version");
cmd.assert().success().stdout(predicate::str::contains("gwm"));
}
#[test]
fn types_lists_branch_types() {
let dir = tempfile::TempDir::new().unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.current_dir(dir.path()).arg("types");
cmd
.assert()
.success()
.stdout(predicate::str::contains("feat"))
.stdout(predicate::str::contains("fix"))
.stdout(predicate::str::contains("hotfix"))
.stdout(predicate::str::contains("chore"))
.stdout(predicate::str::contains("(source: built-in defaults)"));
}
#[test]
fn types_lists_configured_branch_types_from_dot_gwm_toml() {
let (dir, _repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
r#"
[[branch_types]]
name = "feat"
description = "Feature"
[[branch_types]]
name = "migration"
description = "Database migration"
"#,
)
.unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.current_dir(dir.path()).arg("types");
cmd
.assert()
.success()
.stdout(predicate::str::contains("feat"))
.stdout(predicate::str::contains("migration"))
.stdout(predicate::str::contains("Database migration"))
.stdout(predicate::str::contains("(source: .gwm.toml)"))
.stdout(predicate::str::contains("hotfix").not());
}
#[test]
fn types_inside_bare_repo_falls_back_to_built_in_defaults() {
let dir = tempfile::TempDir::new().unwrap();
git2::Repository::init_bare(dir.path()).expect("init bare repo");
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.current_dir(dir.path()).arg("types");
cmd
.assert()
.success()
.stdout(predicate::str::contains("feat"))
.stdout(predicate::str::contains("hotfix"))
.stdout(predicate::str::contains("(source: built-in defaults)"));
}
#[test]
fn create_outside_git_repo_fails() {
let dir = tempfile::TempDir::new().unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.current_dir(dir.path()).arg("list");
cmd
.assert()
.failure()
.stderr(predicate::str::contains("not inside a git repository"));
}
#[test]
fn list_detect_pr_flag_adds_pr_column_with_detected_number() {
let (dir, repo) = init_repo();
repo.remote("origin", "https://github.com/kbrdn1/gwm-cli.git").unwrap();
let fake_bin = tempfile::TempDir::new().unwrap();
let fake_gh = write_dispatch_gh(fake_bin.path(), r#"[{"number":128}]"#, r#"{}"#);
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_GH", &fake_gh)
.env("PATH", prepend_path(fake_bin.path()))
.args(["list", "--detect-pr"])
.assert()
.success()
.stdout(predicate::str::contains("PR"))
.stdout(predicate::str::contains("#128"));
}
#[test]
fn list_without_detect_pr_flag_has_no_pr_column() {
let (dir, repo) = init_repo();
repo.remote("origin", "https://github.com/kbrdn1/gwm-cli.git").unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["list"])
.assert()
.success()
.stdout(predicate::str::contains("STATUS"))
.stdout(predicate::str::contains("PATH"))
.stdout(predicate::str::contains('#').not());
}
#[test]
fn completions_zsh_emits_compdef_header() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["completions", "zsh"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("#compdef gwm"))
.stdout(predicate::str::contains("_gwm"));
}
#[test]
fn completions_bash_emits_complete_directive() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["completions", "bash"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("_gwm"))
.stdout(predicate::str::contains("complete "));
}
#[test]
fn completions_fish_emits_complete_directive() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["completions", "fish"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("complete -c gwm"));
}
#[test]
fn completions_powershell_emits_register_block() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["completions", "powershell"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("Register-ArgumentCompleter"))
.stdout(predicate::str::contains("gwm"));
}
#[test]
fn completions_rejects_unknown_shell() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["completions", "tcsh"]);
cmd.assert().failure();
}
#[test]
fn list_format_names_emits_one_name_per_line() {
let (dir, _repo) = init_repo();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["list", "--format=names"])
.assert()
.success()
.stdout(predicate::str::contains("NAME").not())
.stdout(predicate::str::contains("STATUS").not())
.stdout(predicate::str::is_empty());
}
#[test]
fn list_format_json_emits_a_parseable_worktree_array() {
let (dir, _repo) = init_repo();
let out = Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["list", "--format=json"])
.output()
.unwrap();
assert!(out.status.success(), "list --format=json must exit 0");
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout must be valid JSON");
let arr = v.as_array().expect("top level must be a JSON array");
assert_eq!(arr.len(), 1, "a fresh repo has exactly the main worktree");
let main = &arr[0];
assert_eq!(main["is_main"], serde_json::json!(true));
assert!(main.get("name").is_some());
assert!(main.get("path").is_some());
assert!(main.get("status").is_some());
assert!(main["status"].get("is_dirty").is_some());
assert_eq!(main["pr"], serde_json::Value::Null);
}
#[test]
fn list_format_json_with_detect_pr_populates_the_pr_field() {
let (dir, repo) = init_repo();
repo.remote("origin", "https://github.com/kbrdn1/gwm-cli.git").unwrap();
let fake_bin = tempfile::TempDir::new().unwrap();
let fake_gh = write_dispatch_gh(fake_bin.path(), r#"[{"number":128}]"#, r#"{}"#);
let out = Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_GH", &fake_gh)
.env("PATH", prepend_path(fake_bin.path()))
.args(["list", "--format=json", "--detect-pr"])
.output()
.unwrap();
assert!(out.status.success());
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid JSON");
let arr = v.as_array().unwrap();
assert!(
arr.iter().any(|w| w["pr"] == serde_json::json!(128)),
"detected PR number must surface in the JSON `pr` field, got: {v}"
);
}
#[test]
fn list_format_json_detect_pr_keeps_explicit_link_when_detection_cannot_run() {
let (dir, repo) = init_repo();
let head = repo.head().unwrap();
let branch = head.shorthand().unwrap().to_string();
repo
.config()
.unwrap()
.set_i64(&format!("branch.{branch}.gwm-pr"), 77)
.unwrap();
let out = Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["list", "--format=json", "--detect-pr"])
.output()
.unwrap();
assert!(out.status.success());
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid JSON");
let arr = v.as_array().unwrap();
assert!(
arr.iter().any(|w| w["pr"] == serde_json::json!(77)),
"explicit PR link must be preserved when detection can't run, got: {v}"
);
}
#[test]
fn list_format_json_detect_pr_clears_a_stale_persisted_pr() {
let (dir, repo) = init_repo();
repo.remote("origin", "https://github.com/kbrdn1/gwm-cli.git").unwrap();
let head = repo.head().unwrap();
let branch = head.shorthand().unwrap().to_string();
repo
.config()
.unwrap()
.set_i64(&format!("branch.{branch}.gwm-pr-detected"), 99)
.unwrap();
let fake_bin = tempfile::TempDir::new().unwrap();
let fake_gh = write_dispatch_gh(fake_bin.path(), "[]", "{}");
let out = Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_GH", &fake_gh)
.env("PATH", prepend_path(fake_bin.path()))
.args(["list", "--format=json", "--detect-pr"])
.output()
.unwrap();
assert!(out.status.success());
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid JSON");
let arr = v.as_array().unwrap();
assert!(
arr.iter().all(|w| w["pr"] != serde_json::json!(99)),
"a stale persisted PR must be cleared once detection runs, got: {v}"
);
}
#[test]
fn doctor_format_json_emits_checks_severity_and_exit_code() {
let (dir, _repo) = init_repo();
let out = Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["doctor", "--format=json"])
.output()
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("doctor json must parse");
assert!(v["checks"].is_array(), "checks must be an array");
assert!(
!v["checks"].as_array().unwrap().is_empty(),
"doctor runs at least one check"
);
let sev = v["severity"].as_str().expect("severity must be a string");
assert!(
matches!(sev, "ok" | "warning" | "failed"),
"severity stable enum, got {sev}"
);
let json_code = v["exit_code"].as_i64().expect("exit_code is an integer");
let proc_code = out.status.code().unwrap_or(-1) as i64;
assert_eq!(json_code, proc_code, "json exit_code must equal the process exit code");
let first = &v["checks"][0];
assert!(first.get("name").is_some());
let cs = first["status"].as_str().unwrap();
assert!(matches!(cs, "ok" | "warning" | "failed"));
}
#[test]
fn path_format_json_emits_name_path_branch() {
let (dir, _repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
write_test_config(dir.path(), base.path());
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["create", "feat", "38", "json-path"])
.assert()
.success();
let out = Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["path", "json-path", "--format=json"])
.output()
.unwrap();
assert!(out.status.success(), "path --format=json must exit 0");
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("path json must parse");
let obj = v.as_object().expect("path json must be an object");
assert_eq!(obj.len(), 3, "exactly {{name, path, branch}}");
assert_eq!(v["name"], serde_json::json!("feat-38-json-path"));
assert_eq!(v["branch"], serde_json::json!("feat/#38-json-path"));
assert!(
v["path"].as_str().unwrap().ends_with("feat-38-json-path"),
"path points at the worktree dir"
);
}
#[test]
fn daemon_rejects_zero_poll_ms() {
Command::cargo_bin("gwm")
.unwrap()
.args(["daemon", "--poll-ms", "0"])
.assert()
.failure()
.stderr(
predicate::str::contains("0").and(predicate::str::contains("not in").or(predicate::str::contains("invalid"))),
);
}
#[test]
fn statusline_without_a_daemon_prints_blank_and_exits_zero() {
let sock_dir = tempfile::TempDir::new().unwrap();
let missing = sock_dir.path().join("no-daemon.sock");
Command::cargo_bin("gwm")
.unwrap()
.arg("statusline")
.arg("--socket")
.arg(&missing)
.assert()
.success()
.stdout(predicate::str::contains(" wt").not());
}
#[test]
fn statusline_help_documents_watch_and_socket() {
Command::cargo_bin("gwm")
.unwrap()
.args(["statusline", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--watch"))
.stdout(predicate::str::contains("--socket"));
}
#[test]
fn cd_unknown_pattern_fails_with_not_found() {
let (dir, _repo) = init_repo();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["cd", "nope"])
.assert()
.failure()
.stderr(predicate::str::contains("not found"));
}
#[test]
fn cd_outside_git_repo_fails() {
let dir = tempfile::TempDir::new().unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["cd", "anything"])
.assert()
.failure()
.stderr(predicate::str::contains("not inside a git repository"));
}
#[test]
fn shell_init_bash_emits_gcd_function() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["shell-init", "bash"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("function gcd"))
.stdout(predicate::str::contains("gwm cd \"$@\""));
}
#[test]
fn shell_init_zsh_emits_gcd_function() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["shell-init", "zsh"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("function gcd"))
.stdout(predicate::str::contains("gwm cd \"$@\""));
}
#[test]
fn shell_init_posix_unaliases_gcd_first() {
for shell in ["bash", "zsh"] {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["shell-init", shell]);
cmd.assert().success().stdout(predicate::str::contains("unalias gcd"));
}
}
#[test]
fn shell_init_powershell_unaliases_gcd_first() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["shell-init", "powershell"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("Remove-Alias"))
.stdout(predicate::str::contains("gcd"));
}
#[test]
fn shell_init_fish_emits_function_block() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["shell-init", "fish"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("function gcd"))
.stdout(predicate::str::contains("gwm cd $argv"))
.stdout(predicate::str::contains("end"));
}
#[test]
fn shell_init_fish_quotes_target_with_double_dash() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["shell-init", "fish"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("cd -- \"$target\""));
}
#[test]
fn shell_init_powershell_emits_function_block() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["shell-init", "powershell"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("function gcd"))
.stdout(predicate::str::contains("gwm cd $Pattern"))
.stdout(predicate::str::contains("Set-Location"));
}
#[test]
fn shell_init_posix_no_arg_invokes_switch() {
for shell in ["bash", "zsh"] {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["shell-init", shell]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("gwm switch"))
.stdout(predicate::str::contains("\"$#\" -eq 0"));
}
}
#[test]
fn shell_init_fish_no_arg_invokes_switch() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["shell-init", "fish"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("gwm switch"))
.stdout(predicate::str::contains("count $argv) -eq 0"));
}
#[test]
fn shell_init_powershell_no_arg_invokes_switch() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["shell-init", "powershell"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("gwm switch"))
.stdout(predicate::str::contains("IsNullOrEmpty"));
}
#[test]
fn shell_init_posix_header_documents_no_arg_route() {
for shell in ["bash", "zsh"] {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["shell-init", shell]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("picker via `gwm switch`"));
}
}
#[test]
fn shell_init_fish_header_documents_no_arg_route() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["shell-init", "fish"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("picker via `gwm switch`"));
}
#[test]
fn shell_init_powershell_header_documents_no_arg_route() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["shell-init", "powershell"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("picker via `gwm switch`"));
}
#[test]
fn switch_help_mentions_gcd_wrapper() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["switch", "--help"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("gcd").and(predicate::str::contains("shell-init")));
}
#[test]
fn shell_init_rejects_unknown_shell() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["shell-init", "tcsh"]);
cmd.assert().failure();
}
#[test]
fn list_format_table_is_default() {
let (dir, _repo) = init_repo();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("NAME"))
.stdout(predicate::str::contains("STATUS"));
}
#[test]
fn tmux_outside_tmux_session_fails_with_clear_error() {
let (dir, _repo) = init_repo();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.env_remove("TMUX")
.args(["tmux", "anything"])
.assert()
.failure()
.stderr(predicate::str::contains("tmux").and(predicate::str::contains("not")));
}
#[test]
fn zellij_outside_zellij_session_fails_with_clear_error() {
let (dir, _repo) = init_repo();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.env_remove("ZELLIJ")
.args(["zellij", "anything"])
.assert()
.failure()
.stderr(predicate::str::contains("zellij").and(predicate::str::contains("not")));
}
#[test]
fn tmux_outside_git_repo_fails() {
let dir = tempfile::TempDir::new().unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.env_remove("TMUX")
.args(["tmux", "anything"])
.assert()
.failure()
.stderr(predicate::str::contains("not inside a git repository"));
}
#[test]
fn zellij_outside_git_repo_fails() {
let dir = tempfile::TempDir::new().unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.env_remove("ZELLIJ")
.args(["zellij", "anything"])
.assert()
.failure()
.stderr(predicate::str::contains("not inside a git repository"));
}
#[test]
fn tmux_outside_tmux_error_does_not_escape_dollar() {
let (dir, _repo) = init_repo();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.env_remove("TMUX")
.args(["tmux", "anything"])
.assert()
.failure()
.stderr(predicate::str::contains("\\$").not())
.stderr(predicate::str::contains("$TMUX"));
}
#[test]
fn zellij_outside_zellij_error_does_not_escape_dollar() {
let (dir, _repo) = init_repo();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.env_remove("ZELLIJ")
.args(["zellij", "anything"])
.assert()
.failure()
.stderr(predicate::str::contains("\\$").not())
.stderr(predicate::str::contains("$ZELLIJ"));
}
#[test]
fn tmux_help_mentions_split_flag() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["tmux", "--help"]);
cmd.assert().success().stdout(predicate::str::contains("--split"));
}
#[test]
fn zellij_help_mentions_split_flag() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["zellij", "--help"]);
cmd.assert().success().stdout(predicate::str::contains("--split"));
}
#[test]
fn link_help_documents_issue_and_pr_targets() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["link", "--help"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("issue"))
.stdout(predicate::str::contains("pr"));
}
#[test]
fn unlink_help_documents_issue_and_pr_targets() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["unlink", "--help"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("issue"))
.stdout(predicate::str::contains("pr"));
}
#[test]
fn open_help_documents_issue_and_pr_and_print_url() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["open", "--help"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("issue"))
.stdout(predicate::str::contains("pr"))
.stdout(predicate::str::contains("--print-url"));
}
#[test]
fn status_help_documents_json_flag() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["status", "--help"]);
cmd.assert().success().stdout(predicate::str::contains("--json"));
}
#[test]
fn link_outside_git_repo_fails() {
let dir = tempfile::TempDir::new().unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.args(["link", "issue", "42"])
.assert()
.failure()
.stderr(predicate::str::contains("not inside a git repository"));
}
#[test]
fn link_issue_persists_and_status_reflects_it() {
let (dir, repo) = init_repo();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feat/#42-tui-search", &head, false).unwrap();
repo.set_head("refs/heads/feat/#42-tui-search").unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["link", "issue", "99"])
.assert()
.success()
.stdout(predicate::str::contains("issue #99"));
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["status", "--json"])
.assert()
.success()
.stdout(predicate::str::contains("\"issue\""))
.stdout(predicate::str::contains("99"));
}
#[test]
fn unlink_issue_falls_back_to_branch_name_auto_detect() {
let (dir, repo) = init_repo();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feat/#42-tui-search", &head, false).unwrap();
repo.set_head("refs/heads/feat/#42-tui-search").unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["link", "issue", "99"])
.assert()
.success();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["unlink", "issue"])
.assert()
.success();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["status", "--json"])
.assert()
.success()
.stdout(predicate::str::contains("42"));
}
#[test]
fn status_auto_detects_pr_when_none_explicitly_linked() {
let (dir, repo) = init_repo();
repo.remote("origin", "https://github.com/kbrdn1/gwm-cli.git").unwrap();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("detect-me", &head, false).unwrap();
repo.set_head("refs/heads/detect-me").unwrap();
let fake_bin = tempfile::TempDir::new().unwrap();
let fake_gh = write_dispatch_gh(
fake_bin.path(),
r#"[{"number":128}]"#,
r#"{"number":128,"title":"Auto-detect PR","state":"OPEN","isDraft":false,"url":"https://github.com/kbrdn1/gwm-cli/pull/128"}"#,
);
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_GH", &fake_gh)
.env("PATH", prepend_path(fake_bin.path()))
.args(["status", "--json"])
.assert()
.success()
.stdout(predicate::str::contains("\"number\":128"))
.stdout(predicate::str::contains("\"source\":\"detected\""));
}
#[test]
fn status_persists_detected_pr_title_after_fetch() {
let (dir, repo) = init_repo();
repo.remote("origin", "https://github.com/kbrdn1/gwm-cli.git").unwrap();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("detect-me", &head, false).unwrap();
repo.set_head("refs/heads/detect-me").unwrap();
let fake_bin = tempfile::TempDir::new().unwrap();
let fake_gh = write_dispatch_gh(
fake_bin.path(),
r#"[{"number":128}]"#,
r#"{"number":128,"title":"Auto-detect PR","state":"OPEN","isDraft":false,"url":"https://github.com/kbrdn1/gwm-cli/pull/128"}"#,
);
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_GH", &fake_gh)
.env("PATH", prepend_path(fake_bin.path()))
.args(["status", "--json"])
.assert()
.success();
let link = gwm::github::read_link(&repo, "detect-me").unwrap();
assert_eq!(link.pr, Some(128));
assert_eq!(link.pr_source, gwm::github::LinkSource::Detected);
assert_eq!(link.pr_title.as_deref(), Some("Auto-detect PR"));
}
#[test]
fn review_resolves_pr_metadata_and_names_branch_end_to_end() {
let (dir, repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
write_test_config(dir.path(), base.path());
repo.remote("origin", "https://github.com/kbrdn1/gwm-cli.git").unwrap();
let fake_bin = tempfile::TempDir::new().unwrap();
let fake_gh = write_dispatch_gh(
fake_bin.path(),
"[]",
r#"{"number":1,"author":{"login":"alice"},"headRefName":"feat/spike-x","baseRefName":"main"}"#,
);
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_GH", &fake_gh)
.env("PATH", prepend_path(fake_bin.path()))
.env("GIT_ALLOW_PROTOCOL", "file") .args(["review", "1"])
.assert()
.failure()
.stdout(predicate::str::contains("PR : #1 by alice (feat/spike-x → main)"))
.stdout(predicate::str::contains("branch : review/pr-1-alice-spike-x"))
.stdout(predicate::str::contains("review-pr-1-alice-spike-x"));
}
#[test]
fn status_explicit_pr_link_wins_over_detection() {
let (dir, repo) = init_repo();
repo.remote("origin", "https://github.com/kbrdn1/gwm-cli.git").unwrap();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("detect-me", &head, false).unwrap();
repo.set_head("refs/heads/detect-me").unwrap();
let fake_bin = tempfile::TempDir::new().unwrap();
let fake_gh = write_dispatch_gh(
fake_bin.path(),
r#"[{"number":999}]"#,
r#"{"number":61,"title":"Explicit","state":"OPEN","isDraft":false,"url":"https://github.com/kbrdn1/gwm-cli/pull/61"}"#,
);
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["link", "pr", "61"])
.assert()
.success();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_GH", &fake_gh)
.env("PATH", prepend_path(fake_bin.path()))
.args(["status", "--json"])
.assert()
.success()
.stdout(predicate::str::contains("\"number\":61"))
.stdout(predicate::str::contains("\"source\":\"explicit\""));
}
#[test]
fn open_print_url_emits_url_without_spawning_browser() {
let (dir, repo) = init_repo();
repo.remote("origin", "https://github.com/kbrdn1/gwm-cli.git").unwrap();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feat/#42-tui-search", &head, false).unwrap();
repo.set_head("refs/heads/feat/#42-tui-search").unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["open", "issue", "--print-url"])
.assert()
.success()
.stdout(predicate::str::contains("https://github.com/kbrdn1/gwm-cli/issues/42"));
}
#[test]
fn open_pr_without_link_fails_clearly() {
let (dir, repo) = init_repo();
repo.remote("origin", "https://github.com/kbrdn1/gwm-cli.git").unwrap();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("random-branch", &head, false).unwrap();
repo.set_head("refs/heads/random-branch").unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["open", "pr", "--print-url"])
.assert()
.failure()
.stderr(predicate::str::contains("no PR linked"));
}
#[test]
fn status_on_branch_with_no_link_reports_no_link() {
let (dir, repo) = init_repo();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("random-branch", &head, false).unwrap();
repo.set_head("refs/heads/random-branch").unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["status"])
.assert()
.success()
.stdout(predicate::str::contains("no link"));
}
#[test]
fn init_writes_gwm_toml_with_expected_sections() {
let (dir, _repo) = init_repo();
let cfg_path = dir.path().join(".gwm.toml");
assert!(!cfg_path.exists(), "precondition: no .gwm.toml in fresh repo");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.arg("init")
.assert()
.success()
.stdout(predicate::str::contains(".gwm.toml"));
assert!(cfg_path.exists(), "gwm init must write .gwm.toml on disk");
let body = std::fs::read_to_string(&cfg_path).unwrap();
assert!(body.contains("[worktree]"), "missing [worktree] section");
assert!(
body.contains("base = ") && body.contains("{home}") && body.contains("{repo}"),
"missing documented placeholders in [worktree].base"
);
assert!(
body.contains("[[bootstrap.copy]]"),
"missing [[bootstrap.copy]] template"
);
assert!(
body.contains("[[bootstrap.guard]]"),
"missing [[bootstrap.guard]] template"
);
}
#[test]
fn init_refuses_to_overwrite_existing_gwm_toml() {
let (dir, _repo) = init_repo();
let cfg_path = dir.path().join(".gwm.toml");
std::fs::write(&cfg_path, "# user edits\n[worktree]\nbase = \"/custom\"\n").unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.arg("init")
.assert()
.failure()
.stderr(predicate::str::contains(".gwm.toml"))
.stderr(predicate::str::contains("already exists"));
let body = std::fs::read_to_string(&cfg_path).unwrap();
assert!(
body.contains("# user edits"),
"failed gwm init must not modify the existing .gwm.toml"
);
}
#[test]
fn init_outside_git_repo_fails() {
let dir = tempfile::TempDir::new().unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.arg("init")
.assert()
.failure()
.stderr(predicate::str::contains("not inside a git repository"));
}
#[test]
fn init_list_presets_enumerates_builtins() {
let dir = tempfile::TempDir::new().unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["init", "--list-presets"])
.assert()
.success()
.stdout(predicate::str::contains("generic"))
.stdout(predicate::str::contains("laravel"))
.stdout(predicate::str::contains("node"))
.stdout(predicate::str::contains("rust"))
.stdout(predicate::str::contains("python-uv"));
}
#[test]
fn init_preset_show_prints_without_writing() {
let (dir, _repo) = init_repo();
let cfg_path = dir.path().join(".gwm.toml");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["init", "--preset", "rust", "--show"])
.assert()
.success()
.stdout(predicate::str::contains("[[bootstrap.no_symlink]]"))
.stdout(predicate::str::contains("target"));
assert!(!cfg_path.exists(), "--show must not write .gwm.toml to disk");
}
#[test]
fn init_preset_writes_stack_config() {
let (dir, _repo) = init_repo();
let cfg_path = dir.path().join(".gwm.toml");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["init", "--preset", "laravel"])
.assert()
.success()
.stdout(predicate::str::contains(".gwm.toml"));
let body = std::fs::read_to_string(&cfg_path).unwrap();
assert!(body.contains("no-aws-rds"), "laravel preset missing the AWS-RDS guard");
assert!(body.contains("vendor"), "laravel preset missing the vendor no-symlink");
}
#[test]
fn init_unknown_preset_fails() {
let (dir, _repo) = init_repo();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["init", "--preset", "cobol"])
.assert()
.failure()
.stderr(predicate::str::contains("cobol"))
.stderr(predicate::str::contains("list-presets"));
}
#[test]
fn init_preset_nuxt_alias_writes_node_body() {
let (dir, _repo) = init_repo();
let cfg_path = dir.path().join(".gwm.toml");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["init", "--preset", "nuxt"])
.assert()
.success();
let body = std::fs::read_to_string(&cfg_path).unwrap();
assert!(body.contains("node_modules"), "nuxt alias must seed the node preset");
}
fn write_test_config(repo_root: &Path, base: &Path) {
let body = format!(
r#"
[worktree]
base = "{base}"
path_pattern = "{{type}}-{{issue}}-{{desc}}"
branch_pattern = "{{type}}/#{{issue}}-{{desc}}"
"#,
base = toml_basic_string(base),
);
std::fs::write(repo_root.join(".gwm.toml"), body).unwrap();
}
fn toml_basic_string(path: &Path) -> String {
path.display().to_string().replace('\\', "\\\\").replace('"', "\\\"")
}
fn prepend_path(dir: &Path) -> String {
let old = std::env::var_os("PATH").unwrap_or_default();
let mut paths = vec![dir.to_path_buf()];
paths.extend(std::env::split_paths(&old));
std::env::join_paths(paths).unwrap().to_string_lossy().into_owned()
}
fn write_dispatch_gh(root: &Path, pr_list_json: &str, pr_view_json: &str) -> PathBuf {
#[cfg(unix)]
{
let script = root.join("gh");
fs::write(
&script,
format!(
r#"#!/bin/sh
if [ "$1" = "pr" ] && [ "$2" = "list" ]; then
printf '%s' '{list}'
elif [ "$1" = "pr" ] && [ "$2" = "view" ]; then
printf '%s' '{view}'
fi
"#,
list = pr_list_json.replace('\'', "'\\''"),
view = pr_view_json.replace('\'', "'\\''"),
),
)
.unwrap();
let mut perms = fs::metadata(&script).unwrap().permissions();
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o755);
fs::set_permissions(&script, perms).unwrap();
script
}
#[cfg(windows)]
{
let script = root.join("gh.cmd");
fs::write(
&script,
format!(
"@echo off\r\nif \"%~1\"==\"pr\" if \"%~2\"==\"list\" echo {list}\r\nif \"%~1\"==\"pr\" if \"%~2\"==\"view\" echo {view}\r\n",
list = pr_list_json,
view = pr_view_json,
),
)
.unwrap();
script
}
}
fn write_fake_gh(root: &Path, issue_url: &str) -> PathBuf {
#[cfg(unix)]
{
write_unix_fake_gh(root, issue_url)
}
#[cfg(windows)]
{
write_windows_fake_gh(root, issue_url)
}
}
#[cfg(unix)]
fn write_unix_fake_gh(root: &Path, issue_url: &str) -> PathBuf {
let script = root.join("gh");
fs::write(
&script,
format!(
r#"#!/bin/sh
printf '%s\n' "$*" > '{args}'
body_file=""
prev=""
for arg in "$@"; do
if [ "$prev" = "--body-file" ]; then
body_file="$arg"
fi
prev="$arg"
done
if [ -n "$body_file" ]; then
cp "$body_file" '{body}'
fi
printf '%s' '{url}'
"#,
args = root.join("gh-args.txt").display(),
body = root.join("gh-body.md").display(),
url = issue_url.replace('\'', "'\\''"),
),
)
.unwrap();
let mut perms = fs::metadata(&script).unwrap().permissions();
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o755);
fs::set_permissions(&script, perms).unwrap();
script
}
#[cfg(windows)]
fn write_windows_fake_gh(root: &Path, issue_url: &str) -> PathBuf {
let script = root.join("gh.cmd");
fs::write(
&script,
format!(
r#"@echo off
echo %* > "{args}"
:scan
if "%~1"=="" goto done
if "%~1"=="--body-file" (
copy "%~2" "{body}" > nul
)
shift
goto scan
:done
echo {url}
"#,
args = root.join("gh-args.txt").display(),
body = root.join("gh-body.md").display(),
url = issue_url.trim(),
),
)
.unwrap();
script
}
#[test]
fn test_config_base_path_is_escaped_for_toml_basic_strings() {
assert_eq!(
toml_basic_string(Path::new(r#"C:\Users\runner\AppData\Local\Temp"#)),
r#"C:\\Users\\runner\\AppData\\Local\\Temp"#
);
}
#[test]
fn create_adds_worktree_dir_and_branch_at_head() {
let (dir, repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
write_test_config(dir.path(), base.path());
let head_oid = repo.head().unwrap().target().unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["create", "feat", "42", "tui-search"])
.assert()
.success()
.stdout(predicate::str::contains("feat/#42-tui-search"))
.stdout(predicate::str::contains("worktree created"));
let wt_dir = base.path().join("feat-42-tui-search");
assert!(wt_dir.exists(), "worktree dir must exist on disk");
assert!(wt_dir.join(".git").exists(), "worktree must carry a .git pointer");
let branch = repo
.find_branch("feat/#42-tui-search", git2::BranchType::Local)
.expect("branch must be created");
let branch_oid = branch.into_reference().target().unwrap();
assert_eq!(branch_oid, head_oid, "fresh branch must point at the main HEAD commit");
let cfg = repo.config().unwrap();
let recorded_base = cfg.get_string("branch.feat/#42-tui-search.gwm-base").unwrap();
assert_eq!(
recorded_base, "main",
"gwm create must record the parent ref for the launcher fallback chain"
);
}
#[test]
fn create_runs_bootstrap_by_default() {
let (dir, _repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
let marker = "GWM_E2E_BOOTSTRAP_MARKER_v1";
std::fs::write(dir.path().join("seed.env"), marker).unwrap();
let body = format!(
r#"
[worktree]
base = "{base}"
path_pattern = "{{type}}-{{issue}}-{{desc}}"
branch_pattern = "{{type}}/#{{issue}}-{{desc}}"
[[bootstrap.copy]]
from = "seed.env"
to = "seed.env"
required = true
"#,
base = toml_basic_string(base.path()),
);
std::fs::write(dir.path().join(".gwm.toml"), body).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["create", "feat", "7", "bootstrap-on"])
.assert()
.success();
let copied = base.path().join("feat-7-bootstrap-on").join("seed.env");
assert!(copied.exists(), "bootstrap copy step did not run");
let body = std::fs::read_to_string(&copied).unwrap();
assert!(
body.contains(marker),
"bootstrap copy must duplicate the source file content"
);
}
#[test]
fn create_runs_lifecycle_hooks_and_legacy_bootstrap_command_alias() {
let (dir, _repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
let body = format!(
r#"
[worktree]
base = "{base}"
path_pattern = "{{type}}-{{issue}}-{{desc}}"
branch_pattern = "{{type}}/#{{issue}}-{{desc}}"
[[hooks.pre_create]]
name = "record pre-create"
run = "printf pre-{{branch}}-{{issue}}-{{desc}} > pre-create.txt"
[[hooks.post_create]]
name = "record post-create"
run = "printf post-{{branch}} > post-create.txt"
[[bootstrap.command]]
name = "legacy post-create"
run = "printf legacy > legacy-post-create.txt"
"#,
base = toml_basic_string(base.path()),
);
std::fs::write(dir.path().join(".gwm.toml"), body).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["create", "feat", "88", "hooks"])
.assert()
.success()
.stdout(predicate::str::contains("[pre_create] record pre-create"))
.stdout(predicate::str::contains("[post_create] record post-create"))
.stdout(predicate::str::contains("[post_create] legacy post-create"));
let worktree = base.path().join("feat-88-hooks");
assert_eq!(
std::fs::read_to_string(dir.path().join("pre-create.txt")).unwrap(),
"pre-feat/#88-hooks-88-hooks"
);
assert_eq!(
std::fs::read_to_string(worktree.join("post-create.txt")).unwrap(),
"post-feat/#88-hooks"
);
assert_eq!(
std::fs::read_to_string(worktree.join("legacy-post-create.txt")).unwrap(),
"legacy"
);
}
#[test]
fn new_creates_issue_from_template_then_creates_worktree() {
let (dir, _repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
let fake_bin = tempfile::TempDir::new().unwrap();
let fake_gh = write_fake_gh(fake_bin.path(), "https://github.com/acme/widgets/issues/142\n");
fs::create_dir_all(dir.path().join(".github/ISSUE_TEMPLATE")).unwrap();
fs::write(
dir.path().join(".github/ISSUE_TEMPLATE/feature_request.yml"),
r#"
name: Feature
title: "[Feature]: "
labels: ["feature"]
body:
- type: markdown
attributes:
value: |
Feature request for {desc}
- type: dropdown
id: surface
attributes:
label: Surface
options:
- cli
- tui
- type: textarea
id: proposal
attributes:
label: Proposed solution
placeholder: "Implement {type}"
"#,
)
.unwrap();
let body = format!(
r#"
[worktree]
base = "{base}"
path_pattern = "{{type}}-{{issue}}-{{desc}}"
branch_pattern = "{{type}}/#{{issue}}-{{desc}}"
[issue_template]
default = "feature_request.yml"
[issue_template.by_type]
feat = {{ template = "feature_request.yml", surface = "cli", title_prefix = "[Feature]: ", labels = ["enhancement"] }}
"#,
base = toml_basic_string(base.path()),
);
fs::write(dir.path().join(".gwm.toml"), body).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.env("GWM_GH", &fake_gh)
.env("PATH", prepend_path(fake_bin.path()))
.args(["new", "feat", "add-config-types", "--no-bootstrap"])
.assert()
.success()
.stdout(predicate::str::contains("created issue #142"))
.stdout(predicate::str::contains("feat/#142-add-config-types"))
.stdout(predicate::str::contains("worktree created"));
assert!(base.path().join("feat-142-add-config-types").exists());
let gh_args_raw = fs::read_to_string(fake_bin.path().join("gh-args.txt")).unwrap();
let gh_args = gh_args_raw.replace('"', "");
assert!(gh_args.contains("issue create"), "{gh_args_raw}");
assert!(gh_args.contains("--title [Feature]: add-config-types"), "{gh_args_raw}");
assert!(gh_args.contains("--label feature"), "{gh_args_raw}");
assert!(gh_args.contains("--label enhancement"), "{gh_args_raw}");
let gh_body = fs::read_to_string(fake_bin.path().join("gh-body.md")).unwrap();
assert!(gh_body.contains("Feature request for add-config-types"), "{gh_body}");
assert!(gh_body.contains("**Surface:** cli"), "{gh_body}");
assert!(gh_body.contains("Implement feat"), "{gh_body}");
}
#[test]
fn create_pre_create_abort_failure_leaves_no_worktree() {
let (dir, _repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
let body = format!(
r#"
[worktree]
base = "{base}"
path_pattern = "{{type}}-{{issue}}-{{desc}}"
branch_pattern = "{{type}}/#{{issue}}-{{desc}}"
[[hooks.pre_create]]
name = "block create"
run = "false"
on_fail = "abort"
"#,
base = toml_basic_string(base.path()),
);
std::fs::write(dir.path().join(".gwm.toml"), body).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["create", "feat", "88", "blocked"])
.assert()
.failure()
.stderr(predicate::str::contains("hook pre_create 'block create' failed"));
assert!(
!base.path().join("feat-88-blocked").exists(),
"pre_create abort must happen before worktree creation"
);
}
#[test]
fn create_warn_and_ignore_hook_failures_do_not_abort() {
let (dir, _repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
let body = format!(
r#"
[worktree]
base = "{base}"
path_pattern = "{{type}}-{{issue}}-{{desc}}"
branch_pattern = "{{type}}/#{{issue}}-{{desc}}"
[[hooks.pre_create]]
name = "warn only"
run = "false"
on_fail = "warn"
[[hooks.pre_create]]
name = "ignore failure"
run = "false"
on_fail = "ignore"
"#,
base = toml_basic_string(base.path()),
);
std::fs::write(dir.path().join(".gwm.toml"), body).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["create", "feat", "88", "nonfatal"])
.assert()
.success()
.stdout(predicate::str::contains("! [pre_create] warn only"))
.stdout(predicate::str::contains("· [pre_create] ignore failure"));
assert!(base.path().join("feat-88-nonfatal").exists());
}
#[test]
fn create_skip_hooks_bypasses_named_phase() {
let (dir, _repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
let body = format!(
r#"
[worktree]
base = "{base}"
path_pattern = "{{type}}-{{issue}}-{{desc}}"
branch_pattern = "{{type}}/#{{issue}}-{{desc}}"
[[hooks.pre_create]]
name = "would block"
run = "false"
"#,
base = toml_basic_string(base.path()),
);
std::fs::write(dir.path().join(".gwm.toml"), body).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["create", "feat", "88", "skip", "--skip-hooks", "pre_create"])
.assert()
.success();
assert!(base.path().join("feat-88-skip").exists());
}
#[test]
fn bootstrap_runs_pre_and_post_bootstrap_hooks() {
let (dir, _repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
let body = format!(
r#"
[worktree]
base = "{base}"
path_pattern = "{{type}}-{{issue}}-{{desc}}"
branch_pattern = "{{type}}/#{{issue}}-{{desc}}"
[[hooks.pre_bootstrap]]
name = "before bootstrap"
run = "printf pre > pre-bootstrap.txt"
[[bootstrap.copy]]
from = "seed.txt"
to = "seed.txt"
required = true
[[hooks.post_bootstrap]]
name = "after bootstrap"
run = "printf post > post-bootstrap.txt"
"#,
base = toml_basic_string(base.path()),
);
std::fs::write(dir.path().join(".gwm.toml"), body).unwrap();
std::fs::write(dir.path().join("seed.txt"), "seed").unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["create", "feat", "88", "boot-hooks", "--no-bootstrap"])
.assert()
.success();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["bootstrap", "feat-88-boot-hooks"])
.assert()
.success()
.stdout(predicate::str::contains("[pre_bootstrap] before bootstrap"))
.stdout(predicate::str::contains("[post_bootstrap] after bootstrap"));
let worktree = base.path().join("feat-88-boot-hooks");
assert_eq!(
std::fs::read_to_string(worktree.join("pre-bootstrap.txt")).unwrap(),
"pre"
);
assert_eq!(std::fs::read_to_string(worktree.join("seed.txt")).unwrap(), "seed");
assert_eq!(
std::fs::read_to_string(worktree.join("post-bootstrap.txt")).unwrap(),
"post"
);
}
#[test]
fn remove_runs_pre_and_post_remove_hooks_and_force_skips_them() {
let (dir, _repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
write_test_config(dir.path(), base.path());
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["create", "feat", "88", "remove-hooks", "--no-bootstrap"])
.assert()
.success();
let config = format!(
r#"
[worktree]
base = "{base}"
path_pattern = "{{type}}-{{issue}}-{{desc}}"
branch_pattern = "{{type}}/#{{issue}}-{{desc}}"
[[hooks.pre_remove]]
name = "block remove"
run = "false"
[[hooks.post_remove]]
name = "cleanup"
run = "printf removed-{{branch}} > post-remove.txt"
"#,
base = toml_basic_string(base.path()),
);
std::fs::write(dir.path().join(".gwm.toml"), config).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["remove", "remove-hooks"])
.assert()
.failure()
.stderr(predicate::str::contains("hook pre_remove 'block remove' failed"));
assert!(base.path().join("feat-88-remove-hooks").exists());
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["remove", "remove-hooks", "--force"])
.assert()
.success();
assert!(!base.path().join("feat-88-remove-hooks").exists());
assert!(
!dir.path().join("post-remove.txt").exists(),
"--force must skip pre_remove and post_remove hooks"
);
}
#[test]
fn create_skips_bootstrap_with_no_bootstrap_flag() {
let (dir, _repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("seed.env"), "marker").unwrap();
let body = format!(
r#"
[worktree]
base = "{base}"
path_pattern = "{{type}}-{{issue}}-{{desc}}"
branch_pattern = "{{type}}/#{{issue}}-{{desc}}"
[[bootstrap.copy]]
from = "seed.env"
to = "seed.env"
required = true
"#,
base = toml_basic_string(base.path()),
);
std::fs::write(dir.path().join(".gwm.toml"), body).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["create", "feat", "8", "bootstrap-off", "--no-bootstrap"])
.assert()
.success()
.stdout(predicate::str::contains("skipped bootstrap"));
let wt_dir = base.path().join("feat-8-bootstrap-off");
assert!(wt_dir.exists(), "worktree dir must still be created");
assert!(
!wt_dir.join("seed.env").exists(),
"--no-bootstrap must prevent the copy step from running"
);
}
#[test]
fn create_rejects_unknown_branch_type() {
let (dir, _repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
write_test_config(dir.path(), base.path());
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["create", "blarg", "9", "nope"])
.assert()
.failure()
.stderr(predicate::str::contains("blarg"));
assert!(
!base.path().join("blarg-9-nope").exists(),
"no worktree dir may be created when branch-type validation fails"
);
}
#[test]
fn create_rejects_non_digit_issue() {
let (dir, _repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
write_test_config(dir.path(), base.path());
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["create", "feat", "abc", "thing"])
.assert()
.failure();
assert!(
!base.path().join("feat-abc-thing").exists(),
"no worktree dir may be created when issue-number validation fails"
);
}
#[test]
fn create_refuses_stale_branch_without_reuse_flag() {
let (dir, repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
write_test_config(dir.path(), base.path());
let seed = repo.head().unwrap().peel_to_commit().unwrap();
let stale_branch = repo.branch("feat/#99-stale", &seed, false).unwrap();
let stale_oid = stale_branch.into_reference().target().unwrap().to_string();
let sig = git2::Signature::now("gwm-test", "gwm@test").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo
.commit(Some("HEAD"), &sig, &sig, "advance main", &tree, &[&seed])
.unwrap();
let new_head = repo.head().unwrap().target().unwrap().to_string();
assert_ne!(new_head, stale_oid, "precondition: HEAD must diverge from stale branch");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["create", "feat", "99", "stale"])
.assert()
.failure()
.stderr(predicate::str::contains("feat/#99-stale"))
.stderr(predicate::str::contains("--reuse-branch"))
.stderr(predicate::str::contains(stale_oid.as_str()));
assert!(
!base.path().join("feat-99-stale").exists(),
"no worktree dir may be created when the branch is refused"
);
}
#[test]
fn create_reuses_stale_branch_with_flag() {
let (dir, repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
write_test_config(dir.path(), base.path());
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feat/#99-stale", &head, false).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["create", "feat", "99", "stale", "--reuse-branch"])
.assert()
.success();
assert!(
base.path().join("feat-99-stale").exists(),
"with --reuse-branch the worktree dir must be created against the stale branch"
);
}
#[test]
fn create_subcommand_outside_git_repo_fails() {
let dir = tempfile::TempDir::new().unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["create", "feat", "1", "x"])
.assert()
.failure()
.stderr(predicate::str::contains("not inside a git repository"));
}
#[test]
fn remove_deletes_worktree_dir_and_keeps_branch_by_default() {
let (dir, repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
write_test_config(dir.path(), base.path());
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1") .args(["create", "feat", "10", "remove-me"])
.assert()
.success();
let wt_dir = base.path().join("feat-10-remove-me");
assert!(wt_dir.exists());
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["remove", "remove-me"])
.assert()
.success()
.stdout(predicate::str::contains("removed"));
assert!(!wt_dir.exists(), "remove must delete the worktree directory");
assert!(
repo.find_branch("feat/#10-remove-me", git2::BranchType::Local).is_ok(),
"remove without --delete-branch must keep the local branch"
);
}
#[test]
fn remove_with_delete_branch_drops_branch() {
let (dir, repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
write_test_config(dir.path(), base.path());
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1") .args(["create", "feat", "11", "drop-branch"])
.assert()
.success();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["remove", "drop-branch", "--delete-branch"])
.assert()
.success()
.stdout(predicate::str::contains("branch"))
.stdout(predicate::str::contains("deleted"));
assert!(
repo
.find_branch("feat/#11-drop-branch", git2::BranchType::Local)
.is_err(),
"--delete-branch must remove the local branch ref"
);
}
#[test]
fn remove_unknown_pattern_fails() {
let (dir, _repo) = init_repo();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["remove", "ghost"])
.assert()
.failure()
.stderr(predicate::str::contains("not found"));
}
#[test]
fn remove_outside_git_repo_fails() {
let dir = tempfile::TempDir::new().unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["remove", "anything"])
.assert()
.failure()
.stderr(predicate::str::contains("not inside a git repository"));
}
#[test]
fn remove_dry_run_prints_plan_and_keeps_worktree_intact() {
let (dir, repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
write_test_config(dir.path(), base.path());
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["create", "feat", "31", "preview"])
.assert()
.success();
let wt_dir = base.path().join("feat-31-preview");
assert!(wt_dir.exists());
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["remove", "preview", "--dry-run"])
.assert()
.success()
.stdout(predicate::str::contains("would remove"))
.stdout(predicate::str::contains("feat-31-preview"))
.stdout(predicate::str::contains("feat/#31-preview"));
assert!(wt_dir.exists(), "--dry-run must not delete the worktree directory");
assert!(
repo.find_branch("feat/#31-preview", git2::BranchType::Local).is_ok(),
"--dry-run must not delete the local branch"
);
}
#[test]
fn remove_dry_run_with_delete_branch_flags_branch_deletion() {
let (dir, repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
write_test_config(dir.path(), base.path());
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["create", "feat", "32", "preview-drop"])
.assert()
.success();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["remove", "preview-drop", "--delete-branch", "--dry-run"])
.assert()
.success()
.stdout(predicate::str::contains("would be deleted"));
assert!(
repo
.find_branch("feat/#32-preview-drop", git2::BranchType::Local)
.is_ok(),
"--dry-run --delete-branch must not delete the local branch"
);
}
#[test]
fn remove_dry_run_on_ambiguous_pattern_still_fails() {
let (dir, _repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
write_test_config(dir.path(), base.path());
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["create", "feat", "33", "ambiguous-one"])
.assert()
.success();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["create", "feat", "34", "ambiguous-two"])
.assert()
.success();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["remove", "ambiguous", "--dry-run"])
.assert()
.failure()
.stderr(predicate::str::contains("ambiguous"));
}
#[test]
fn prune_dry_run_prints_plan_without_pruning() {
let (dir, repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
write_test_config(dir.path(), base.path());
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["create", "feat", "35", "stale"])
.assert()
.success();
let wt_dir = base.path().join("feat-35-stale");
assert!(wt_dir.exists());
std::fs::remove_dir_all(&wt_dir).unwrap();
let admin_dir = dir.path().join(".git").join("worktrees").join("feat-35-stale");
assert!(admin_dir.exists(), "precondition: admin entry must still exist");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["prune", "--dry-run"])
.assert()
.success()
.stdout(predicate::str::contains("would prune"))
.stdout(predicate::str::contains("feat-35-stale"));
assert!(admin_dir.exists(), "--dry-run must not remove the prunable admin entry");
assert!(
repo.find_worktree("feat-35-stale").is_ok(),
"--dry-run must leave libgit2's worktree list untouched"
);
}
#[test]
fn prune_dry_run_reports_no_candidates_when_clean() {
let (dir, _repo) = init_repo();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["prune", "--dry-run"])
.assert()
.success()
.stdout(predicate::str::contains("0 worktree"));
}
#[test]
fn trust_help_lists_list_revoke_show() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["trust", "--help"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("list"))
.stdout(predicate::str::contains("revoke"))
.stdout(predicate::str::contains("show"));
}
#[test]
fn trust_list_empty_prints_zero_entries() {
let dir = tempfile::TempDir::new().unwrap();
let ledger = dir.path().join("trust.toml");
Command::cargo_bin("gwm")
.unwrap()
.env("GWM_TRUST_LEDGER", &ledger)
.args(["trust", "list"])
.assert()
.success()
.stdout(predicate::str::contains("0 entries in trust ledger"));
}
#[test]
fn trust_show_when_absent_says_so() {
let dir = tempfile::TempDir::new().unwrap();
let ledger = dir.path().join("absent.toml");
Command::cargo_bin("gwm")
.unwrap()
.env("GWM_TRUST_LEDGER", &ledger)
.args(["trust", "show"])
.assert()
.success()
.stdout(predicate::str::contains("file does not exist yet"));
}
#[test]
fn trust_revoke_no_matching_origin_is_a_no_op() {
let dir = tempfile::TempDir::new().unwrap();
let ledger = dir.path().join("trust.toml");
Command::cargo_bin("gwm")
.unwrap()
.env("GWM_TRUST_LEDGER", &ledger)
.args(["trust", "revoke", "git@github.com:foo/bar.git"])
.assert()
.success()
.stdout(predicate::str::contains("0 entries matched"));
}
#[test]
fn trust_list_show_revoke_round_trip() {
use std::fs;
let dir = tempfile::TempDir::new().unwrap();
let ledger = dir.path().join("trust.toml");
fs::write(
&ledger,
r#"[[entries]]
origin = "git@github.com:kbrdn1/gwm-cli.git"
config_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
trusted_at = "2026-05-22T10:00:00Z"
trusted_by = "kylian@laptop"
"#,
)
.unwrap();
Command::cargo_bin("gwm")
.unwrap()
.env("GWM_TRUST_LEDGER", &ledger)
.args(["trust", "list"])
.assert()
.success()
.stdout(predicate::str::contains("git@github.com:kbrdn1/gwm-cli.git"))
.stdout(predicate::str::contains("deadbeefdead"));
Command::cargo_bin("gwm")
.unwrap()
.env("GWM_TRUST_LEDGER", &ledger)
.args(["trust", "show"])
.assert()
.success()
.stdout(predicate::str::contains("kylian@laptop"));
Command::cargo_bin("gwm")
.unwrap()
.env("GWM_TRUST_LEDGER", &ledger)
.args(["trust", "revoke", "git@github.com:kbrdn1/gwm-cli.git"])
.assert()
.success()
.stdout(predicate::str::contains("✓ revoked 1"));
Command::cargo_bin("gwm")
.unwrap()
.env("GWM_TRUST_LEDGER", &ledger)
.args(["trust", "list"])
.assert()
.success()
.stdout(predicate::str::contains("0 entries"));
}
#[test]
fn allow_bootstrap_flag_is_global_and_documented() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["--help"]);
cmd
.assert()
.success()
.stdout(predicate::str::contains("--allow-bootstrap"))
.stdout(predicate::str::contains("--deny-bootstrap"));
}
#[test]
fn create_without_trust_in_non_interactive_aborts_cleanly() {
let (dir, _repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
r#"[[bootstrap.command]]
name = "echo"
run = "echo trapped"
"#,
)
.unwrap();
let ledger_dir = tempfile::TempDir::new().unwrap();
let ledger = ledger_dir.path().join("trust.toml");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_TRUST_LEDGER", &ledger)
.args(["create", "feat", "42", "trapped"])
.assert()
.failure()
.stderr(
predicate::str::contains("not in the trust ledger").or(predicate::str::contains("stdin is not interactive")),
);
}
#[test]
fn create_with_allow_bootstrap_flag_bypasses_the_prompt() {
let (dir, _repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
r#"[[bootstrap.command]]
name = "echo"
run = "echo would-have-run"
"#,
)
.unwrap();
let ledger_dir = tempfile::TempDir::new().unwrap();
let ledger = ledger_dir.path().join("trust.toml");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_TRUST_LEDGER", &ledger)
.args(["--allow-bootstrap", "create", "feat", "42", "trapped", "--no-bootstrap"])
.assert()
.success();
assert!(
!ledger.exists()
|| std::fs::read_to_string(&ledger).unwrap().contains("entries = []")
|| std::fs::read_to_string(&ledger).unwrap().is_empty()
);
}
#[test]
fn create_with_gwm_allow_bootstrap_env_bypasses_the_prompt() {
let (dir, _repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
r#"[[bootstrap.command]]
name = "echo"
run = "echo would-have-run"
"#,
)
.unwrap();
let ledger_dir = tempfile::TempDir::new().unwrap();
let ledger = ledger_dir.path().join("trust.toml");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_TRUST_LEDGER", &ledger)
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["create", "feat", "42", "env-trapped", "--no-bootstrap"])
.assert()
.success();
assert!(
!ledger.exists()
|| std::fs::read_to_string(&ledger).unwrap().contains("entries = []")
|| std::fs::read_to_string(&ledger).unwrap().is_empty()
);
}
#[test]
fn create_skips_trust_gate_when_bootstrap_surface_is_empty() {
let (dir, _repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
std::fs::write(
dir.path().join(".gwm.toml"),
format!(
r#"[worktree]
base = "{base}"
path_pattern = "{{type}}-{{issue}}-{{desc}}"
branch_pattern = "{{type}}/#{{issue}}-{{desc}}"
"#,
base = toml_basic_string(base.path()),
),
)
.unwrap();
let ledger_dir = tempfile::TempDir::new().unwrap();
let ledger = ledger_dir.path().join("trust.toml");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_TRUST_LEDGER", &ledger)
.args(["create", "feat", "13", "empty-surface"])
.assert()
.success();
assert!(
!ledger.exists(),
"empty-surface short-circuit must not write to the ledger"
);
}
#[test]
fn allow_bootstrap_succeeds_even_when_ledger_is_malformed() {
let (dir, _repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
"[[bootstrap.command]]\nname = \"x\"\nrun = \"true\"\n",
)
.unwrap();
let ledger_dir = tempfile::TempDir::new().unwrap();
let ledger = ledger_dir.path().join("trust.toml");
std::fs::write(&ledger, b"this is not valid toml @@@@").unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_TRUST_LEDGER", &ledger)
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["create", "feat", "99", "broken-ledger", "--no-bootstrap"])
.assert()
.success();
}
#[test]
fn deny_bootstrap_aborts_even_when_trusted() {
let (dir, _repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
"[[bootstrap.command]]\nname = \"x\"\nrun = \"true\"\n",
)
.unwrap();
let ledger_dir = tempfile::TempDir::new().unwrap();
let ledger = ledger_dir.path().join("trust.toml");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_TRUST_LEDGER", &ledger)
.args(["--deny-bootstrap", "bootstrap"])
.assert()
.failure()
.stderr(predicate::str::contains("--deny-bootstrap"));
}
#[test]
fn aliases_help_lists_list() {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.args(["aliases", "--help"]);
cmd.assert().success().stdout(predicate::str::contains("list"));
}
#[test]
fn aliases_list_prints_built_in_section_outside_repo() {
let dir = tempfile::TempDir::new().unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.env("XDG_CONFIG_HOME", dir.path())
.env("HOME", dir.path())
.args(["aliases", "list"])
.assert()
.success()
.stdout(predicate::str::contains("built-in:"))
.stdout(predicate::str::contains(" s → switch"))
.stdout(predicate::str::contains(" cd → path"));
}
#[test]
fn aliases_list_prints_repo_aliases_with_source() {
let (dir, _repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
r#"
[aliases]
wip = "create feat 0 wip"
ll = "list --format names"
"#,
)
.unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.env("XDG_CONFIG_HOME", dir.path())
.env("HOME", dir.path())
.args(["aliases", "list"])
.assert()
.success()
.stdout(predicate::str::contains("repo (.gwm.toml)"))
.stdout(predicate::str::contains("wip"))
.stdout(predicate::str::contains("create feat 0 wip"))
.stdout(predicate::str::contains("ll"))
.stdout(predicate::str::contains("list --format names"));
}
#[test]
fn aliases_list_prints_user_aliases_with_source() {
let dir = tempfile::TempDir::new().unwrap();
let user_cfg = dir.path().join("gwm");
std::fs::create_dir_all(&user_cfg).unwrap();
std::fs::write(
user_cfg.join("aliases.toml"),
r#"
[aliases]
copy = "path"
"#,
)
.unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.env("XDG_CONFIG_HOME", dir.path())
.env("HOME", dir.path())
.args(["aliases", "list"])
.assert()
.success()
.stdout(predicate::str::contains("user"))
.stdout(predicate::str::contains("aliases.toml"))
.stdout(predicate::str::contains("copy"))
.stdout(predicate::str::contains("path"));
}
#[test]
fn aliases_list_repo_overrides_user_for_same_name() {
let (dir, _repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
r#"
[aliases]
copy = "path bar"
"#,
)
.unwrap();
let user_cfg = dir.path().join("gwm");
std::fs::create_dir_all(&user_cfg).unwrap();
std::fs::write(
user_cfg.join("aliases.toml"),
r#"
[aliases]
copy = "path foo"
"#,
)
.unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.env("XDG_CONFIG_HOME", dir.path())
.env("HOME", dir.path())
.args(["aliases", "list"])
.assert()
.success()
.stdout(predicate::str::contains("path bar"))
.stdout(predicate::str::contains("path foo"));
}
#[test]
fn aliases_list_surfaces_shadow_error_with_alias_name() {
let (dir, _repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
r#"
[aliases]
list = "create feat 0 wip"
"#,
)
.unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.env("XDG_CONFIG_HOME", dir.path())
.env("HOME", dir.path())
.args(["aliases", "list"])
.assert()
.failure()
.stderr(predicate::str::contains("list").and(predicate::str::contains("built-in")));
}
#[test]
fn alias_expansion_runs_built_in_subcommand() {
let (dir, _repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
r#"
[aliases]
lst = "list --format names"
"#,
)
.unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.env("XDG_CONFIG_HOME", dir.path())
.env("HOME", dir.path())
.arg("lst")
.assert()
.success();
}
#[test]
fn alias_expansion_preserves_trailing_user_args() {
let (dir, _repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
r#"
[aliases]
typ = "types"
"#,
)
.unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.env("XDG_CONFIG_HOME", dir.path())
.env("HOME", dir.path())
.args(["typ", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("branch types"));
}
#[test]
fn alias_does_not_shadow_built_in_subcommand_at_runtime() {
let (dir, _repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
r#"
[aliases]
types = "list"
"#,
)
.unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(dir.path())
.env("XDG_CONFIG_HOME", dir.path())
.env("HOME", dir.path())
.args(["types"])
.assert()
.failure()
.stderr(predicate::str::contains("types"));
}
#[cfg(unix)]
#[test]
fn binary_tolerates_non_utf8_argv() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let dir = tempfile::TempDir::new().unwrap();
let invalid_utf8: OsString = OsString::from_vec(vec![0xff, 0xfe, 0x80]);
let output = Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("XDG_CONFIG_HOME", dir.path())
.env("HOME", dir.path())
.arg(&invalid_utf8)
.output()
.expect("binary must launch");
assert!(
output.status.code().is_some(),
"binary died on a signal (likely a panic abort) when given non-UTF-8 argv; stderr={:?}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("invalid utf-8") && !stderr.contains("panicked at"),
"binary panicked instead of gracefully handling non-UTF-8 argv: {}",
stderr
);
}
fn write_seed_history(path: &Path, repo_root: &Path, worktree: &str, branch: &str) {
let body = format!(
r#"[[op]]
ts = "2026-05-19T08:42:11Z"
kind = "remove"
worktree = "{worktree}"
branch = "{branch}"
branch_oid = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678"
path = "/tmp/cc-worktree/{worktree}"
deleted_branch = false
repo_root = "{repo_root}"
"#,
repo_root = toml_basic_string(repo_root),
);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, body).unwrap();
}
#[test]
fn history_empty_journal_reports_no_ops() {
let (dir, _) = init_repo();
let tmp = tempfile::TempDir::new().unwrap();
let history_file = tmp.path().join("history.toml");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_HISTORY_FILE", &history_file)
.arg("history")
.assert()
.success()
.stdout(predicate::str::contains("no operations recorded"));
}
#[test]
fn history_lists_recorded_remove_op() {
let (dir, _) = init_repo();
let repo_root = dir.path().canonicalize().unwrap();
let tmp = tempfile::TempDir::new().unwrap();
let history_file = tmp.path().join("history.toml");
write_seed_history(&history_file, &repo_root, "feat-29-foo", "feat/#29-foo");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_HISTORY_FILE", &history_file)
.arg("history")
.assert()
.success()
.stdout(predicate::str::contains("remove"))
.stdout(predicate::str::contains("feat-29-foo"));
}
#[test]
fn history_filters_to_current_repo_by_default() {
let (dir, _) = init_repo();
let repo_root = dir.path().canonicalize().unwrap();
let tmp = tempfile::TempDir::new().unwrap();
let history_file = tmp.path().join("history.toml");
let body = format!(
r#"[[op]]
ts = "2026-05-19T08:42:11Z"
kind = "remove"
worktree = "feat-here-foo"
branch = "feat/#1-here"
branch_oid = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678"
path = "/tmp/cc-worktree/feat-here-foo"
deleted_branch = false
repo_root = "{repo_root}"
[[op]]
ts = "2026-05-19T09:00:00Z"
kind = "remove"
worktree = "feat-elsewhere-bar"
branch = "feat/#2-elsewhere"
branch_oid = "b2c3d4e5f60718293a4b5c6d7e8f9012345678a1"
path = "/tmp/cc-worktree/feat-elsewhere-bar"
deleted_branch = false
repo_root = "/nonexistent/other-repo"
"#,
repo_root = toml_basic_string(&repo_root),
);
if let Some(parent) = history_file.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&history_file, body).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_HISTORY_FILE", &history_file)
.arg("history")
.assert()
.success()
.stdout(predicate::str::contains("feat-here-foo"))
.stdout(predicate::str::contains("feat-elsewhere-bar").not());
}
#[test]
fn remove_records_journal_entry_before_destruction() {
let (dir, _) = init_repo();
let base = tempfile::TempDir::new().unwrap();
let history_dir = tempfile::TempDir::new().unwrap();
let history_file = history_dir.path().join("history.toml");
let body = format!(
r#"
[worktree]
base = "{base}"
path_pattern = "{{type}}-{{issue}}-{{desc}}"
branch_pattern = "{{type}}/#{{issue}}-{{desc}}"
"#,
base = toml_basic_string(base.path()),
);
std::fs::write(dir.path().join(".gwm.toml"), body).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.env("GWM_HISTORY_FILE", &history_file)
.args(["create", "feat", "29", "doomed", "--no-bootstrap"])
.assert()
.success();
assert!(
!history_file.exists() || std::fs::read_to_string(&history_file).unwrap().trim().is_empty(),
"create must not record a journal entry"
);
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_HISTORY_FILE", &history_file)
.args(["remove", "feat-29-doomed"])
.assert()
.success();
assert!(
history_file.exists(),
"remove must create the journal file at {}",
history_file.display()
);
let body = std::fs::read_to_string(&history_file).unwrap();
assert!(
body.contains("feat-29-doomed"),
"journal must record the removed worktree name; got:\n{}",
body
);
assert!(
body.contains("kind = \"remove\""),
"journal must record kind = remove; got:\n{}",
body
);
assert!(
body.contains("feat/#29-doomed"),
"journal must record the branch name; got:\n{}",
body
);
}
#[test]
fn remove_dry_run_does_not_record_journal_entry() {
let (dir, _) = init_repo();
let base = tempfile::TempDir::new().unwrap();
let history_dir = tempfile::TempDir::new().unwrap();
let history_file = history_dir.path().join("history.toml");
let body = format!(
r#"
[worktree]
base = "{base}"
path_pattern = "{{type}}-{{issue}}-{{desc}}"
branch_pattern = "{{type}}/#{{issue}}-{{desc}}"
"#,
base = toml_basic_string(base.path()),
);
std::fs::write(dir.path().join(".gwm.toml"), body).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.env("GWM_HISTORY_FILE", &history_file)
.args(["create", "feat", "31", "preview", "--no-bootstrap"])
.assert()
.success();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_HISTORY_FILE", &history_file)
.args(["remove", "feat-31-preview", "--dry-run"])
.assert()
.success();
assert!(
!history_file.exists() || std::fs::read_to_string(&history_file).unwrap().trim().is_empty(),
"remove --dry-run must NOT write to the journal; got: {:?}",
std::fs::read_to_string(&history_file).ok()
);
}
#[test]
fn undo_recreates_branch_and_worktree_after_remove() {
let (dir, _) = init_repo();
let base = tempfile::TempDir::new().unwrap();
let history_dir = tempfile::TempDir::new().unwrap();
let history_file = history_dir.path().join("history.toml");
let body = format!(
r#"
[worktree]
base = "{base}"
path_pattern = "{{type}}-{{issue}}-{{desc}}"
branch_pattern = "{{type}}/#{{issue}}-{{desc}}"
"#,
base = toml_basic_string(base.path()),
);
std::fs::write(dir.path().join(".gwm.toml"), body).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.env("GWM_HISTORY_FILE", &history_file)
.args(["create", "feat", "29", "undo-rt", "--no-bootstrap"])
.assert()
.success();
let wt_path = base.path().join("feat-29-undo-rt");
assert!(wt_path.exists(), "create must produce the worktree dir");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_HISTORY_FILE", &history_file)
.args(["remove", "feat-29-undo-rt", "--delete-branch"])
.assert()
.success();
assert!(!wt_path.exists(), "remove must drop the worktree dir");
let repo = git2::Repository::open(dir.path()).unwrap();
assert!(
repo.find_branch("feat/#29-undo-rt", git2::BranchType::Local).is_err(),
"remove --delete-branch must drop the local branch"
);
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_HISTORY_FILE", &history_file)
.arg("undo")
.assert()
.success()
.stdout(predicate::str::contains("recreated branch"))
.stdout(predicate::str::contains("re-added worktree"));
assert!(wt_path.exists(), "undo must restore the worktree dir");
let repo = git2::Repository::open(dir.path()).unwrap();
assert!(
repo.find_branch("feat/#29-undo-rt", git2::BranchType::Local).is_ok(),
"undo must recreate the local branch"
);
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_HISTORY_FILE", &history_file)
.arg("undo")
.assert()
.failure()
.stderr(predicate::str::contains("nothing to undo"));
}
#[test]
fn undo_refuses_detached_head_entry_with_clear_error() {
let (dir, _) = init_repo();
let repo_root = dir.path().canonicalize().unwrap();
let tmp = tempfile::TempDir::new().unwrap();
let history_file = tmp.path().join("history.toml");
let body = format!(
r#"[[op]]
ts = "2026-05-19T08:42:11Z"
kind = "remove"
worktree = "feat-detached-foo"
branch_oid = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678"
path = "/tmp/cc-worktree/feat-detached-foo"
deleted_branch = false
repo_root = "{repo_root}"
"#,
repo_root = toml_basic_string(&repo_root),
);
std::fs::write(&history_file, body).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_HISTORY_FILE", &history_file)
.arg("undo")
.assert()
.failure()
.stderr(predicate::str::contains("detached-HEAD"));
}
#[test]
fn history_with_zero_limit_prints_nothing_but_does_not_lie() {
let (dir, _) = init_repo();
let repo_root = dir.path().canonicalize().unwrap();
let tmp = tempfile::TempDir::new().unwrap();
let history_file = tmp.path().join("history.toml");
write_seed_history(&history_file, &repo_root, "feat-zero-foo", "feat/#1-zero");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_HISTORY_FILE", &history_file)
.args(["history", "--limit", "0"])
.assert()
.success()
.stdout(predicate::eq(""));
}
#[test]
fn undo_with_empty_journal_errors_clearly() {
let (dir, _) = init_repo();
let history_dir = tempfile::TempDir::new().unwrap();
let history_file = history_dir.path().join("history.toml");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_HISTORY_FILE", &history_file)
.arg("undo")
.assert()
.failure()
.stderr(predicate::str::contains("nothing to undo"));
}
#[test]
fn history_all_flag_surfaces_every_repo() {
let (dir, _) = init_repo();
let repo_root = dir.path().canonicalize().unwrap();
let tmp = tempfile::TempDir::new().unwrap();
let history_file = tmp.path().join("history.toml");
let body = format!(
r#"[[op]]
ts = "2026-05-19T08:42:11Z"
kind = "remove"
worktree = "feat-here-foo"
branch = "feat/#1-here"
branch_oid = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678"
path = "/tmp/cc-worktree/feat-here-foo"
deleted_branch = false
repo_root = "{repo_root}"
[[op]]
ts = "2026-05-19T09:00:00Z"
kind = "remove"
worktree = "feat-elsewhere-bar"
branch = "feat/#2-elsewhere"
branch_oid = "b2c3d4e5f60718293a4b5c6d7e8f9012345678a1"
path = "/tmp/cc-worktree/feat-elsewhere-bar"
deleted_branch = false
repo_root = "/nonexistent/other-repo"
"#,
repo_root = toml_basic_string(&repo_root),
);
std::fs::create_dir_all(history_file.parent().unwrap()).unwrap();
std::fs::write(&history_file, body).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_HISTORY_FILE", &history_file)
.args(["history", "--all"])
.assert()
.success()
.stdout(predicate::str::contains("feat-here-foo"))
.stdout(predicate::str::contains("feat-elsewhere-bar"));
}
fn make_feature_branch_with_commit(
repo: &git2::Repository,
workdir: &std::path::Path,
branch: &str,
filename: &str,
contents: &str,
message: &str,
) {
let head = repo.head().unwrap().peel_to_commit().unwrap();
let branch_ref = repo.branch(branch, &head, false).unwrap();
let ref_name = branch_ref.into_reference().name().unwrap().to_string();
repo.set_head(&ref_name).unwrap();
repo
.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))
.unwrap();
let dest = workdir.join(filename);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&dest, contents).unwrap();
let mut index = repo.index().unwrap();
index.add_path(std::path::Path::new(filename)).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = git2::Signature::now("gwm-test", "gwm@test").unwrap();
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&head]).unwrap();
}
#[test]
fn pr_render_prints_body_with_placeholders_substituted() {
let (dir, repo) = init_repo();
make_feature_branch_with_commit(
&repo,
dir.path(),
"feat/#84-pr-templates",
"docs/note.md",
"hello pr\n",
"✨ feat: pr templates",
);
let body = r###"
[pr_template.by_type.feat]
body = """
## Summary
{desc} (#{issue})
## Commits
{commits}
## Files changed
{files_changed}
"""
"###;
std::fs::write(dir.path().join(".gwm.toml"), body).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["pr", "--render"])
.assert()
.success()
.stdout(predicate::str::contains("pr-templates (#84)"))
.stdout(predicate::str::contains("- ✨ feat: pr templates"))
.stdout(predicate::str::contains("docs/note.md"));
}
#[test]
fn pr_creates_pull_request_via_gh() {
let (dir, repo) = init_repo();
make_feature_branch_with_commit(
&repo,
dir.path(),
"feat/#84-pr-templates",
"src/x.rs",
"fn x() {}\n",
"✨ feat: x",
);
let fake_bin = tempfile::TempDir::new().unwrap();
let fake_gh = write_fake_gh(fake_bin.path(), "https://github.com/acme/widgets/pull/321\n");
let body = r###"
[pr_template.by_type.feat]
body = "## Summary\n{desc} (#{issue})\n"
"###;
std::fs::write(dir.path().join(".gwm.toml"), body).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_GH", &fake_gh)
.env("PATH", prepend_path(fake_bin.path()))
.args(["pr"])
.assert()
.success()
.stdout(predicate::str::contains("created PR #321"))
.stdout(predicate::str::contains("https://github.com/acme/widgets/pull/321"));
let gh_args_raw = std::fs::read_to_string(fake_bin.path().join("gh-args.txt")).unwrap();
let gh_args = gh_args_raw.replace('"', "");
assert!(gh_args.contains("pr create"), "{gh_args_raw}");
assert!(gh_args.contains("--head feat/#84-pr-templates"), "{gh_args_raw}");
let gh_body = std::fs::read_to_string(fake_bin.path().join("gh-body.md")).unwrap();
assert!(gh_body.contains("pr-templates (#84)"), "{gh_body}");
}
#[test]
fn pr_draft_flag_is_forwarded_to_gh() {
let (dir, repo) = init_repo();
make_feature_branch_with_commit(
&repo,
dir.path(),
"feat/#84-pr-templates",
"src/y.rs",
"fn y() {}\n",
"✨ feat: y",
);
let fake_bin = tempfile::TempDir::new().unwrap();
let fake_gh = write_fake_gh(fake_bin.path(), "https://github.com/acme/widgets/pull/777\n");
let body = r###"
[pr_template.by_type.feat]
body = "draft body for {desc}"
"###;
std::fs::write(dir.path().join(".gwm.toml"), body).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_GH", &fake_gh)
.env("PATH", prepend_path(fake_bin.path()))
.args(["pr", "--draft"])
.assert()
.success()
.stdout(predicate::str::contains("created PR #777"));
let gh_args_raw = std::fs::read_to_string(fake_bin.path().join("gh-args.txt")).unwrap();
let gh_args = gh_args_raw.replace('"', "");
assert!(gh_args.contains("--draft"), "{gh_args_raw}");
}
#[test]
fn pr_falls_back_to_common_trunk_when_configured_trunks_do_not_resolve() {
let (dir, repo) = init_repo();
make_feature_branch_with_commit(
&repo,
dir.path(),
"feat/#84-fallback",
"src/fallback.rs",
"fn fb() {}\n",
"✨ feat: fallback",
);
let body = r###"
[doctor]
trunks = ["nonexistent-trunk"]
[pr_template.by_type.feat]
body = "summary: {desc} (#{issue})\ncommits:\n{commits}\n"
"###;
std::fs::write(dir.path().join(".gwm.toml"), body).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["pr", "--render"])
.assert()
.success()
.stdout(predicate::str::contains("fallback (#84)"))
.stdout(predicate::str::contains("- ✨ feat: fallback"));
}
#[test]
fn pr_errors_when_no_template_configured() {
let (dir, repo) = init_repo();
make_feature_branch_with_commit(
&repo,
dir.path(),
"feat/#84-pr-templates",
"src/z.rs",
"fn z() {}\n",
"✨ feat: z",
);
std::fs::write(dir.path().join(".gwm.toml"), "").unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["pr", "--render"])
.assert()
.failure()
.stderr(predicate::str::contains("pr_template"))
.stderr(predicate::str::contains("feat"));
}
#[test]
fn tui_keys_lists_default_bindings() {
let (dir, _) = init_repo();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["tui", "keys"])
.assert()
.success()
.stdout(predicate::str::contains("down"))
.stdout(predicate::str::contains("j"))
.stdout(predicate::str::contains("top"))
.stdout(predicate::str::contains("g g"))
.stdout(predicate::str::contains("quit"))
.stdout(predicate::str::contains("default"));
}
#[test]
fn tui_keys_marks_user_overrides_in_source_column() {
let (dir, _) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
r#"
[tui.keys]
down = ["Ctrl+n"]
"#,
)
.unwrap();
let output = Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["tui", "keys"])
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
let down_line = stdout
.lines()
.find(|l| l.starts_with("down"))
.unwrap_or_else(|| panic!("expected a `down` row in:\n{stdout}"));
assert!(
down_line.contains("Ctrl+n"),
"expected `down` row to show the override, got: {down_line}"
);
assert!(
down_line.contains(".gwm.toml"),
"expected `down` row source to be `.gwm.toml`, got: {down_line}"
);
let up_line = stdout
.lines()
.find(|l| l.starts_with("up"))
.unwrap_or_else(|| panic!("expected an `up` row in:\n{stdout}"));
assert!(
up_line.contains("default"),
"expected `up` row source to stay `default`, got: {up_line}"
);
}
#[test]
fn theme_list_includes_builtin_presets() {
let (dir, _) = init_repo();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["theme", "list"])
.assert()
.success()
.stdout(predicate::str::contains("catppuccin"))
.stdout(predicate::str::contains("claude-dark"));
}
#[test]
fn theme_show_claude_dark_emits_the_orange_accent_as_hex() {
let (dir, _) = init_repo();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["theme", "show", "claude-dark"])
.assert()
.success()
.stdout(predicate::str::contains("[theme]"))
.stdout(predicate::str::contains("#d4825d"));
}
#[test]
fn theme_show_emits_toml_block_users_can_copy() {
let (dir, _) = init_repo();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["theme", "show", "catppuccin"])
.assert()
.success()
.stdout(predicate::str::contains("[theme]"))
.stdout(predicate::str::contains("focus"));
}
#[test]
fn theme_show_includes_name_and_path_chrome_roles() {
let (dir, _) = init_repo();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["theme", "show", "catppuccin"])
.assert()
.success()
.stdout(predicate::str::contains("name ="))
.stdout(predicate::str::contains("path ="));
}
#[test]
fn theme_show_includes_git_status_family_roles() {
let (dir, _) = init_repo();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["theme", "show", "catppuccin"])
.assert()
.success()
.stdout(predicate::str::contains("staged ="))
.stdout(predicate::str::contains("modified ="))
.stdout(predicate::str::contains("untracked ="));
}
#[test]
fn theme_show_rejects_unknown_preset() {
let (dir, _) = init_repo();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["theme", "show", "does-not-exist"])
.assert()
.failure()
.stderr(predicate::str::contains("does-not-exist"));
}
#[test]
fn theme_show_output_round_trips_through_gwm_toml() {
let (dir, _) = init_repo();
let output = Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["theme", "show", "catppuccin"])
.assert()
.success()
.get_output()
.clone();
let toml_block = String::from_utf8(output.stdout).unwrap();
let target = dir.path().join(".gwm.toml");
std::fs::write(&target, &toml_block).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["doctor"])
.assert()
.stderr(predicate::str::contains("invalid TOML").not())
.stderr(predicate::str::contains("config error").not());
}
#[test]
fn tui_keys_lists_modal_contexts() {
let (dir, _) = init_repo();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["tui", "keys"])
.assert()
.success()
.stdout(predicate::str::contains("[tui.keys.modal.confirm]"))
.stdout(predicate::str::contains("focus_confirm"))
.stdout(predicate::str::contains("[tui.keys.modal.link.choose_target]"));
}
fn repo_with_one_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
let (dir, _repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
write_test_config(dir.path(), base.path());
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["create", "feat", "1", "wt"])
.assert()
.success();
let wt = base.path().join("feat-1-wt");
assert!(wt.exists(), "worktree must exist for the exec/clean tests");
(dir, base, wt)
}
#[test]
fn exec_runs_the_command_inside_each_worktree() {
let (dir, _base, wt) = repo_with_one_worktree();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--", "sh", "-c", "echo hi > exec_marker.txt"])
.assert()
.success()
.stdout(predicate::str::contains("✓"))
.stdout(predicate::str::contains("feat-1-wt"));
assert!(
wt.join("exec_marker.txt").exists(),
"the command must run with the worktree as its working directory"
);
}
#[test]
fn exec_exits_nonzero_when_a_worktree_command_fails() {
let (dir, _base, _wt) = repo_with_one_worktree();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--", "sh", "-c", "exit 5"])
.assert()
.failure()
.stdout(predicate::str::contains("✗"))
.stdout(predicate::str::contains("exit 5"));
}
fn append_config(repo_root: &Path, snippet: &str) {
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(repo_root.join(".gwm.toml"))
.unwrap();
writeln!(f, "{snippet}").unwrap();
}
#[test]
fn exec_runs_a_named_profile_command() {
let (dir, _base, wt) = repo_with_one_worktree();
append_config(
dir.path(),
"[exec.profiles.greet]\ncommand = [\"sh\", \"-c\", \"echo hi > prof_marker.txt\"]\n",
);
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--profile", "greet"])
.assert()
.success()
.stdout(predicate::str::contains("✓"));
assert!(
wt.join("prof_marker.txt").exists(),
"the profile's command must run in the worktree"
);
}
#[test]
fn exec_jobs_runs_the_parallel_capture_path() {
let (dir, _base, wt) = repo_with_one_worktree();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args([
"exec",
"--jobs",
"2",
"--",
"sh",
"-c",
"echo blockline; echo hi > exec_jobs_marker.txt",
])
.assert()
.success()
.stdout(predicate::str::contains("blockline")) .stdout(predicate::str::contains("feat-1-wt"))
.stdout(predicate::str::contains("✓"));
assert!(
wt.join("exec_jobs_marker.txt").exists(),
"the command must still run under --jobs"
);
}
#[test]
fn exec_jobs_preserves_raw_binary_output() {
let (dir, _base, _wt) = repo_with_one_worktree();
let out = Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--jobs", "2", "--", "printf", "\\377"])
.output()
.unwrap();
assert!(out.status.success(), "exec should succeed: {out:?}");
assert!(
out.stdout.contains(&0xFFu8),
"the raw 0xFF byte must survive the capture, not be UTF-8-mangled"
);
}
#[test]
fn exec_inline_inside_bare_repo_does_not_require_a_workdir() {
let dir = tempfile::TempDir::new().unwrap();
git2::Repository::init_bare(dir.path()).expect("init bare repo");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--", "true"])
.assert()
.success()
.stdout(predicate::str::contains("no worktrees to run in"));
}
#[test]
fn exec_jobs_flag_skips_config_when_inline() {
let (dir, _base, _wt) = repo_with_one_worktree();
append_config(dir.path(), "[exec.profiles.bad]\ncommand = []\n");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--jobs", "2", "--", "true"])
.assert()
.success();
}
#[test]
fn exec_inline_jobs_default_tolerates_a_semantic_sibling_profile() {
let (dir, _base, _wt) = repo_with_one_worktree();
append_config(dir.path(), "[exec]\njobs = 1\n[exec.profiles.bad]\ncommand = []\n");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--", "true"])
.assert()
.success();
}
#[test]
fn exec_jobs_flag_parses_and_propagates_failure() {
let (dir, _base, _wt) = repo_with_one_worktree();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--jobs", "3", "--", "sh", "-c", "exit 4"])
.assert()
.failure()
.stdout(predicate::str::contains("✗"))
.stdout(predicate::str::contains("exit 4"));
}
#[test]
fn exec_inline_command_ignores_a_broken_gwm_toml() {
let (dir, _base, _wt) = repo_with_one_worktree();
append_config(dir.path(), "nonsense_key = true\n");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--", "sh", "-c", "true"])
.assert()
.success();
}
#[test]
fn exec_profile_tolerates_an_unrelated_config_error() {
let (dir, _base, _wt) = repo_with_one_worktree();
append_config(dir.path(), "nonsense_key = true\n");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--profile", "whatever"])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("no profile named `whatever`"));
}
#[test]
fn exec_profile_surfaces_a_malformed_exec_section() {
let (dir, _base, _wt) = repo_with_one_worktree();
append_config(dir.path(), "[exec.profiles.bad]\ncommand = [\"true\"]\nbogus = 1\n");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--profile", "bad"])
.assert()
.failure()
.code(1);
}
#[test]
fn exec_profile_and_inline_command_are_mutually_exclusive() {
let (dir, _base, _wt) = repo_with_one_worktree();
append_config(dir.path(), "[exec.profiles.t]\ncommand = [\"true\"]\n");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--profile", "t", "--", "echo", "hi"])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("mutually exclusive"));
}
#[test]
fn exec_unknown_profile_exits_one() {
let (dir, _base, _wt) = repo_with_one_worktree();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--profile", "ghost"])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("no profile named `ghost`"));
}
#[test]
fn clean_unknown_profile_exits_one() {
let (dir, _base, _wt) = repo_with_one_worktree();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["clean", "--profile", "ghost"])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("no profile named `ghost`"));
}
#[test]
fn clean_builtin_ignores_a_broken_gwm_toml() {
let (dir, _base, _wt) = repo_with_one_worktree();
append_config(dir.path(), "nonsense_key = true\n");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["clean"])
.assert()
.success();
}
#[test]
fn clean_honors_default_profile_despite_an_unrelated_config_error() {
let (dir, _base, wt) = repo_with_one_worktree();
std::fs::create_dir_all(wt.join("coverage")).unwrap();
std::fs::write(wt.join("coverage/lcov.info"), vec![0u8; 2048]).unwrap();
std::fs::write(wt.join(".gitignore"), "coverage/\n").unwrap();
append_config(
dir.path(),
"nonsense_key = true\n[clean.profiles.default]\ndirs = [\"coverage\"]\n",
);
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["clean"])
.assert()
.success()
.stdout(predicate::str::contains("coverage"));
}
#[test]
fn clean_surfaces_a_malformed_clean_section() {
let (dir, _base, _wt) = repo_with_one_worktree();
append_config(dir.path(), "[clean.profiles.broken]\n");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["clean"])
.assert()
.failure()
.code(1);
}
#[test]
fn clean_rejects_a_sibling_invalid_profile() {
let (dir, _base, _wt) = repo_with_one_worktree();
append_config(
dir.path(),
"[clean.profiles.good]\ndirs = [\"target\"]\n[clean.profiles.bad]\ndirs = [\"..\"]\n",
);
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["clean", "--profile", "good"])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains(".."));
}
#[test]
fn exec_rejects_a_sibling_invalid_profile() {
let (dir, _base, _wt) = repo_with_one_worktree();
append_config(
dir.path(),
"[exec.profiles.good]\ncommand = [\"true\"]\n[exec.profiles.bad]\ncommand = []\n",
);
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--profile", "good"])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("empty `command`"));
}
#[test]
fn clean_profile_with_an_unsafe_dir_exits_one() {
let (dir, _base, _wt) = repo_with_one_worktree();
append_config(dir.path(), "[clean.profiles.evil]\ndirs = [\"..\"]\n");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["clean", "--profile", "evil"])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("escape the worktree"));
}
#[test]
fn clean_named_profile_scopes_the_reclaim_to_its_dirs() {
let (dir, _base, wt) = repo_with_one_worktree();
std::fs::create_dir_all(wt.join("coverage")).unwrap();
std::fs::write(wt.join("coverage/lcov.info"), vec![0u8; 2048]).unwrap();
std::fs::write(wt.join(".gitignore"), "coverage/\n").unwrap();
append_config(dir.path(), "[clean.profiles.cov]\ndirs = [\"coverage\"]\n");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["clean", "--profile", "cov"])
.assert()
.success()
.stdout(predicate::str::contains("coverage"));
}
#[test]
fn clean_reclaims_a_leading_dash_profile_dir() {
let (dir, _base, wt) = repo_with_one_worktree();
std::fs::create_dir_all(wt.join("-cache")).unwrap();
std::fs::write(wt.join("-cache/blob.bin"), vec![0u8; 2048]).unwrap();
std::fs::write(wt.join(".gitignore"), "/-cache/\n").unwrap();
append_config(dir.path(), "[clean.profiles.dash]\ndirs = [\"-cache\"]\n");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["clean", "--profile", "dash", "--yes"])
.assert()
.success();
assert!(
!wt.join("-cache").exists(),
"a git-ignored `-cache` profile dir must be reclaimed, not skipped"
);
}
#[test]
fn clean_reports_artifacts_without_deleting_by_default() {
let (dir, _base, wt) = repo_with_one_worktree();
fs::write(wt.join(".gitignore"), "/target\n").unwrap();
fs::create_dir_all(wt.join("target")).unwrap();
fs::write(wt.join("target").join("blob.bin"), vec![0u8; 4096]).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["clean"])
.assert()
.success()
.stdout(predicate::str::contains("target"))
.stdout(predicate::str::contains("re-run with --yes"));
assert!(wt.join("target").exists(), "report-only mode must not delete anything");
}
#[test]
fn clean_preview_excludes_non_deletable_dirs_from_total() {
let (dir, _base, wt) = repo_with_one_worktree();
fs::create_dir_all(wt.join("dist")).unwrap();
fs::write(wt.join("dist").join("keep.txt"), b"hand-authored").unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["clean"])
.assert()
.success()
.stdout(predicate::str::contains("skipped"))
.stdout(predicate::str::contains("dist"))
.stdout(predicate::str::contains("nothing to reclaim"))
.stdout(predicate::str::contains("re-run with --yes").not());
}
#[test]
fn clean_yes_deletes_gitignored_artifacts() {
let (dir, _base, wt) = repo_with_one_worktree();
fs::write(wt.join(".gitignore"), "/target\n").unwrap();
fs::create_dir_all(wt.join("target")).unwrap();
fs::write(wt.join("target").join("blob.bin"), vec![0u8; 4096]).unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["clean", "--yes"])
.assert()
.success()
.stdout(predicate::str::contains("reclaimed"));
assert!(
!wt.join("target").exists(),
"a git-ignored target/ should be deleted with --yes"
);
}
#[test]
fn clean_yes_refuses_to_delete_non_ignored_artifacts() {
let (dir, _base, wt) = repo_with_one_worktree();
fs::create_dir_all(wt.join("dist")).unwrap();
fs::write(wt.join("dist").join("keep.txt"), b"non-regenerable work").unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["clean", "--yes"])
.assert()
.success()
.stdout(predicate::str::contains("skipped"))
.stdout(predicate::str::contains("dist"));
assert!(wt.join("dist").exists(), "a non-ignored dist/ must be preserved");
assert!(
wt.join("dist").join("keep.txt").exists(),
"hand-authored content under a non-ignored dir must survive --yes"
);
}
#[test]
fn clean_yes_refuses_ignored_dir_holding_tracked_files() {
let (dir, _base, wt) = repo_with_one_worktree();
fs::write(wt.join(".gitignore"), "/dist\n").unwrap();
fs::create_dir_all(wt.join("dist")).unwrap();
fs::write(wt.join("dist").join("index.html"), b"tracked output").unwrap();
std::process::Command::new("git")
.current_dir(&wt)
.args(["add", "-f", "dist/index.html"])
.status()
.expect("git add -f");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["clean", "--yes"])
.assert()
.success()
.stdout(predicate::str::contains("skipped"))
.stdout(predicate::str::contains("dist"));
assert!(
wt.join("dist").join("index.html").exists(),
"a tracked file under an ignored dir must survive --yes"
);
}
fn git_at(dir: &Path, args: &[&str]) {
let ok = std::process::Command::new("git")
.current_dir(dir)
.args(["-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false"])
.args(args)
.status()
.unwrap()
.success();
assert!(ok, "git {args:?} in {} failed", dir.display());
}
fn workspace_with_worktrees() -> tempfile::TempDir {
let root = tempfile::TempDir::new().unwrap();
for (repo, wt) in [("alpha", "alpha-wt"), ("beta", "beta-wt")] {
let repo_dir = root.path().join(repo);
std::fs::create_dir_all(&repo_dir).unwrap();
git_at(&repo_dir, &["init", "-b", "main"]);
git_at(&repo_dir, &["config", "user.email", "t@t.t"]);
git_at(&repo_dir, &["config", "user.name", "t"]);
std::fs::write(repo_dir.join("README.md"), "x").unwrap();
git_at(&repo_dir, &["add", "."]);
git_at(&repo_dir, &["commit", "-m", "init"]);
let wt_path = root.path().join(wt);
git_at(
&repo_dir,
&["worktree", "add", "-b", "feat/x", wt_path.to_str().unwrap()],
);
}
root
}
fn workspace_main_only() -> tempfile::TempDir {
let root = tempfile::TempDir::new().unwrap();
for repo in ["alpha", "beta"] {
let repo_dir = root.path().join(repo);
std::fs::create_dir_all(&repo_dir).unwrap();
git_at(&repo_dir, &["init", "-b", "main"]);
git_at(&repo_dir, &["config", "user.email", "t@t.t"]);
git_at(&repo_dir, &["config", "user.name", "t"]);
std::fs::write(repo_dir.join("README.md"), "x").unwrap();
git_at(&repo_dir, &["add", "."]);
git_at(&repo_dir, &["commit", "-m", "init"]);
}
root
}
#[test]
fn exec_fans_out_across_workspace_child_repos() {
let root = workspace_with_worktrees();
Command::cargo_bin("gwm")
.unwrap()
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--workspace"])
.arg(root.path())
.args(["--", "sh", "-c", "echo ran > ws_exec_marker.txt"])
.assert()
.success()
.stdout(predicate::str::contains("══ alpha"))
.stdout(predicate::str::contains("══ beta"))
.stdout(predicate::str::contains("✓ alpha/"))
.stdout(predicate::str::contains("✓ beta/"));
assert!(root.path().join("alpha-wt/ws_exec_marker.txt").exists());
assert!(root.path().join("beta-wt/ws_exec_marker.txt").exists());
}
#[test]
fn exec_workspace_scopes_to_a_matching_slug() {
let root = workspace_with_worktrees();
Command::cargo_bin("gwm")
.unwrap()
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--workspace"])
.arg(root.path())
.args(["alpha-wt", "--", "sh", "-c", "echo hi > scoped.txt"])
.assert()
.success();
assert!(root.path().join("alpha-wt/scoped.txt").exists(), "alpha matched");
assert!(
!root.path().join("beta-wt/scoped.txt").exists(),
"beta did not match the slug"
);
}
#[test]
fn exec_workspace_scoped_slug_ignores_an_unrelated_repos_missing_profile() {
let root = workspace_with_worktrees();
std::fs::write(
root.path().join("alpha/.gwm.toml"),
"[exec.profiles.fmt]\ncommand = [\"sh\", \"-c\", \"echo hi > prof.txt\"]\n",
)
.unwrap();
Command::cargo_bin("gwm")
.unwrap()
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--workspace"])
.arg(root.path())
.args(["alpha-wt", "--profile", "fmt"])
.assert()
.success();
assert!(root.path().join("alpha-wt/prof.txt").exists(), "alpha's profile ran");
}
#[test]
fn exec_workspace_errors_on_a_slug_matching_no_child_repo() {
let root = workspace_with_worktrees();
Command::cargo_bin("gwm")
.unwrap()
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--workspace"])
.arg(root.path())
.args(["ghost-typo", "--", "true"])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("ghost-typo"));
}
#[test]
fn exec_workspace_aggregates_a_nonzero_exit() {
let root = workspace_with_worktrees();
Command::cargo_bin("gwm")
.unwrap()
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--workspace"])
.arg(root.path())
.args(["--", "sh", "-c", "exit 3"])
.assert()
.failure()
.stdout(predicate::str::contains("✗ alpha/"))
.stdout(predicate::str::contains("✗ beta/"));
}
#[test]
fn clean_fans_out_across_workspace_child_repos() {
let root = workspace_with_worktrees();
for (repo, wt) in [("alpha", "alpha-wt"), ("beta", "beta-wt")] {
let _ = repo;
let wtdir = root.path().join(wt);
std::fs::create_dir_all(wtdir.join("target")).unwrap();
std::fs::write(wtdir.join("target/blob.bin"), vec![0u8; 4096]).unwrap();
std::fs::write(wtdir.join(".gitignore"), "/target\n").unwrap();
}
Command::cargo_bin("gwm")
.unwrap()
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["clean", "--workspace"])
.arg(root.path())
.assert()
.success()
.stdout(predicate::str::contains("alpha/"))
.stdout(predicate::str::contains("beta/"))
.stdout(predicate::str::contains("re-run with --yes"));
assert!(
root.path().join("alpha-wt/target").exists(),
"report-only keeps target/"
);
}
#[test]
fn clean_workspace_yes_deletes_in_every_child_repo() {
let root = workspace_with_worktrees();
for wt in ["alpha-wt", "beta-wt"] {
let wtdir = root.path().join(wt);
std::fs::create_dir_all(wtdir.join("target")).unwrap();
std::fs::write(wtdir.join("target/blob.bin"), vec![0u8; 4096]).unwrap();
std::fs::write(wtdir.join(".gitignore"), "/target\n").unwrap();
}
Command::cargo_bin("gwm")
.unwrap()
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["clean", "--workspace"])
.arg(root.path())
.arg("--yes")
.assert()
.success()
.stdout(predicate::str::contains("reclaimed"));
assert!(!root.path().join("alpha-wt/target").exists());
assert!(!root.path().join("beta-wt/target").exists());
}
#[test]
fn clean_workspace_errors_before_deleting_on_a_corrupt_child_repo() {
let root = workspace_with_worktrees();
let wtdir = root.path().join("alpha-wt");
std::fs::create_dir_all(wtdir.join("target")).unwrap();
std::fs::write(wtdir.join("target/blob.bin"), vec![0u8; 4096]).unwrap();
std::fs::write(wtdir.join(".gitignore"), "/target\n").unwrap();
let corrupt = root.path().join("corrupt");
std::fs::create_dir_all(&corrupt).unwrap();
std::fs::write(corrupt.join(".git"), "gitdir: /nonexistent-gitdir\n").unwrap();
Command::cargo_bin("gwm")
.unwrap()
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["clean", "--workspace"])
.arg(root.path())
.arg("--yes")
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("corrupt"));
assert!(wtdir.join("target").exists(), "fan-out must fail before any deletion");
}
#[test]
fn exec_workspace_all_empty_errors_on_a_missing_command() {
let root = workspace_main_only();
Command::cargo_bin("gwm")
.unwrap()
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--workspace"])
.arg(root.path())
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("provide a command"));
}
#[test]
fn exec_workspace_all_empty_errors_on_an_unknown_profile() {
let root = workspace_main_only();
Command::cargo_bin("gwm")
.unwrap()
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--workspace"])
.arg(root.path())
.args(["--profile", "ghost"])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("no profile named `ghost`"));
}
#[test]
fn exec_workspace_all_empty_inline_command_exits_zero() {
let root = workspace_main_only();
Command::cargo_bin("gwm")
.unwrap()
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["exec", "--workspace"])
.arg(root.path())
.args(["--", "true"])
.assert()
.success()
.stdout(predicate::str::contains("no worktrees to run in"));
}
#[test]
fn clean_workspace_all_empty_errors_on_an_unknown_profile() {
let root = workspace_main_only();
Command::cargo_bin("gwm")
.unwrap()
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["clean", "--workspace"])
.arg(root.path())
.args(["--profile", "ghost", "--yes"])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("no profile named `ghost`"));
}
#[test]
fn workspace_refuses_a_still_unsupported_subcommand() {
let (dir, _base, _wt) = repo_with_one_worktree();
let ws = tempfile::TempDir::new().unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["sync", "--workspace", ws.path().to_str().unwrap()])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("--workspace is only supported"));
}