mod common;
use common::init_repo;
use gwm::config::ResolvedLauncher;
use gwm::launcher::{
count_commits_ahead, expand_command, git_diff_argv, git_rev_list_count_argv, locate_binary, missing_binary_for,
resolve_review_base, write_gwm_base, LauncherContext,
};
use std::path::Path;
fn ctx<'a>(path: &'a Path, base: Option<&'a str>, head: Option<&'a str>) -> LauncherContext<'a> {
LauncherContext {
worktree_path: path,
base,
head,
repo_workdir: Some(path),
}
}
#[test]
fn expand_substitutes_path_placeholder() {
let c = ctx(Path::new("/tmp/wt/x"), None, None);
let cmd = expand_command("lazygit -p {path}", &c).unwrap();
assert_eq!(cmd.argv, vec!["lazygit", "-p", "/tmp/wt/x"]);
assert!(
cmd.diff_file.is_none(),
"no {{diff}} in template ⇒ no tempfile materialised"
);
}
#[test]
fn expand_preserves_backslashes_in_path_placeholders() {
let c = ctx(Path::new(r"C:\Users\runner\AppData\Local\Temp\wt"), None, None);
let cmd = expand_command("lazygit -p {path}", &c).unwrap();
assert_eq!(
cmd.argv,
vec!["lazygit", "-p", r"C:\Users\runner\AppData\Local\Temp\wt"]
);
}
#[test]
fn expand_substitutes_base_head_path() {
let c = ctx(Path::new("/tmp/wt/x"), Some("dev"), Some("feat/foo"));
let cmd = expand_command("lumen diff {base}..{head}", &c).unwrap();
assert_eq!(cmd.argv, vec!["lumen", "diff", "dev..feat/foo"]);
}
#[test]
fn expand_respects_shell_words_quoting() {
let c = ctx(Path::new("/tmp/wt/x"), Some("dev"), Some("feat/foo"));
let cmd = expand_command("claude --print 'review {base}..{head}'", &c).unwrap();
assert_eq!(
cmd.argv,
vec!["claude", "--print", "review dev..feat/foo"],
"quoted argument must stay one token even after placeholder expansion"
);
}
#[test]
fn expand_errors_on_unsubstitutable_base() {
let c = ctx(Path::new("/tmp/wt/x"), None, None);
let err = expand_command("lumen diff {base}..HEAD", &c).unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("{base}"),
"error must mention the missing placeholder: {}",
msg
);
}
#[test]
fn expand_errors_on_diff_without_repo() {
let c = LauncherContext {
worktree_path: Path::new("/tmp/wt/x"),
base: Some("dev"),
head: Some("feat/foo"),
repo_workdir: None,
};
let err = expand_command("reviewer --diff {diff}", &c).unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("workdir") || msg.contains("repo"),
"diff error should mention the missing repo workdir: {}",
msg
);
}
#[test]
fn expand_materialises_diff_lazily() {
let (dir, repo) = init_repo();
std::fs::write(dir.path().join("a.txt"), "hello\n").unwrap();
let mut index = repo.index().unwrap();
index.add_path(Path::new("a.txt")).unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let parent = repo.head().unwrap().peel_to_commit().unwrap();
let sig = git2::Signature::now("t", "t@test").unwrap();
let commit_oid = repo.commit(None, &sig, &sig, "add a", &tree, &[&parent]).unwrap();
let new_commit = repo.find_commit(commit_oid).unwrap();
repo.branch("feat", &new_commit, false).unwrap();
let c = LauncherContext {
worktree_path: dir.path(),
base: Some("main"),
head: Some("feat"),
repo_workdir: Some(dir.path()),
};
let cmd = expand_command("reviewer --diff {diff}", &c).unwrap();
assert_eq!(cmd.argv[0], "reviewer");
assert_eq!(cmd.argv[1], "--diff");
let diff_path = &cmd.argv[2];
assert!(
Path::new(diff_path).exists(),
"{{diff}} placeholder must produce a real tempfile at: {}",
diff_path
);
let body = std::fs::read_to_string(diff_path).unwrap();
assert!(
body.contains("a.txt"),
"git diff output should mention the file: {}",
body
);
assert!(
cmd.diff_file.is_some(),
"ExpandedCommand must keep the tempfile alive until drop"
);
}
#[test]
fn resolve_base_falls_back_to_main_when_no_dev_branch_exists() {
let (_dir, repo) = init_repo();
let base = resolve_review_base(&repo, "feat/x", None);
assert_eq!(base, "main", "no dev branch ⇒ fall through to main");
}
#[test]
fn resolve_base_falls_back_to_dev_when_dev_branch_exists() {
let (_dir, repo) = init_repo();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("dev", &head, false).unwrap();
let base = resolve_review_base(&repo, "feat/x", None);
assert_eq!(base, "dev", "dev branch present ⇒ prefer it over main");
}
#[test]
fn resolve_base_uses_config_default_when_set() {
let (_dir, repo) = init_repo();
let base = resolve_review_base(&repo, "feat/x", Some("trunk"));
assert_eq!(base, "trunk");
}
#[test]
fn resolve_base_prefers_gwm_base_over_default() {
let (_dir, repo) = init_repo();
write_gwm_base(&repo, "feat/x", "release-1.x").unwrap();
let base = resolve_review_base(&repo, "feat/x", Some("trunk"));
assert_eq!(base, "release-1.x");
}
#[test]
fn resolve_base_prefers_upstream_over_gwm_base() {
let (_dir, repo) = init_repo();
{
let mut cfg = repo.config().unwrap();
cfg.set_str("branch.feat/x.merge", "refs/heads/staging").unwrap();
}
write_gwm_base(&repo, "feat/x", "release-1.x").unwrap();
let base = resolve_review_base(&repo, "feat/x", Some("trunk"));
assert_eq!(base, "staging", "branch.<n>.merge must outrank gwm-base");
}
#[test]
fn resolve_base_strips_refs_heads_prefix_from_merge() {
let (_dir, repo) = init_repo();
{
let mut cfg = repo.config().unwrap();
cfg.set_str("branch.feat/x.merge", "refs/heads/dev").unwrap();
}
let base = resolve_review_base(&repo, "feat/x", None);
assert_eq!(base, "dev");
}
#[test]
fn resolve_base_empty_strings_in_config_are_ignored() {
let (_dir, repo) = init_repo();
{
let mut cfg = repo.config().unwrap();
cfg.set_str("branch.feat/x.merge", "").unwrap();
}
let base = resolve_review_base(&repo, "feat/x", Some("trunk"));
assert_eq!(base, "trunk", "empty merge must fall through to the next chain step");
}
#[test]
fn resolve_base_skips_self_tracking_upstream() {
let (_dir, repo) = init_repo();
{
let mut cfg = repo.config().unwrap();
cfg.set_str("branch.feat/x.merge", "refs/heads/feat/x").unwrap();
}
write_gwm_base(&repo, "feat/x", "release-1.x").unwrap();
let base = resolve_review_base(&repo, "feat/x", Some("trunk"));
assert_eq!(
base, "release-1.x",
"self-tracking upstream must be ignored — fall through to gwm-base"
);
}
#[test]
fn resolve_base_skips_self_tracking_upstream_short_form() {
let (_dir, repo) = init_repo();
{
let mut cfg = repo.config().unwrap();
cfg.set_str("branch.feat/x.merge", "feat/x").unwrap();
}
let base = resolve_review_base(&repo, "feat/x", Some("trunk"));
assert_eq!(
base, "trunk",
"bare self-tracking upstream must be ignored too — fall through to default_base"
);
}
#[test]
fn count_commits_ahead_is_zero_on_identical_refs() {
let (dir, _repo) = init_repo();
let n = count_commits_ahead(dir.path(), "HEAD", "HEAD");
assert_eq!(n, 0, "HEAD..HEAD must always be 0");
}
#[test]
fn count_commits_ahead_returns_zero_on_git_failure() {
let (dir, _repo) = init_repo();
let n = count_commits_ahead(dir.path(), "does-not-exist", "HEAD");
assert_eq!(n, 0);
}
#[test]
fn missing_binary_for_returns_some_when_absent() {
let l = ResolvedLauncher {
command: "definitely-not-a-real-binary-3afe2c {path}".into(),
fullscreen: false,
};
assert_eq!(
missing_binary_for(&l).as_deref(),
Some("definitely-not-a-real-binary-3afe2c")
);
}
#[test]
fn missing_binary_for_returns_none_when_present() {
let l = ResolvedLauncher {
command: "sh -c 'echo {base}'".into(),
fullscreen: false,
};
assert!(
missing_binary_for(&l).is_none(),
"/bin/sh must resolve on every supported platform"
);
}
#[test]
fn locate_binary_finds_resolved_argv0() {
let c = ctx(Path::new("/tmp"), None, None);
let cmd = expand_command("sh -c 'echo hi'", &c).unwrap();
assert!(locate_binary(&cmd).is_some());
}
#[test]
fn git_diff_argv_inserts_end_of_options_before_refspec() {
let argv = git_diff_argv("dev", "feat/#1-x");
assert_eq!(argv, vec!["diff", "--end-of-options", "dev..feat/#1-x"]);
let sep_idx = argv.iter().position(|a| a == "--end-of-options").unwrap();
let refspec_idx = argv.iter().position(|a| a.contains("..")).unwrap();
assert!(
sep_idx < refspec_idx,
"--end-of-options must precede the refspec; argv: {:?}",
argv
);
}
#[test]
fn git_diff_argv_keeps_separator_first_for_dash_prefixed_base() {
let argv = git_diff_argv("--upload-pack=/tmp/x", "HEAD");
assert_eq!(argv[0], "diff");
assert_eq!(argv[1], "--end-of-options");
assert!(
argv[2].starts_with("--upload-pack=/tmp/x.."),
"malicious base must land as a positional ref after the separator; argv: {:?}",
argv
);
}
#[test]
fn git_rev_list_count_argv_inserts_end_of_options_before_refspec() {
let argv = git_rev_list_count_argv("dev", "HEAD");
assert_eq!(argv, vec!["rev-list", "--count", "--end-of-options", "dev..HEAD"]);
}
#[test]
fn count_commits_ahead_treats_dash_prefixed_base_as_ref_not_flag() {
let (dir, _) = init_repo();
let n = count_commits_ahead(dir.path(), "--version", "HEAD");
assert_eq!(n, 0, "dash-prefixed base must never let git happily print a banner");
}