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(" agents "))
.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::is_match(r"\[alias(es)?: cd\]").unwrap())
.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::is_match(r"\[alias(es)?: s\]").unwrap()));
}
#[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"));
}
#[cfg(unix)]
#[test]
fn list_detect_pr_dispatches_to_glab_on_a_gitlab_origin() {
let (dir, repo) = init_repo();
repo.remote("origin", "https://gitlab.com/group/proj.git").unwrap();
let fake_bin = tempfile::TempDir::new().unwrap();
let log = fake_bin.path().join("calls.log");
let fake_glab = write_recording_glab(
fake_bin.path(),
r#"[{"iid":128,"project_id":7,"source_project_id":7}]"#,
"{}",
);
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_GLAB", &fake_glab)
.env("GWM_FAKE_LOG", &log)
.env("PATH", prepend_path(fake_bin.path()))
.args(["list", "--detect-pr"])
.assert()
.success()
.stdout(predicate::str::contains("#128"));
let calls = fs::read_to_string(&log).unwrap();
assert!(calls.contains("ARGV:mr list"), "must speak MR, not PR: {calls}");
assert!(
calls.contains("--repo group/proj"),
"must target the origin slug: {calls}"
);
}
#[cfg(unix)]
#[test]
fn the_glab_child_does_not_inherit_the_environment_that_would_retarget_it() {
let (dir, repo) = init_repo();
repo.remote("origin", "https://gitlab.com/group/proj.git").unwrap();
let fake_bin = tempfile::TempDir::new().unwrap();
let log = fake_bin.path().join("calls.log");
let fake_glab = write_recording_glab(fake_bin.path(), "[]", "{}");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_GLAB", &fake_glab)
.env("GWM_FAKE_LOG", &log)
.env("PATH", prepend_path(fake_bin.path()))
.env("GITLAB_REPO", "someone-else/private")
.env("GITLAB_GROUP", "someone-else")
.env("GITLAB_TOKEN", "keep-me")
.env("GITLAB_API_HOST", "https://api.gitlab.example.com")
.env("GL_HOST", "https://gitlab.other.example")
.env("API_PROTOCOL", "http")
.args(["list", "--detect-pr"])
.assert()
.success();
let calls = fs::read_to_string(&log).unwrap();
assert!(
calls.contains("GITLAB_REPO:<unset>"),
"selector must be cleared: {calls}"
);
assert!(
calls.contains("GITLAB_GROUP:<unset>"),
"selector must be cleared: {calls}"
);
assert!(
calls.contains("GITLAB_HOST:https://gitlab.com"),
"an authoritative origin must be pinned: {calls}"
);
assert!(
calls.contains("GITLAB_TOKEN:keep-me"),
"authentication is the user's to set and must survive: {calls}"
);
assert!(
calls.contains("GITLAB_API_HOST:<unset>"),
"an inherited API host outranks the pin and must go: {calls}"
);
assert!(calls.contains("GL_HOST:<unset>"), "{calls}");
assert!(
calls.contains("API_PROTOCOL:https"),
"the scheme is pinned, not merely cleared: {calls}"
);
}
#[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
}
}
#[cfg(unix)]
fn write_recording_glab(root: &Path, mr_list_json: &str, api_json: &str) -> PathBuf {
let script = root.join("glab");
fs::write(
&script,
format!(
r#"#!/bin/sh
{{
echo "ARGV:$*"
echo "CWD:$(pwd)"
echo "GITLAB_HOST:${{GITLAB_HOST-<unset>}}"
echo "GITLAB_REPO:${{GITLAB_REPO-<unset>}}"
echo "GITLAB_GROUP:${{GITLAB_GROUP-<unset>}}"
echo "GITLAB_API_HOST:${{GITLAB_API_HOST-<unset>}}"
echo "GL_HOST:${{GL_HOST-<unset>}}"
echo "API_PROTOCOL:${{API_PROTOCOL-<unset>}}"
echo "GITLAB_TOKEN:${{GITLAB_TOKEN-<unset>}}"
echo "GLAB_DEBUG_HTTP:${{GLAB_DEBUG_HTTP-<unset>}}"
}} >> "$GWM_FAKE_LOG"
if [ "$1" = "mr" ] && [ "$2" = "list" ]; then
printf '%s' '{list}'
elif [ "$1" = "api" ]; then
# Drain the request body so a test can assert what travelled on the
# pipe rather than on the command line (issue #459).
cat > "$GWM_FAKE_STDIN"
printf '%s' '{api}'
fi
"#,
list = mr_list_json.replace('\'', "'\\''"),
api = api_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
}
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_bootstrap_without_trust_aborts() {
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 ledger_dir = tempfile::TempDir::new().unwrap();
let ledger = ledger_dir.path().join("trust.toml");
let body = format!(
r#"
[worktree]
base = "{base}"
path_pattern = "{{type}}-{{issue}}-{{desc}}"
branch_pattern = "{{type}}/#{{issue}}-{{desc}}"
[[bootstrap.command]]
name = "trap"
run = "echo trapped"
"#,
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_TRUST_LEDGER", &ledger)
.env("GWM_HISTORY_FILE", &history_file)
.args(["create", "feat", "338", "undo-trust", "--no-bootstrap"])
.assert()
.success();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_TRUST_LEDGER", &ledger)
.env("GWM_HISTORY_FILE", &history_file)
.args(["remove", "feat-338-undo-trust"])
.assert()
.success();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_TRUST_LEDGER", &ledger)
.env("GWM_HISTORY_FILE", &history_file)
.args(["undo", "--bootstrap"])
.assert()
.failure()
.stderr(
predicate::str::contains("not in the trust ledger").or(predicate::str::contains("stdin is not interactive")),
);
let wt_path = base.path().join("feat-338-undo-trust");
assert!(
!wt_path.exists(),
"a denied trust gate must NOT restore the worktree — undo must stay retryable"
);
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_TRUST_LEDGER", &ledger)
.env("GWM_HISTORY_FILE", &history_file)
.arg("undo")
.assert()
.success();
assert!(
wt_path.exists(),
"retry undo must restore the worktree — the entry was preserved"
);
}
#[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}");
}
#[cfg(unix)]
#[test]
fn pr_body_travels_on_stdin_and_never_reaches_the_glab_argv() {
let (dir, repo) = init_repo();
repo.remote("origin", "https://gitlab.com/group/proj.git").unwrap();
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 log = fake_bin.path().join("calls.log");
let stdin_dump = fake_bin.path().join("stdin.json");
let fake_glab = write_recording_glab(
fake_bin.path(),
"[]",
r#"{"iid":321,"web_url":"https://gitlab.com/group/proj/-/merge_requests/321"}"#,
);
std::fs::write(
dir.path().join(".gwm.toml"),
"[pr_template.by_type.feat]\nbody = \"## Summary\\nSUPER-SECRET-BODY (#{issue})\\n\"\n",
)
.unwrap();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_GLAB", &fake_glab)
.env("GWM_FAKE_LOG", &log)
.env("GWM_FAKE_STDIN", &stdin_dump)
.env("GLAB_DEBUG_HTTP", "1")
.env("PATH", prepend_path(fake_bin.path()))
.args(["pr"])
.assert()
.success()
.stdout(predicate::str::contains("created MR #321"))
.stdout(predicate::str::contains(
"https://gitlab.com/group/proj/-/merge_requests/321",
));
let calls = fs::read_to_string(&log).unwrap();
assert!(
!calls.contains("SUPER-SECRET-BODY"),
"the body must not be visible in the argv: {calls}"
);
assert!(calls.contains("--input -"), "{calls}");
assert!(
calls.contains("GLAB_DEBUG_HTTP:<unset>"),
"the HTTP debug dump would print the body straight back out: {calls}"
);
let sent: serde_json::Value = serde_json::from_str(&fs::read_to_string(&stdin_dump).unwrap()).unwrap();
assert!(
sent["description"].as_str().unwrap().contains("SUPER-SECRET-BODY"),
"{sent}"
);
assert_eq!(sent["source_branch"], "feat/#84-pr-templates");
}
#[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"));
}
mod agents_cmd {
use super::*;
use common::init_repo;
use std::path::Path;
fn seed_codex(home: &Path, cwd: &Path, sid: &str) {
let dir = home
.join(".codex/sessions")
.join(gwm::agent_sessions::codex_day_dir(std::time::SystemTime::now()));
std::fs::create_dir_all(&dir).unwrap();
let cwd = cwd.display().to_string().replace('\\', "\\\\");
let line = format!(
r#"{{"timestamp":"2026-07-22T10:00:00.000Z","type":"session_meta","payload":{{"session_id":"{sid}","cwd":"{cwd}"}}}}"#,
);
std::fs::write(dir.join(format!("rollout-{sid}.jsonl")), format!("{line}\n")).unwrap();
}
fn gwm_in(dir: &Path, home: &Path) -> Command {
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd.current_dir(dir).env("GWM_AGENTS_HOME", home);
cmd
}
#[test]
fn opencode_db_sessions_are_read_without_a_sqlite3_cli() {
let (repo_dir, _repo) = init_repo();
let home = tempfile::TempDir::new().unwrap();
let dir = home.path().join(".local/share/opencode");
std::fs::create_dir_all(dir.join("storage/project")).unwrap();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let conn = rusqlite::Connection::open(dir.join("opencode.db")).unwrap();
conn
.execute_batch(
"CREATE TABLE session (id text, parent_id text, directory text, title text, time_updated integer, time_archived integer);",
)
.unwrap();
conn
.execute(
"INSERT INTO session VALUES (?1, NULL, ?2, ?3, ?4, NULL)",
rusqlite::params![
"oc-nocli",
repo_dir.path().display().to_string(),
"db without cli",
now_ms
],
)
.unwrap();
drop(conn);
gwm_in(repo_dir.path(), home.path())
.env("PATH", "")
.arg("agents")
.assert()
.success()
.stdout(predicate::str::contains("opencode"))
.stdout(predicate::str::contains("db without cli"));
}
#[test]
fn attach_resolves_exact_names_before_substrings() {
let (repo_dir, _repo) = init_repo();
let home = tempfile::TempDir::new().unwrap();
let side = tempfile::TempDir::new().unwrap();
let foo = side.path().join("foo");
let foo_extra = side.path().join("foo-extra");
git_at(
repo_dir.path(),
&["worktree", "add", "-b", "b-foo", foo.to_str().unwrap()],
);
git_at(
repo_dir.path(),
&["worktree", "add", "-b", "b-foo-extra", foo_extra.to_str().unwrap()],
);
seed_codex(home.path(), &foo, "ffff-exact");
gwm_in(repo_dir.path(), home.path())
.args(["agents", "attach", "foo", "ffff-exact"])
.assert()
.success();
}
#[test]
fn opencode_detection_honors_xdg_data_home() {
let (repo_dir, _repo) = init_repo();
let xdg = tempfile::TempDir::new().unwrap();
let dir = xdg.path().join("opencode");
std::fs::create_dir_all(dir.join("storage/project")).unwrap();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let conn = rusqlite::Connection::open(dir.join("opencode.db")).unwrap();
conn
.execute_batch(
"CREATE TABLE session (id text, parent_id text, directory text, title text, time_updated integer, time_archived integer);",
)
.unwrap();
conn
.execute(
"INSERT INTO session VALUES (?1, NULL, ?2, ?3, ?4, NULL)",
rusqlite::params!["oc-xdg", repo_dir.path().display().to_string(), "xdg session", now_ms],
)
.unwrap();
drop(conn);
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(repo_dir.path())
.env_remove("GWM_AGENTS_HOME")
.env("XDG_DATA_HOME", xdg.path())
.arg("agents")
.assert()
.success()
.stdout(predicate::str::contains("opencode"))
.stdout(predicate::str::contains("xdg session"));
}
#[test]
fn gwm_agents_home_beats_an_inherited_xdg_data_home() {
let (repo_dir, _repo) = init_repo();
let home = tempfile::TempDir::new().unwrap();
let xdg = tempfile::TempDir::new().unwrap(); let dir = home.path().join(".local/share/opencode");
std::fs::create_dir_all(dir.join("storage/project")).unwrap();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let conn = rusqlite::Connection::open(dir.join("opencode.db")).unwrap();
conn
.execute_batch(
"CREATE TABLE session (id text, parent_id text, directory text, title text, time_updated integer, time_archived integer);",
)
.unwrap();
conn
.execute(
"INSERT INTO session VALUES (?1, NULL, ?2, ?3, ?4, NULL)",
rusqlite::params!["oc-seam", repo_dir.path().display().to_string(), "seam wins", now_ms],
)
.unwrap();
drop(conn);
gwm_in(repo_dir.path(), home.path())
.env("XDG_DATA_HOME", xdg.path())
.arg("agents")
.assert()
.success()
.stdout(predicate::str::contains("seam wins"));
}
#[test]
fn agents_lists_detected_sessions_per_worktree() {
let (repo_dir, _repo) = init_repo();
let home = tempfile::TempDir::new().unwrap();
seed_codex(home.path(), repo_dir.path(), "0000-cafe");
gwm_in(repo_dir.path(), home.path())
.arg("agents")
.assert()
.success()
.stdout(predicate::str::contains("codex"))
.stdout(predicate::str::contains("0000-cafe"))
.stdout(predicate::str::contains("active"))
.stdout(predicate::str::contains("ago"));
}
#[test]
fn list_hides_the_agent_column_without_any_detected_session() {
let (repo_dir, _repo) = init_repo();
let home = tempfile::TempDir::new().unwrap();
gwm_in(repo_dir.path(), home.path())
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("AGENT").not());
}
#[test]
fn list_shows_the_agent_column_when_a_session_is_detected() {
let (repo_dir, _repo) = init_repo();
let home = tempfile::TempDir::new().unwrap();
seed_codex(home.path(), repo_dir.path(), "1111-cafe");
gwm_in(repo_dir.path(), home.path())
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("AGENT"))
.stdout(predicate::str::contains("codex"));
}
#[test]
fn agents_lists_unmatched_sessions_in_a_dedicated_section() {
let (repo_dir, _repo) = init_repo();
let home = tempfile::TempDir::new().unwrap();
seed_codex(home.path(), Path::new("/somewhere/else"), "9999-lost");
gwm_in(repo_dir.path(), home.path())
.arg("agents")
.assert()
.success()
.stdout(predicate::str::contains("unmatched"))
.stdout(predicate::str::contains("9999-lost"));
}
#[test]
fn agents_omits_the_unmatched_section_when_every_session_is_matched() {
let (repo_dir, _repo) = init_repo();
let home = tempfile::TempDir::new().unwrap();
seed_codex(home.path(), repo_dir.path(), "0000-cafe");
gwm_in(repo_dir.path(), home.path())
.arg("agents")
.assert()
.success()
.stdout(predicate::str::contains("unmatched").not());
}
#[test]
fn agents_listing_prefers_the_session_name_when_present() {
let (repo_dir, _repo) = init_repo();
let home = tempfile::TempDir::new().unwrap();
let dir = home
.path()
.join(".codex/sessions")
.join(gwm::agent_sessions::codex_day_dir(std::time::SystemTime::now()));
std::fs::create_dir_all(&dir).unwrap();
let cwd = repo_dir.path().display().to_string().replace('\\', "\\\\");
let meta = format!(r#"{{"type":"session_meta","payload":{{"session_id":"named-1","cwd":"{cwd}"}}}}"#);
let user = r#"{"type":"event_msg","payload":{"type":"user_message","message":"refactor the login flow"}}"#;
std::fs::write(
dir.join("rollout-named.jsonl"),
format!(
"{meta}
{user}
"
),
)
.unwrap();
gwm_in(repo_dir.path(), home.path())
.arg("agents")
.assert()
.success()
.stdout(predicate::str::contains("refactor the login flow"))
.stdout(predicate::str::contains("named-1"));
}
#[test]
fn pinned_marker_is_scoped_to_the_pinned_worktree_only() {
let (repo_dir, repo) = init_repo();
let home = tempfile::TempDir::new().unwrap();
seed_codex(home.path(), repo_dir.path(), "4444-cafe");
let wt_b = repo_dir.path().join("wt-b");
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feat/b", &head, false).unwrap();
let mut opts = git2::WorktreeAddOptions::new();
let branch = repo.find_branch("feat/b", git2::BranchType::Local).unwrap();
opts.reference(Some(branch.get()));
repo.worktree("wt-b", &wt_b, Some(&opts)).unwrap();
}
gwm_in(repo_dir.path(), home.path())
.args(["agents", "attach", "wt-b", "4444-cafe"])
.assert()
.success();
let out = gwm_in(repo_dir.path(), home.path())
.arg("agents")
.assert()
.success()
.get_output()
.stdout
.clone();
let text = String::from_utf8_lossy(&out);
let blocks: Vec<&str> = text.split("\n\n").collect();
let _ = blocks; let pinned_lines: Vec<&str> = text.lines().filter(|l| l.contains("pinned")).collect();
assert_eq!(pinned_lines.len(), 1, "exactly one pinned line, got: {text}");
}
#[test]
fn attach_accumulates_pins_and_detach_removes_one_or_all() {
let (repo_dir, _repo) = init_repo();
let home = tempfile::TempDir::new().unwrap();
seed_codex(home.path(), repo_dir.path(), "aaaa-multi");
seed_codex(home.path(), repo_dir.path(), "bbbb-multi");
for sid in ["aaaa-multi", "bbbb-multi"] {
gwm_in(repo_dir.path(), home.path())
.args(["agents", "attach", ".", sid])
.assert()
.success();
}
let out = gwm_in(repo_dir.path(), home.path())
.arg("agents")
.assert()
.success()
.get_output()
.stdout
.clone();
let text = String::from_utf8_lossy(&out);
let pinned = text.lines().filter(|l| l.contains("pinned")).count();
assert_eq!(pinned, 2, "both pins marked, got: {text}");
gwm_in(repo_dir.path(), home.path())
.args(["agents", "detach", ".", "aaaa-multi"])
.assert()
.success();
let out = gwm_in(repo_dir.path(), home.path())
.arg("agents")
.assert()
.success()
.get_output()
.stdout
.clone();
let text = String::from_utf8_lossy(&out);
assert_eq!(text.lines().filter(|l| l.contains("pinned")).count(), 1, "got: {text}");
assert!(
text.lines().any(|l| l.contains("bbbb-multi") && l.contains("pinned")),
"the other pin survives: {text}"
);
gwm_in(repo_dir.path(), home.path())
.args(["agents", "detach", "."])
.assert()
.success();
let out = gwm_in(repo_dir.path(), home.path())
.arg("agents")
.assert()
.success()
.get_output()
.stdout
.clone();
assert!(!String::from_utf8_lossy(&out).contains("pinned"));
}
#[test]
fn workspace_list_table_carries_the_agent_column() {
let root = workspace_with_worktrees();
let home = tempfile::TempDir::new().unwrap();
seed_codex(home.path(), &root.path().join("alpha-wt"), "aaaa-ws");
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(root.path())
.env("GWM_AGENTS_HOME", home.path())
.args(["list", "--workspace", "."])
.assert()
.success()
.stdout(predicate::str::contains("AGENT"))
.stdout(predicate::str::contains("codex"));
}
#[test]
fn workspace_list_table_hides_the_agent_column_without_sessions() {
let root = workspace_with_worktrees();
let home = tempfile::TempDir::new().unwrap();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(root.path())
.env("GWM_AGENTS_HOME", home.path())
.args(["list", "--workspace", "."])
.assert()
.success()
.stdout(predicate::str::contains("AGENT").not())
.stdout(predicate::str::is_match(r"clean\s+-\s+/").unwrap().not());
}
#[test]
fn workspace_listings_honor_per_repo_pins() {
let root = workspace_with_worktrees();
let home = tempfile::TempDir::new().unwrap();
seed_codex(home.path(), &home.path().join("elsewhere"), "cccc-wspin");
let alpha_wt = root.path().join("alpha-wt");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(&alpha_wt)
.env("GWM_AGENTS_HOME", home.path())
.args(["agents", "attach", ".", "cccc-wspin"])
.assert()
.success();
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(root.path())
.env("GWM_AGENTS_HOME", home.path())
.args(["list", "--workspace", ".", "--format=json"])
.assert()
.success()
.stdout(predicate::str::contains("cccc-wspin"));
let mut cmd = Command::cargo_bin("gwm").unwrap();
cmd
.current_dir(root.path())
.env("GWM_AGENTS_HOME", home.path())
.args(["list", "--workspace", "."])
.assert()
.success()
.stdout(predicate::str::contains("codex"));
}
#[test]
fn agents_json_returns_the_wire_shape() {
let (repo_dir, _repo) = init_repo();
let home = tempfile::TempDir::new().unwrap();
seed_codex(home.path(), repo_dir.path(), "1111-cafe");
let out = gwm_in(repo_dir.path(), home.path())
.args(["agents", "--format=json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).expect("valid JSON");
let rows = v.as_array().expect("array of worktrees");
let with = rows
.iter()
.find(|r| r["agents"].is_object())
.expect("one row carries agents");
assert_eq!(with["agents"]["top"]["kind"], "codex");
assert_eq!(with["agents"]["top"]["id"], "1111-cafe");
}
#[test]
fn agents_with_nothing_detected_says_so_and_exits_zero() {
let (repo_dir, _repo) = init_repo();
let home = tempfile::TempDir::new().unwrap();
gwm_in(repo_dir.path(), home.path())
.arg("agents")
.assert()
.success()
.stdout(predicate::str::contains("no agent session"));
}
#[test]
fn attach_pins_an_unmatched_session_and_detach_restores_detection() {
let (repo_dir, _repo) = init_repo();
let home = tempfile::TempDir::new().unwrap();
seed_codex(home.path(), Path::new("/somewhere/else"), "2222-cafe");
gwm_in(repo_dir.path(), home.path())
.args(["agents", "attach", ".", "2222-cafe"])
.assert()
.success()
.stdout(predicate::str::contains("2222-cafe"));
gwm_in(repo_dir.path(), home.path())
.arg("agents")
.assert()
.success()
.stdout(predicate::str::contains("codex"))
.stdout(predicate::str::contains("pinned"));
let out = gwm_in(repo_dir.path(), home.path())
.args(["list", "--format=json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert!(
v.as_array()
.unwrap()
.iter()
.any(|r| r["agents"]["top"]["id"] == "2222-cafe"),
"pinned session must ride the list JSON"
);
gwm_in(repo_dir.path(), home.path())
.args(["agents", "detach", "."])
.assert()
.success();
gwm_in(repo_dir.path(), home.path())
.arg("agents")
.assert()
.success()
.stdout(predicate::str::contains("unmatched"))
.stdout(predicate::str::contains("2222-cafe"))
.stdout(predicate::str::contains("pinned").not());
}
#[test]
fn attach_unknown_session_id_fails_with_a_hint() {
let (repo_dir, _repo) = init_repo();
let home = tempfile::TempDir::new().unwrap();
gwm_in(repo_dir.path(), home.path())
.args(["agents", "attach", ".", "nope-nope"])
.assert()
.failure()
.stderr(predicate::str::contains("gwm agents"));
}
#[test]
fn plain_list_table_carries_the_agent_column() {
let (repo_dir, _repo) = init_repo();
let home = tempfile::TempDir::new().unwrap();
seed_codex(home.path(), repo_dir.path(), "3333-cafe");
gwm_in(repo_dir.path(), home.path())
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("AGENT"))
.stdout(predicate::str::contains("codex"));
}
}
#[test]
fn a_link_written_after_a_backend_flip_survives_the_next_command() {
let (dir, repo) = init_repo();
repo
.remote("origin", "https://git.acme.internal/team/proj.git")
.unwrap();
let branch = repo.head().unwrap().shorthand().unwrap().to_string();
let ledger = dir.path().join("trust.toml");
let gwm = |args: &[&str]| {
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_TRUST_LEDGER", &ledger)
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(args)
.assert()
.success();
};
fs::write(dir.path().join(".gwm.toml"), "forge = \"github\"\n").unwrap();
gwm(&["trust", "add"]);
gwm(&["link", "issue", "7"]);
gwm(&["open", "issue", "--print-url"]);
fs::write(dir.path().join(".gwm.toml"), "forge = \"gitlab\"\n").unwrap();
gwm(&["trust", "add"]);
gwm(&["link", "pr", "42"]);
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_TRUST_LEDGER", &ledger)
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(["open", "pr", "--print-url"])
.assert()
.success()
.stdout(predicate::str::contains("/-/merge_requests/42"));
let reopened = git2::Repository::open(dir.path()).unwrap();
assert_eq!(
gwm::github::read_link(&reopened, &branch).unwrap().pr,
Some(42),
"the user linked this under the new backend; nothing may drop it"
);
}
#[test]
fn a_repo_cannot_authorise_its_own_host_until_it_is_trusted() {
let (dir, repo) = init_repo();
repo
.remote("origin", "https://code.acme.internal/team/proj.git")
.unwrap();
fs::write(dir.path().join(".gwm.toml"), "forge = \"gitlab\"\n").unwrap();
let ledger = dir.path().join("trust.toml");
let run = |args: &[&str]| {
let mut c = Command::cargo_bin("gwm").unwrap();
c.current_dir(dir.path())
.env("GWM_TRUST_LEDGER", &ledger)
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(args);
c.assert()
};
run(&["link", "issue", "7"]).success();
run(&["open", "issue", "--print-url"])
.failure()
.stderr(predicate::str::contains("gwm trust add"));
run(&["trust", "add"]).success();
run(&["open", "issue", "--print-url"])
.success()
.stdout(predicate::str::contains("code.acme.internal"));
fs::write(dir.path().join(".gwm.toml"), "forge = \"gitlab\"\n# changed\n").unwrap();
run(&["open", "issue", "--print-url"]).failure();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_TRUST_LEDGER", &ledger)
.env("GWM_NO_GLOBAL_CONFIG", "1")
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["open", "issue", "--print-url"])
.assert()
.success();
}
#[test]
fn a_global_forge_kind_does_not_authorise_an_arbitrary_host() {
let (dir, repo) = init_repo();
repo
.remote("origin", "https://code.acme.internal/team/proj.git")
.unwrap();
let xdg = tempfile::TempDir::new().unwrap();
fs::create_dir_all(xdg.path().join("gwm")).unwrap();
fs::write(xdg.path().join("gwm").join("config.toml"), "forge = \"gitlab\"\n").unwrap();
let ledger = dir.path().join("trust.toml");
let run = |args: &[&str]| {
let mut c = Command::cargo_bin("gwm").unwrap();
c.current_dir(dir.path())
.env("GWM_TRUST_LEDGER", &ledger)
.env("XDG_CONFIG_HOME", xdg.path())
.env_remove("GWM_NO_GLOBAL_CONFIG")
.args(args);
c.assert()
};
run(&["link", "issue", "7"]).success();
run(&["open", "issue", "--print-url"])
.failure()
.stderr(predicate::str::contains("forge_hosts"));
}
#[test]
fn a_global_config_authorises_the_hosts_it_names_with_their_kind() {
let xdg = tempfile::TempDir::new().unwrap();
fs::create_dir_all(xdg.path().join("gwm")).unwrap();
fs::write(
xdg.path().join("gwm").join("config.toml"),
"[forge_hosts]\n\"Code.ACME.internal\" = \"gitlab\"\n\"ghe.acme.internal\" = \"github\"\n",
)
.unwrap();
let case = |url: &str, want: &str| {
let (dir, repo) = init_repo();
repo.remote("origin", url).unwrap();
let ledger = dir.path().join("trust.toml");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_TRUST_LEDGER", &ledger)
.env("XDG_CONFIG_HOME", xdg.path())
.env_remove("GWM_NO_GLOBAL_CONFIG")
.args(["link", "issue", "7"])
.assert()
.success();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_TRUST_LEDGER", &ledger)
.env("XDG_CONFIG_HOME", xdg.path())
.env_remove("GWM_NO_GLOBAL_CONFIG")
.args(["open", "issue", "--print-url"])
.assert()
.success()
.stdout(predicate::str::contains(want));
};
case(
"https://code.acme.internal/team/proj.git",
"code.acme.internal/team/proj/-/issues/7",
);
case(
"https://ghe.acme.internal/team/proj.git",
"ghe.acme.internal/team/proj/issues/7",
);
}
#[test]
fn approving_one_repo_does_not_approve_its_neighbour_on_the_same_host() {
let ledger = tempfile::TempDir::new().unwrap();
let ledger = ledger.path().join("trust.toml");
let config = "forge = \"gitlab\"\n";
let make = |slug: &str| {
let (dir, repo) = init_repo();
repo
.remote("origin", &format!("https://code.acme.internal/{slug}.git"))
.unwrap();
fs::write(dir.path().join(".gwm.toml"), config).unwrap();
dir
};
let mine = make("team/mine");
let theirs = make("team/theirs");
let run = |dir: &tempfile::TempDir, args: &[&str]| {
let mut c = Command::cargo_bin("gwm").unwrap();
c.current_dir(dir.path())
.env("GWM_TRUST_LEDGER", &ledger)
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(args);
c.assert()
};
run(&mine, &["link", "issue", "7"]).success();
run(&theirs, &["link", "issue", "7"]).success();
run(&mine, &["trust", "add"]).success();
run(&mine, &["open", "issue", "--print-url"]).success();
run(&theirs, &["open", "issue", "--print-url"])
.failure()
.stderr(predicate::str::contains("trust ledger"));
}
#[test]
fn trust_add_satisfies_the_gate_that_bootstrap_checks() {
let (dir, repo) = init_repo();
repo
.remote("origin", "https://code.acme.internal/team/proj.git")
.unwrap();
fs::write(
dir.path().join(".gwm.toml"),
"forge = \"gitlab\"\n\n[[bootstrap.command]]\nname = \"noop\"\nrun = \"true\"\n",
)
.unwrap();
let ledger = dir.path().join("trust.toml");
let run = |args: &[&str]| {
let mut c = Command::cargo_bin("gwm").unwrap();
c.current_dir(dir.path())
.env("GWM_TRUST_LEDGER", &ledger)
.env("GWM_NO_GLOBAL_CONFIG", "1")
.args(args);
c.assert()
};
run(&["trust", "add"]).success();
run(&["link", "issue", "7"]).success();
run(&["open", "issue", "--print-url"]).success();
run(&["bootstrap"]).success();
}
#[test]
fn create_name_flag_appears_in_help() {
Command::cargo_bin("gwm")
.unwrap()
.args(["create", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--name"));
}
#[test]
fn create_rejects_a_partial_triple() {
Command::cargo_bin("gwm")
.unwrap()
.args(["create", "feat", "42"])
.assert()
.failure()
.stderr(predicate::str::contains("<DESC>").or(predicate::str::contains("required")));
}
#[test]
fn create_name_conflicts_with_the_structured_triple() {
Command::cargo_bin("gwm")
.unwrap()
.args(["create", "--name", "spike-redis", "feat", "42", "x"])
.assert()
.failure()
.stderr(predicate::str::contains("cannot be used with").or(predicate::str::contains("conflict")));
}
#[test]
fn create_name_makes_a_worktree_whose_branch_is_the_name() {
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", "--name", "spike-redis"])
.assert()
.success()
.stdout(predicate::str::contains("spike-redis"))
.stdout(predicate::str::contains("worktree created"));
let wt_dir = base.path().join("spike-redis");
assert!(wt_dir.exists(), "worktree dir must be the name verbatim");
assert!(
repo.find_branch("spike-redis", git2::BranchType::Local).is_ok(),
"the branch must be the name verbatim"
);
}
#[test]
fn create_name_rejects_a_name_git_would_refuse() {
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", "--name", "has space"])
.assert()
.failure()
.stderr(predicate::str::contains("has space"));
}
#[test]
fn commit_prefix_reads_a_branch_written_with_a_custom_pattern() {
let (dir, _repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
"[worktree]\nbranch_pattern = \"{type}-{issue}-{desc}\"\n",
)
.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(":sparkles: feat(#41):"));
}
#[test]
fn commit_prefix_never_echoes_control_bytes_from_the_branch_pattern() {
let (dir, _repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
"[worktree]\nbranch_pattern = \"{type}/{desc}\\u001B]52;c;cHduZWQ=\\u0007\"\n",
)
.expect("seed .gwm.toml");
let assert = Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["commit-prefix", "--branch", "nothing-like-it"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assert.get_output().stderr).to_string();
assert!(
!stderr.chars().any(|c| c.is_control() && c != '\n'),
"no control character may reach the terminal: {:?}",
stderr
);
let assert = Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["commit-prefix", "--branch", "feat/x"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assert.get_output().stderr).to_string();
assert!(
!stderr.chars().any(|c| c.is_control() && c != '\n'),
"the second error quotes the pattern too: {:?}",
stderr
);
}
#[test]
fn pr_falls_back_to_chore_when_the_pattern_carries_no_type() {
let (dir, repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
"[worktree]\nbranch_pattern = \"{issue}-{desc}\"\n\n\
[pr_template.by_type.chore]\nbody = \"chore body for {desc} (#{issue})\\n\"\n",
)
.expect("seed .gwm.toml");
make_feature_branch_with_commit(&repo, dir.path(), "42-tidy-up", "note.md", "x\n", "🔧 chore: tidy");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.args(["pr", "--render"])
.assert()
.success()
.stdout(predicate::str::contains("chore body for tidy-up (#42)"));
}
#[test]
fn commit_prefix_reads_the_branch_of_the_worktree_it_runs_in() {
let (_dir, _base, wt) = repo_with_one_worktree();
Command::cargo_bin("gwm")
.unwrap()
.current_dir(&wt)
.args(["commit-prefix", "--unicode"])
.assert()
.success()
.stdout(predicate::str::contains("feat(#1):"));
}
#[test]
fn pr_reads_the_branch_of_the_worktree_it_runs_in() {
let (dir, _base, wt) = repo_with_one_worktree();
let cfg = dir.path().join(".gwm.toml");
let mut body = std::fs::read_to_string(&cfg).expect("seeded config");
body.push_str("\n[pr_template.by_type.feat]\nbody = \"GWM_PROBE head={head} type={type} issue={issue}\\n\"\n");
std::fs::write(&cfg, body).expect("append a template");
Command::cargo_bin("gwm")
.unwrap()
.current_dir(&wt)
.args(["pr", "--render"])
.assert()
.success()
.stdout(predicate::str::contains("GWM_PROBE head=feat/#1-wt type=feat issue=1"));
}
#[test]
fn only_the_branch_comes_from_the_worktree_the_repo_name_still_comes_from_the_main_checkout() {
let (dir, _repo) = init_repo();
let base = tempfile::TempDir::new().unwrap();
let repo_name = dir.path().file_name().unwrap().to_string_lossy().to_string();
std::fs::write(
dir.path().join(".gwm.toml"),
format!(
"[worktree]\nbase = \"{base}\"\n\
path_pattern = \"{{type}}-{{issue}}-{{desc}}\"\n\
branch_pattern = \"{{type}}/#{{issue}}-{{desc}}-{{repo}}\"\n",
base = toml_basic_string(base.path()),
),
)
.expect("seed .gwm.toml");
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.join(".git").exists(),
"precondition: the worktree exists and is named after `path_pattern`, not the repo"
);
assert_ne!(
repo_name, "feat-1-wt",
"precondition: the two names differ, or the assertion below proves nothing"
);
Command::cargo_bin("gwm")
.unwrap()
.current_dir(&wt)
.args(["commit-prefix", "--unicode"])
.assert()
.success()
.stdout(predicate::str::contains("feat(#1):"));
}
#[test]
fn bootstrap_gives_hooks_the_branch_of_the_worktree_it_targets() {
let (dir, _base, wt) = repo_with_one_worktree();
let cfg = dir.path().join(".gwm.toml");
let mut body = std::fs::read_to_string(&cfg).expect("seeded config");
body.push_str(
"\n[[hooks.post_bootstrap]]\nname = \"probe\"\n\
run = \"echo GWM_PROBE branch=[{branch}] type=[{type}] issue=[{issue}]\"\n",
);
std::fs::write(&cfg, body).expect("append a hook");
let expected = "GWM_PROBE branch=[feat/#1-wt] type=[feat] issue=[1]";
Command::cargo_bin("gwm")
.unwrap()
.current_dir(&wt)
.env("GWM_ALLOW_BOOTSTRAP", "1")
.arg("bootstrap")
.assert()
.success()
.stdout(predicate::str::contains(expected));
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.arg("bootstrap")
.arg(&wt)
.assert()
.success()
.stdout(predicate::str::contains(expected));
Command::cargo_bin("gwm")
.unwrap()
.current_dir(dir.path())
.env("GWM_ALLOW_BOOTSTRAP", "1")
.args(["bootstrap", "feat-1"])
.assert()
.success()
.stdout(predicate::str::contains(expected));
}