mod common;
use common::init_repo;
use gwm::config::Config;
use gwm::doctor::{self, CheckStatus, DoctorCtx, Severity};
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn ctx_for<'a>(repo: &'a git2::Repository, workdir: &'a std::path::Path, config: &'a Config) -> DoctorCtx<'a> {
DoctorCtx {
repo_workdir: workdir,
repo,
config,
global_config_path: None,
}
}
#[test]
fn fresh_repo_without_config_reports_defaults_assumed() {
let (dir, repo) = init_repo();
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let cfg = report
.checks
.iter()
.find(|c| c.name.contains(".gwm.toml"))
.expect("expected a `.gwm.toml` check in the report");
assert_eq!(cfg.status, CheckStatus::Ok);
assert!(
cfg.detail.to_lowercase().contains("default"),
"missing config should mention 'defaults assumed', got: {}",
cfg.detail
);
}
#[test]
fn invalid_toml_marks_config_check_failed_with_severity_failed() {
let (dir, repo) = init_repo();
std::fs::write(dir.path().join(".gwm.toml"), "this is = not valid [toml").unwrap();
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let cfg = report
.checks
.iter()
.find(|c| c.name.contains(".gwm.toml"))
.expect("expected a `.gwm.toml` check");
assert_eq!(cfg.status, CheckStatus::Failed);
assert_eq!(report.severity(), Severity::Failed);
assert_eq!(report.exit_code(), 2);
}
#[test]
fn valid_toml_marks_config_check_ok() {
let (dir, repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
r#"[worktree]
base = "{home}/wt/{repo}"
path_pattern = "{type}-{issue}-{desc}"
branch_pattern = "{type}/#{issue}-{desc}"
"#,
)
.unwrap();
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let cfg = report
.checks
.iter()
.find(|c| c.name.contains(".gwm.toml"))
.expect("expected a `.gwm.toml` check");
assert_eq!(cfg.status, CheckStatus::Ok);
}
#[test]
fn semantically_invalid_profile_marks_config_check_failed() {
let (dir, repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
"[clean.profiles.default]\ndirs = [\"..\"]\n",
)
.unwrap();
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let cfg = report
.checks
.iter()
.find(|c| c.name.contains(".gwm.toml"))
.expect("expected a `.gwm.toml` check");
assert_eq!(cfg.status, CheckStatus::Failed);
}
#[test]
fn severity_ok_when_all_checks_ok() {
let mut report = gwm::doctor::DoctorReport::new();
report.checks.push(gwm::doctor::Check::ok("a", "fine"));
report.checks.push(gwm::doctor::Check::ok("b", "fine"));
assert_eq!(report.severity(), Severity::Ok);
assert_eq!(report.exit_code(), 0);
}
#[test]
fn severity_warning_when_any_check_warns() {
let mut report = gwm::doctor::DoctorReport::new();
report.checks.push(gwm::doctor::Check::ok("a", "fine"));
report.checks.push(gwm::doctor::Check::warning("b", "meh"));
report.checks.push(gwm::doctor::Check::ok("c", "fine"));
assert_eq!(report.severity(), Severity::Warning);
assert_eq!(report.exit_code(), 1);
}
#[test]
fn severity_failed_dominates_warning() {
let mut report = gwm::doctor::DoctorReport::new();
report.checks.push(gwm::doctor::Check::warning("a", "meh"));
report.checks.push(gwm::doctor::Check::failed("b", "broken"));
report.checks.push(gwm::doctor::Check::warning("c", "meh"));
assert_eq!(report.severity(), Severity::Failed);
assert_eq!(report.exit_code(), 2);
}
#[test]
fn dangling_guard_reference_is_failed() {
let (dir, repo) = init_repo();
let mut config = Config::default();
config.bootstrap.copy.push(gwm::config::CopyStep {
from: ".env".into(),
to: ".env".into(),
required: false,
guards: vec!["does-not-exist".into()],
fallback: None,
});
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report
.checks
.iter()
.find(|c| c.name.contains("guard"))
.expect("expected a guard-references check");
assert_eq!(c.status, CheckStatus::Failed);
assert!(c.detail.contains("does-not-exist"));
assert_eq!(report.severity(), Severity::Failed);
}
#[test]
fn matching_guard_reference_is_ok() {
let (dir, repo) = init_repo();
let mut config = Config::default();
config.bootstrap.guard.push(gwm::config::Guard {
name: "no-aws-rds".into(),
deny_patterns: vec!["amazonaws".into()],
on_match: "abort".into(),
example_file: None,
});
config.bootstrap.copy.push(gwm::config::CopyStep {
from: ".env".into(),
to: ".env".into(),
required: false,
guards: vec!["no-aws-rds".into()],
fallback: None,
});
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report.checks.iter().find(|c| c.name.contains("guard")).unwrap();
assert_eq!(c.status, CheckStatus::Ok);
}
#[test]
fn unsupported_when_predicate_is_failed() {
let (dir, repo) = init_repo();
let mut config = Config::default();
config.bootstrap.command.push(gwm::config::CommandStep {
name: "noop".into(),
run: "true".into(),
when: Some("bogus_predicate:FOO".into()),
env: Default::default(),
});
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report
.checks
.iter()
.find(|c| c.name.contains("when"))
.expect("expected a `when` predicate check");
assert_eq!(c.status, CheckStatus::Failed);
assert!(c.detail.contains("bogus_predicate"));
}
#[test]
fn negated_supported_keyword_is_ok() {
let (dir, repo) = init_repo();
let mut config = Config::default();
config.bootstrap.command.push(gwm::config::CommandStep {
name: "skip-in-ci".into(),
run: "./scripts/full-build.sh".into(),
when: Some("!env_set:CI".into()),
env: Default::default(),
});
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report
.checks
.iter()
.find(|c| c.name.contains("when"))
.expect("expected a `when` predicate check");
assert_eq!(c.status, CheckStatus::Ok);
}
#[test]
fn unsupported_keyword_on_rhs_of_and_is_failed() {
let (dir, repo) = init_repo();
let mut config = Config::default();
config.bootstrap.command.push(gwm::config::CommandStep {
name: "compound".into(),
run: "true".into(),
when: Some("file_exists:a && bogus_predicate:1".into()),
env: Default::default(),
});
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report
.checks
.iter()
.find(|c| c.name.contains("when"))
.expect("expected a `when` predicate check");
assert_eq!(c.status, CheckStatus::Failed);
assert!(c.detail.contains("bogus_predicate"));
}
#[test]
fn file_exists_when_predicate_is_ok() {
let (dir, repo) = init_repo();
let mut config = Config::default();
config.bootstrap.command.push(gwm::config::CommandStep {
name: "direnv allow".into(),
run: "direnv allow .".into(),
when: Some("file_exists:.envrc".into()),
env: Default::default(),
});
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report.checks.iter().find(|c| c.name.contains("when")).unwrap();
assert_eq!(c.status, CheckStatus::Ok);
}
#[test]
fn no_when_predicates_is_ok() {
let (dir, repo) = init_repo();
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report.checks.iter().find(|c| c.name.contains("when")).unwrap();
assert_eq!(c.status, CheckStatus::Ok);
}
#[test]
fn when_predicates_detail_counts_checked_predicates_not_keywords() {
let (dir, repo) = init_repo();
let mut config = Config::default();
for n in 0..3 {
config.bootstrap.command.push(gwm::config::CommandStep {
name: format!("step-{n}"),
run: "true".into(),
when: Some("file_exists:.envrc".into()),
env: Default::default(),
});
}
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report.checks.iter().find(|c| c.name.contains("when")).unwrap();
assert_eq!(c.status, CheckStatus::Ok);
assert!(
c.detail.contains("3 predicate"),
"expected detail to mention 3 checked predicates, got: {}",
c.detail
);
}
#[test]
fn when_predicates_detail_says_none_when_no_predicates_configured() {
let (dir, repo) = init_repo();
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report.checks.iter().find(|c| c.name.contains("when")).unwrap();
assert_eq!(c.status, CheckStatus::Ok);
assert!(
!c.detail.contains("1 predicate"),
"no predicates were configured; detail must not claim 1, got: {}",
c.detail
);
}
#[test]
fn missing_command_binary_is_warning() {
let (dir, repo) = init_repo();
let mut config = Config::default();
config.bootstrap.command.push(gwm::config::CommandStep {
name: "phantom".into(),
run: "definitely-not-on-path-xyz123 --help".into(),
when: None,
env: Default::default(),
});
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report
.checks
.iter()
.find(|c| c.name.contains("PATH"))
.expect("expected a PATH check");
assert_eq!(c.status, CheckStatus::Warning);
assert!(c.detail.contains("definitely-not-on-path-xyz123"));
}
#[test]
fn resolvable_command_binary_is_ok() {
let (dir, repo) = init_repo();
let mut config = Config::default();
config.bootstrap.command.push(gwm::config::CommandStep {
name: "noop".into(),
run: "sh -c 'true'".into(),
when: None,
env: Default::default(),
});
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report.checks.iter().find(|c| c.name.contains("PATH")).unwrap();
if c.status == CheckStatus::Warning {
let missing_section = c.detail.split("not on PATH:").nth(1).unwrap_or("");
let missing: Vec<&str> = missing_section.split([',', '\n']).map(str::trim).collect();
assert!(
!missing.contains(&"sh"),
"sh must not be reported missing, got: {}",
c.detail
);
}
}
#[test]
fn missing_review_binary_is_warning_not_failure() {
let (dir, repo) = init_repo();
let mut config = Config::default();
config.review.command = Some("definitely-not-on-path-review-xyz {base}..{head}".into());
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report
.checks
.iter()
.find(|c| c.name.contains("PATH"))
.expect("expected a PATH check");
assert_eq!(c.status, CheckStatus::Warning);
assert!(
c.detail.contains("definitely-not-on-path-review-xyz"),
"missing review binary must appear in the detail: {}",
c.detail
);
}
#[test]
fn missing_review_tool_preset_is_warning() {
let (dir, repo) = init_repo();
let mut config = Config::default();
config.review.tool = Some("lumen".into());
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report.checks.iter().find(|c| c.name.contains("PATH")).unwrap();
if c.status == CheckStatus::Warning && c.detail.contains("lumen") {
assert!(
c.detail.to_lowercase().contains("lumen"),
"preset's resolved binary must be named in the warning: {}",
c.detail
);
}
}
#[test]
fn launcher_wrapped_by_env_warns_on_real_binary_not_wrapper() {
let (dir, repo) = init_repo();
let mut config = Config::default();
config.review.command = Some("env FOO=bar definitely-not-on-path-wrapped-zz {base}..{head}".into());
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report.checks.iter().find(|c| c.name.contains("PATH")).unwrap();
assert_eq!(c.status, CheckStatus::Warning, "wrapped missing binary must warn");
assert!(
c.detail.contains("definitely-not-on-path-wrapped-zz"),
"wrapper must be peeled: detail should name the real binary, got: {}",
c.detail
);
assert!(
!c.detail.split("not on PATH:").nth(1).unwrap_or("").contains("env"),
"`env` must not appear in the missing-binaries section: {}",
c.detail
);
}
#[test]
fn launcher_wrapped_by_command_warns_on_real_binary_not_wrapper() {
let (dir, repo) = init_repo();
let mut config = Config::default();
config.git_tui.command = Some("command definitely-not-on-path-cmd-yy -d {path}".into());
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report.checks.iter().find(|c| c.name.contains("PATH")).unwrap();
assert!(
c.detail.contains("definitely-not-on-path-cmd-yy"),
"command wrapper must be peeled: detail should name the real binary, got: {}",
c.detail
);
}
#[test]
fn missing_git_tui_binary_is_warning() {
let (dir, repo) = init_repo();
let mut config = Config::default();
config.git_tui.command = Some("definitely-not-on-path-tui-xyz -d {path}".into());
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report.checks.iter().find(|c| c.name.contains("PATH")).unwrap();
assert_eq!(c.status, CheckStatus::Warning);
assert!(
c.detail.contains("definitely-not-on-path-tui-xyz"),
"missing git_tui binary must appear in the detail: {}",
c.detail
);
}
#[test]
fn review_unset_does_not_force_lazygit_warning_to_failure() {
let (dir, repo) = init_repo();
let config = Config::default(); let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
assert!(
matches!(report.severity(), CheckStatus::Ok | CheckStatus::Warning),
"default config must not push doctor into Failed: severity = {:?}",
report.severity()
);
}
#[test]
fn extract_binary_handles_shell_quoted_run_strings() {
let (dir, repo) = init_repo();
let mut config = Config::default();
config.bootstrap.command.push(gwm::config::CommandStep {
name: "quoted".into(),
run: r#""definitely-not-on-path-quoted-xyz" --help"#.into(),
when: None,
env: Default::default(),
});
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report.checks.iter().find(|c| c.name.contains("PATH")).unwrap();
assert!(
c.detail.contains("definitely-not-on-path-quoted-xyz"),
"shell-quoted binary name must be unquoted in the report, got: {}",
c.detail
);
assert!(
!c.detail.contains("\"definitely"),
"the leading quote must be stripped, got: {}",
c.detail
);
}
#[test]
fn base_dir_existing_and_writable_is_ok() {
let (dir, repo) = init_repo();
let base_dir = dir.path().join("wt-base");
std::fs::create_dir(&base_dir).unwrap();
let mut config = Config::default();
config.worktree.base = base_dir.to_string_lossy().into_owned();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report
.checks
.iter()
.find(|c| c.name.contains("base"))
.expect("expected a base-dir check");
assert_eq!(c.status, CheckStatus::Ok);
}
#[test]
fn base_dir_missing_but_parent_writable_is_ok() {
let (dir, repo) = init_repo();
let base_dir = dir.path().join("future-base");
let mut config = Config::default();
config.worktree.base = base_dir.to_string_lossy().into_owned();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report.checks.iter().find(|c| c.name.contains("base")).unwrap();
assert_eq!(c.status, CheckStatus::Ok);
}
#[test]
fn fresh_repo_has_no_prunable_worktrees() {
let (dir, repo) = init_repo();
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report
.checks
.iter()
.find(|c| c.name.contains("prunable"))
.expect("expected a prunable check");
assert_eq!(c.status, CheckStatus::Ok);
}
#[test]
fn orphan_unmerged_gwm_branch_is_warning() {
let (dir, repo) = init_repo();
let head = repo.head().unwrap().peel_to_commit().unwrap();
let sig = git2::Signature::now("test", "test@test").unwrap();
let tree = head.tree().unwrap();
let oid = repo
.commit(None, &sig, &sig, "off-main commit", &tree, &[&head])
.unwrap();
let commit = repo.find_commit(oid).unwrap();
repo.branch("feat/#99-stale-thing", &commit, false).unwrap();
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report
.checks
.iter()
.find(|c| c.name.contains("orphan"))
.expect("expected an orphan-branches check");
assert_eq!(c.status, CheckStatus::Warning);
assert!(
c.detail.contains("feat/#99-stale-thing"),
"orphan branch should be quoted in the detail, got: {}",
c.detail
);
}
#[test]
fn merged_gwm_branch_is_not_flagged_as_orphan() {
let (dir, repo) = init_repo();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feat/#99-already-merged", &head, false).unwrap();
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report.checks.iter().find(|c| c.name.contains("orphan")).unwrap();
assert_eq!(c.status, CheckStatus::Ok);
assert!(
!c.detail.contains("feat/#99-already-merged"),
"merged branch must not appear in the orphan list, got: {}",
c.detail
);
}
#[test]
fn merged_via_merge_commit_gwm_branch_is_not_flagged_as_orphan() {
let (dir, repo) = init_repo();
let main_initial = repo.head().unwrap().peel_to_commit().unwrap();
let sig = git2::Signature::now("test", "test@test").unwrap();
let tree = main_initial.tree().unwrap();
let feature_oid = repo
.commit(None, &sig, &sig, "feature work", &tree, &[&main_initial])
.unwrap();
let feature_commit = repo.find_commit(feature_oid).unwrap();
repo
.branch("feat/#88-merged-via-merge", &feature_commit, false)
.unwrap();
repo
.commit(
Some("refs/heads/main"),
&sig,
&sig,
"merge feat/#88",
&tree,
&[&main_initial, &feature_commit],
)
.unwrap();
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report.checks.iter().find(|c| c.name.contains("orphan")).unwrap();
assert_eq!(c.status, CheckStatus::Ok);
assert!(
!c.detail.contains("feat/#88-merged-via-merge"),
"branch merged via a merge commit must not appear in the orphan list, got: {}",
c.detail
);
}
#[test]
fn non_gwm_branch_is_not_flagged_as_orphan() {
let (dir, repo) = init_repo();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("release-2.0", &head, false).unwrap();
repo.branch("dependabot/cargo/serde-1.0.200", &head, false).unwrap();
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report.checks.iter().find(|c| c.name.contains("orphan")).unwrap();
assert_eq!(c.status, CheckStatus::Ok);
}
#[test]
fn orphan_check_honours_configured_trunks() {
let (dir, repo) = init_repo();
let head = repo.head().unwrap().peel_to_commit().unwrap();
let sig = git2::Signature::now("test", "test@test").unwrap();
let tree = head.tree().unwrap();
let feature_oid = repo.commit(None, &sig, &sig, "feature work", &tree, &[&head]).unwrap();
let feature_commit = repo.find_commit(feature_oid).unwrap();
repo.branch("feat/#77-on-custom-trunk", &feature_commit, false).unwrap();
repo.branch("custom-trunk", &feature_commit, false).unwrap();
let mut config = Config::default();
config.doctor.trunks = vec!["custom-trunk".into()];
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report
.checks
.iter()
.find(|c| c.name.contains("orphan"))
.expect("expected an orphan-branches check");
assert_eq!(c.status, CheckStatus::Ok);
assert!(
!c.detail.contains("feat/#77-on-custom-trunk"),
"merged branch must not appear in the orphan list when its trunk is configured, got: {}",
c.detail
);
}
#[test]
fn orphan_check_with_empty_trunks_disables_merge_filter() {
let (dir, repo) = init_repo();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feat/#88-merged-into-main", &head, false).unwrap();
let mut config = Config::default();
config.doctor.trunks = vec![];
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report
.checks
.iter()
.find(|c| c.name.contains("orphan"))
.expect("expected an orphan-branches check");
assert_eq!(c.status, CheckStatus::Warning);
assert!(
c.detail.contains("feat/#88-merged-into-main"),
"with no configured trunks every gwm branch must surface as orphan, got: {}",
c.detail
);
}
#[test]
fn orphan_check_ignores_configured_trunks_that_do_not_exist() {
let (dir, repo) = init_repo();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feat/#99-merged-into-main", &head, false).unwrap();
let mut config = Config::default();
config.doctor.trunks = vec!["phantom-trunk".into(), "main".into()];
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report.checks.iter().find(|c| c.name.contains("orphan")).unwrap();
assert_eq!(c.status, CheckStatus::Ok);
}
#[test]
fn doctor_passes_with_default_keymap() {
let (dir, repo) = init_repo();
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report
.checks
.iter()
.find(|c| c.name.to_lowercase().contains("keymap"))
.expect("expected a TUI keymap check in the report");
assert_eq!(
c.status,
CheckStatus::Ok,
"default keymap must pass cleanly, got: {} — {}",
match c.status {
CheckStatus::Ok => "ok",
CheckStatus::Warning => "warning",
CheckStatus::Failed => "failed",
},
c.detail
);
}
#[test]
fn doctor_warns_when_user_unbinds_quit() {
let (dir, repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
r#"
[tui.keys]
quit = []
"#,
)
.unwrap();
let config = Config::load_layered(dir.path(), None).unwrap();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report
.checks
.iter()
.find(|c| c.name.to_lowercase().contains("keymap"))
.expect("expected a TUI keymap check in the report");
assert_eq!(c.status, CheckStatus::Warning);
assert!(
c.detail.to_lowercase().contains("quit"),
"expected message to name the missing action, got: {}",
c.detail
);
assert!(
c.detail.to_lowercase().contains("ctrl"),
"expected message to mention the Ctrl+C fallback, got: {}",
c.detail
);
}
#[test]
fn doctor_reports_modal_binding_count_on_default_keymap() {
let (dir, repo) = init_repo();
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report
.checks
.iter()
.find(|c| c.name.to_lowercase().contains("keymap"))
.expect("expected a TUI keymap check");
assert_eq!(c.status, CheckStatus::Ok);
assert!(
c.detail.contains("modal"),
"detail must mention the modal binding count, got: {}",
c.detail
);
}
#[test]
fn doctor_fails_on_in_context_modal_conflict() {
let (dir, repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
"[tui.keys.modal.confirm]\nconfirm = [\"x\"]\ncancel = [\"x\"]\n",
)
.unwrap();
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report
.checks
.iter()
.find(|c| c.name.to_lowercase().contains("keymap"))
.expect("expected a TUI keymap check");
assert_eq!(c.status, CheckStatus::Failed);
assert!(
c.detail.contains("conflict"),
"detail must explain the conflict, got: {}",
c.detail
);
}
#[test]
fn doctor_keymap_check_reads_only_the_threaded_global_layer() {
let (dir, repo) = init_repo();
let global = dir.path().join("global.toml");
std::fs::write(&global, "[tui.keys.modal.confirm]\nconfirm = [\"g g\"]\n").unwrap();
let config = Config::default();
let with = DoctorCtx {
repo_workdir: dir.path(),
repo: &repo,
config: &config,
global_config_path: Some(global.as_path()),
};
let c = doctor::run(&with)
.unwrap()
.checks
.into_iter()
.find(|c| c.name.to_lowercase().contains("keymap"))
.expect("keymap check");
assert_eq!(
c.status,
CheckStatus::Failed,
"a threaded bad global must fail the check: {}",
c.detail
);
let without = DoctorCtx {
repo_workdir: dir.path(),
repo: &repo,
config: &config,
global_config_path: None,
};
let c2 = doctor::run(&without)
.unwrap()
.checks
.into_iter()
.find(|c| c.name.to_lowercase().contains("keymap"))
.expect("keymap check");
assert_eq!(
c2.status,
CheckStatus::Ok,
"an un-threaded global config must be ignored: {}",
c2.detail
);
}
#[test]
fn doctor_modal_error_outranks_the_quit_warning() {
let (dir, repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
"[tui.keys]\nquit = []\n\n[tui.keys.modal.confirm]\nconfirm = [\"g g\"]\n",
)
.unwrap();
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report
.checks
.iter()
.find(|c| c.name.to_lowercase().contains("keymap"))
.expect("expected a TUI keymap check");
assert_eq!(
c.status,
CheckStatus::Failed,
"the modal error must outrank the quit warning, got: {}",
c.detail
);
assert!(
c.detail.contains("single keystroke"),
"detail must be the modal error, not the quit warning, got: {}",
c.detail
);
}
#[test]
fn doctor_fails_on_disk_modal_error_even_when_context_defaulted() {
let (dir, repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
"[tui.keys.modal.confirm]\nconfirm = [\"g g\"]\n",
)
.unwrap();
assert!(
Config::load_layered(dir.path(), None).is_err(),
"the on-disk modal chord must be rejected at load time for this regression"
);
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report
.checks
.iter()
.find(|c| c.name.to_lowercase().contains("keymap"))
.expect("expected a TUI keymap check");
assert_eq!(
c.status,
CheckStatus::Failed,
"doctor must flag the on-disk modal error, not the defaulted ctx.config: {}",
c.detail
);
assert!(
c.detail.contains("single keystroke"),
"detail must explain the multi-stroke modal chord rejection, got: {}",
c.detail
);
}
#[test]
fn forge_cli_is_not_probed_when_the_key_is_unset() {
let (dir, repo) = init_repo();
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report.checks.iter().find(|c| c.name.contains("PATH")).unwrap();
let missing = c.detail.split("not on PATH:").nth(1).unwrap_or("");
assert!(
!missing.contains("glab") && !missing.contains("gh"),
"no forge CLI should be probed without an explicit `forge` key, got: {}",
c.detail
);
}
#[test]
fn an_explicit_gitlab_forge_probes_glab() {
let (dir, repo) = init_repo();
let config = Config {
forge: Some(gwm::forge::ForgeKind::GitLab),
..Default::default()
};
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let c = report.checks.iter().find(|c| c.name.contains("PATH")).unwrap();
if which::which("glab").is_err() {
assert_eq!(c.status, CheckStatus::Warning, "missing glab must warn: {}", c.detail);
assert!(
c.detail.contains("glab"),
"the missing forge CLI should be named, got: {}",
c.detail
);
}
let missing = c.detail.split("not on PATH:").nth(1).unwrap_or("");
assert!(
!missing.split(',').any(|b| b.trim() == "gh"),
"selecting GitLab must not probe for `gh`, got: {}",
c.detail
);
}
#[test]
fn a_forge_hosts_entry_is_also_an_opt_in_for_the_cli_probe() {
let (dir, repo) = init_repo();
repo
.remote("origin", "https://gitlab.acme.internal/team/proj.git")
.unwrap();
let global = dir.path().join("global.toml");
std::fs::write(&global, "[forge_hosts]\n\"gitlab.acme.internal\" = \"gitlab\"\n").unwrap();
let config = Config::default();
let report = doctor::run(&DoctorCtx {
repo_workdir: dir.path(),
repo: &repo,
config: &config,
global_config_path: Some(&global),
})
.unwrap();
let c = report.checks.iter().find(|c| c.name.contains("PATH")).unwrap();
if which::which("glab").is_err() {
assert_eq!(c.status, CheckStatus::Warning, "missing glab must warn: {}", c.detail);
assert!(
c.detail.contains("glab"),
"the forge CLI should be named, got: {}",
c.detail
);
}
let missing = c.detail.split("not on PATH:").nth(1).unwrap_or("");
assert!(
!missing.split(',').any(|b| b.trim() == "gh"),
"a GitLab host entry must not probe for `gh`, got: {}",
c.detail
);
}
#[test]
fn a_forge_hosts_entry_for_another_host_probes_nothing() {
let (dir, repo) = init_repo();
repo.remote("origin", "https://github.com/team/proj.git").unwrap();
let global = dir.path().join("global.toml");
std::fs::write(&global, "[forge_hosts]\n\"gitlab.acme.internal\" = \"gitlab\"\n").unwrap();
let config = Config::default();
let report = doctor::run(&DoctorCtx {
repo_workdir: dir.path(),
repo: &repo,
config: &config,
global_config_path: Some(&global),
})
.unwrap();
let c = report.checks.iter().find(|c| c.name.contains("PATH")).unwrap();
let missing = c.detail.split("not on PATH:").nth(1).unwrap_or("");
assert!(
!missing.contains("glab") && !missing.split(',').any(|b| b.trim() == "gh"),
"an unrelated host entry must not probe any forge CLI, got: {}",
c.detail
);
}
#[test]
fn the_forge_cli_probe_honours_the_gwm_gh_override() {
let (dir, repo) = init_repo();
let fake = dir.path().join(if cfg!(windows) { "my-gh.exe" } else { "my-gh" });
std::fs::write(&fake, "#!/bin/sh\nexit 0\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&fake).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&fake, perms).unwrap();
}
let config = Config {
forge: Some(gwm::forge::ForgeKind::GitHub),
..Default::default()
};
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prior = std::env::var("GWM_GH").ok();
unsafe {
std::env::set_var("GWM_GH", &fake);
}
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
unsafe {
match prior {
Some(v) => std::env::set_var("GWM_GH", v),
None => std::env::remove_var("GWM_GH"),
}
}
let c = report.checks.iter().find(|c| c.name.contains("PATH")).unwrap();
let missing = c.detail.split("not on PATH:").nth(1).unwrap_or("");
assert!(
!missing.contains("gh"),
"the overridden binary exists, so nothing should be reported missing: {}",
c.detail
);
}
#[test]
fn a_branch_pattern_nothing_reads_back_warns_that_the_parser_is_blind() {
let (dir, repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
"[worktree]\nbranch_pattern = \"~/{type}/#{issue}-{desc}\"\n",
)
.unwrap();
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let check = report
.checks
.iter()
.find(|c| c.name.contains("branch_pattern"))
.expect("expected a `branch_pattern` check in the report");
assert_eq!(check.status, CheckStatus::Warning);
for expected in ["auto-linking", "gitmoji", "branch-convention"] {
assert!(
check.detail.contains(expected),
"warning should name the '{}' consequence, got: {}",
expected,
check.detail
);
}
}
#[test]
fn default_branch_pattern_does_not_warn() {
let (dir, repo) = init_repo();
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let check = report
.checks
.iter()
.find(|c| c.name.contains("branch_pattern"))
.expect("expected a `branch_pattern` check in the report");
assert_eq!(check.status, CheckStatus::Ok);
}
#[test]
fn branch_pattern_check_reads_the_on_disk_config_not_the_lenient_fallback() {
let (dir, repo) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
"[worktree]\nbranch_pattern = \"~/{type}/#{issue}-{desc}\"\n",
)
.unwrap();
let config = Config::default();
let report = doctor::run(&ctx_for(&repo, dir.path(), &config)).unwrap();
let check = report
.checks
.iter()
.find(|c| c.name.contains("branch_pattern"))
.expect("expected a `branch_pattern` check in the report");
assert_eq!(check.status, CheckStatus::Warning);
}