use gwm::config::{Config, HookStep};
use gwm::lifecycle::{self, HookContext, HookPhase, HookSkips};
use std::collections::HashMap;
use std::path::Path;
fn ctx_at(cwd: &Path) -> HookContext {
HookContext {
main_repo: cwd.to_path_buf(),
cwd: cwd.to_path_buf(),
path: cwd.to_path_buf(),
branch: "feat/#226-demo".into(),
branch_type: "feat".into(),
issue: "226".into(),
desc: "demo".into(),
user: "tester".into(),
owner: "kbrdn1".into(),
repo: "gwm-cli".into(),
}
}
fn run_one_hook(dir: &Path, ctx: &HookContext, run: &str, env: HashMap<String, String>) -> String {
let mut cfg = Config::default();
cfg.hooks.post_create.push(HookStep {
name: "probe".into(),
run: run.into(),
when: None,
env,
on_fail: gwm::config::HookOnFail::default(),
});
let ctx = ctx.with_cwd(dir);
let report = lifecycle::run_phase(&cfg, HookPhase::PostCreate, &ctx, &HookSkips::default(), false).unwrap();
report
.steps
.iter()
.map(|s| s.detail.clone())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn a_branch_name_cannot_terminate_the_hook_command() {
let dir = tempfile::TempDir::new().unwrap();
let proof = dir.path().join("pwned");
let mut ctx = ctx_at(dir.path());
ctx.branch = format!("x;id>{}", proof.display());
let out = run_one_hook(dir.path(), &ctx, "echo {branch}", HashMap::new());
assert!(
!proof.exists(),
"the branch name started a second command: {} was created",
proof.display()
);
assert!(
out.contains(&ctx.branch),
"the hook must receive the whole branch name as one literal value, got: {out}"
);
}
#[test]
fn every_placeholder_is_escaped_not_just_the_branch() {
let dir = tempfile::TempDir::new().unwrap();
for (field, template) in [
("desc", "echo {desc}"),
("issue", "echo {issue}"),
("branch_type", "echo {type}"),
("repo", "echo {repo}"),
("owner", "echo {owner}"),
("user", "echo {user}"),
] {
let proof = dir.path().join(format!("pwned-{field}"));
let payload = format!("x;id>{}", proof.display());
let mut ctx = ctx_at(dir.path());
match field {
"desc" => ctx.desc = payload,
"issue" => ctx.issue = payload,
"branch_type" => ctx.branch_type = payload,
"repo" => ctx.repo = payload,
"owner" => ctx.owner = payload,
_ => ctx.user = payload,
}
run_one_hook(dir.path(), &ctx, template, HashMap::new());
assert!(!proof.exists(), "`{{{field}}}` executed its payload");
}
}
#[test]
fn a_value_that_looks_like_a_placeholder_is_not_expanded_again() {
let dir = tempfile::TempDir::new().unwrap();
let mut ctx = ctx_at(dir.path());
ctx.branch = "spike-{issue}".into();
ctx.issue = "42".into();
let out = run_one_hook(dir.path(), &ctx, "echo {branch}", HashMap::new());
assert!(
out.contains("spike-{issue}"),
"the branch name must reach the hook verbatim, got: {out}"
);
assert!(!out.contains("spike-42"), "the value was re-expanded: {out}");
}
#[test]
fn env_values_are_passed_raw_because_they_never_reach_a_shell() {
let dir = tempfile::TempDir::new().unwrap();
let ctx = ctx_at(dir.path());
let env = HashMap::from([("GWM_TEST_BRANCH".to_string(), "{branch}".to_string())]);
let out = run_one_hook(dir.path(), &ctx, "printenv GWM_TEST_BRANCH", env);
assert!(
out.contains("feat/#226-demo"),
"the env value must be the raw branch, got: {out}"
);
assert!(!out.contains('\''), "the env value must not carry shell quotes: {out}");
}
#[test]
fn the_context_is_also_exported_as_environment_variables() {
let dir = tempfile::TempDir::new().unwrap();
let mut ctx = ctx_at(dir.path());
ctx.branch = "x;id".into();
let out = run_one_hook(
dir.path(),
&ctx,
"printenv GWM_BRANCH; printenv GWM_REPO",
HashMap::new(),
);
assert!(out.contains("x;id"), "GWM_BRANCH must carry the raw branch: {out}");
assert!(out.contains("gwm-cli"), "GWM_REPO must be exported too: {out}");
}
#[test]
fn a_legacy_bootstrap_command_gets_the_same_escaping() {
let dir = tempfile::TempDir::new().unwrap();
let proof = dir.path().join("pwned-legacy");
let mut ctx = ctx_at(dir.path());
ctx.branch = format!("x;id>{}", proof.display());
let mut cfg = Config::default();
cfg.bootstrap.command.push(gwm::config::CommandStep {
name: "legacy".into(),
run: "echo {branch}".into(),
when: None,
env: HashMap::new(),
});
let ctx = ctx.with_cwd(dir.path());
lifecycle::run_phase(&cfg, HookPhase::PostCreate, &ctx, &HookSkips::default(), true).unwrap();
assert!(!proof.exists(), "a legacy bootstrap command executed the payload");
}
#[test]
fn post_create_hook_is_recorded_on_the_command_log() {
let sentinel = "gwm-hook-cmdlog-5b7a";
let dir = tempfile::TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.hooks.post_create.push(HookStep {
name: "echo".into(),
run: format!("echo {sentinel}"),
when: None,
env: HashMap::new(),
on_fail: gwm::config::HookOnFail::default(),
});
let ctx = ctx_at(dir.path());
lifecycle::run_phase(&cfg, HookPhase::PostCreate, &ctx, &HookSkips::default(), false).unwrap();
let recorded = gwm::command_log::snapshot();
let mine = recorded
.iter()
.find(|e| e.command.contains(sentinel))
.expect("post_create hook recorded on the command log");
assert!(mine.is_success(), "the echo hook exited cleanly");
assert!(mine.output.contains(sentinel), "captured stdout is stored");
}
#[test]
fn an_empty_placeholder_expands_to_nothing_not_to_an_empty_argument() {
let dir = tempfile::tempdir().unwrap();
let mut ctx = ctx_at(dir.path());
ctx.issue = String::new();
let out = run_one_hook(dir.path(), &ctx, "set -- {issue}; echo $#", HashMap::new());
assert_eq!(
out.trim(),
"0",
"an empty placeholder must not start passing an empty argument"
);
let mut hostile = ctx_at(dir.path());
hostile.issue = "1;id".into();
let out = run_one_hook(dir.path(), &hostile, "set -- {issue}; echo $#", HashMap::new());
assert_eq!(out.trim(), "1", "a non-empty value stays a single quoted argument");
}