use super::*;
use crate::test_support::{REAL_GIT, executable, fixture, git, isolated_overlay, remove, repository};
use std::path::{Path, PathBuf};
fn tokens(command: &Command) -> Vec<String> {
return command.get_args().map(|argument| { return argument.to_string_lossy().into_owned(); }).collect();
}
fn prefix() -> Vec<String> {
let mut tokens: Vec<String> = vec![String::from("-c"), format!("core.hooksPath={NO_HOOKS_DIRECTORY}")];
for event in switched_off_events() {
tokens.push(String::from("-c"));
tokens.push(format!("hook.{event}.enabled=false"));
}
return tokens;
}
fn with_prefix(globals: &[&str], rest: &[&str]) -> Vec<String> {
let mut tokens: Vec<String> = globals.iter().map(|token| { return String::from(*token); }).collect();
tokens.extend(prefix());
tokens.extend(rest.iter().map(|token| { return String::from(*token); }));
return tokens;
}
fn built(arguments: &[&str]) -> Command {
let owned: Vec<OsString> = arguments.iter().map(OsString::from).collect();
let mut command: Command = Command::new(REAL_GIT);
command.args(hooks_off_arguments(owned.as_slice()));
return command;
}
#[test]
fn the_option_is_added_only_after_the_process_switched_it_on() {
let owned: Vec<OsString> = vec![OsString::from("status")];
let mut command: Command = Command::new(REAL_GIT);
add_arguments(&mut command, owned.as_slice());
assert_eq!(tokens(&command), vec!["status"]);
}
#[test]
fn the_options_follow_the_global_options_of_the_caller() {
assert_eq!(tokens(&built(&["status"])), with_prefix(&[], &["status"]));
assert_eq!(
tokens(&built(&["-C", "dir", "-c", "core.hooksPath=mine", "--config-env=hook.post-commit.enabled=V", "push"])),
with_prefix(&["-C", "dir", "-c", "core.hooksPath=mine", "--config-env=hook.post-commit.enabled=V"], &["push"])
);
assert_eq!(tokens(&built(&["--version"])), with_prefix(&[], &["--version"]));
}
#[test]
fn a_configuration_query_goes_without_the_option() {
assert_eq!(tokens(&built(&["config", "--get", "core.hooksPath"])), vec!["config", "--get", "core.hooksPath"]);
assert_eq!(
tokens(&built(&["-C", "dir", "-c", "a=b", "config", "--list"])),
vec!["-C", "dir", "-c", "a=b", "config", "--list"]
);
assert_eq!(tokens(&built(&["log", "config"])), with_prefix(&[], &["log", "config"]));
}
#[test]
fn the_directory_can_never_exist() {
assert!(!Path::new(NO_HOOKS_DIRECTORY).exists());
assert!(std::fs::create_dir(NO_HOOKS_DIRECTORY).is_err());
}
fn run(directory: &Path, arguments: &[&str]) -> std::process::Output {
let mut command: Command = built(arguments);
for (name, value) in isolated_overlay() {
command.env(name, value);
}
return command.current_dir(directory).output().expect("git starts");
}
fn hooked_repository(root: &Path) -> (PathBuf, PathBuf, PathBuf) {
let repo: PathBuf = repository(root, "repo");
let file_marker: PathBuf = root.join("file-hook-ran");
let config_marker: PathBuf = root.join("config-hook-ran");
executable(
repo.join(".git/hooks/pre-commit").as_path(),
format!("#!/bin/sh\ntouch '{}'\nexit 1\n", file_marker.display()).as_bytes(),
);
executable(
root.join("config-hook").as_path(),
format!("#!/bin/sh\ntouch '{}'\n", config_marker.display()).as_bytes(),
);
git(repo.as_path(), &["config", "hook.marker.event", "pre-commit"]);
git(repo.as_path(), &["config", "hook.marker.command", root.join("config-hook").to_str().expect("UTF-8")]);
std::fs::write(repo.join("a.txt"), b"one\n").expect("a.txt");
git(repo.as_path(), &["add", "--", "a.txt"]);
return (repo, file_marker, config_marker);
}
#[test]
fn real_git_runs_no_hook_file_and_no_configured_hook() {
let root: PathBuf = fixture("hooks-off-real");
let (repo, file_marker, config_marker) = hooked_repository(root.as_path());
let committed = run(repo.as_path(), &["commit", "--quiet", "--message=one"]);
assert!(committed.status.success(), "{}", String::from_utf8_lossy(&committed.stderr));
assert!(!file_marker.exists(), "the hook file must not run");
assert!(!config_marker.exists(), "the configured hook must not run");
git(repo.as_path(), &["reset", "--quiet", "--soft", "HEAD~1"]);
let direct = crate::test_support::git_output(repo.as_path(), &["commit", "--quiet", "--message=one"]);
assert!(!direct.status.success());
assert!(file_marker.exists(), "real Git without the options runs the hook file");
assert!(config_marker.exists(), "real Git without the options runs the configured hook");
remove(root.as_path());
}
#[test]
fn the_callers_own_options_cannot_switch_a_hook_back_on() {
let root: PathBuf = fixture("hooks-off-caller");
let (repo, file_marker, config_marker) = hooked_repository(root.as_path());
let hooks: String = repo.join(".git/hooks").display().to_string();
let path_of_caller: String = format!("core.hooksPath={hooks}");
let committed = run(
repo.as_path(),
&["-c", path_of_caller.as_str(), "-c", "hook.pre-commit.enabled=true", "commit", "--quiet", "--message=one"],
);
assert!(committed.status.success(), "{}", String::from_utf8_lossy(&committed.stderr));
assert!(!file_marker.exists(), "the hook file ran");
assert!(!config_marker.exists(), "the configured hook ran");
remove(root.as_path());
}
#[test]
fn a_configuration_query_still_reports_the_repositorys_hooks_path() {
let root: PathBuf = fixture("hooks-off-query");
let repo: PathBuf = repository(root.as_path(), "repo");
git(repo.as_path(), &["config", "core.hooksPath", "/somewhere/hooks"]);
let reported = run(repo.as_path(), &["config", "--get", "core.hooksPath"]);
assert_eq!(String::from_utf8_lossy(&reported.stdout).trim(), "/somewhere/hooks");
let listed = run(repo.as_path(), &["config", "--list"]);
assert!(!String::from_utf8_lossy(&listed.stdout).contains("cli-git-no-hooks"));
remove(root.as_path());
}
#[test]
fn every_listed_event_is_a_hook_event_of_real_git() {
let root: PathBuf = fixture("hooks-off-events");
let repo: PathBuf = repository(root.as_path(), "repo");
for event in HOOK_EVENTS {
let listed = crate::test_support::git_output(repo.as_path(), &["hook", "list", event]);
let text: String = String::from_utf8_lossy(&listed.stderr).into_owned();
assert!(!text.contains("unknown hook event"), "{event}: {text}");
}
let unknown = crate::test_support::git_output(repo.as_path(), &["hook", "list", "not-a-hook-event"]);
assert!(
String::from_utf8_lossy(&unknown.stderr).contains("unknown hook event"),
"positive control: Git must refuse a made-up name: {}",
String::from_utf8_lossy(&unknown.stderr)
);
remove(root.as_path());
}
#[test]
fn a_configured_hook_for_each_switched_off_event_does_not_run() {
let root: PathBuf = fixture("hooks-off-each-event");
let repo: PathBuf = repository(root.as_path(), "repo");
for (index, event) in switched_off_events().enumerate() {
git(repo.as_path(), &["config", format!("hook.named-{index}.event").as_str(), event]);
git(repo.as_path(), &["config", format!("hook.named-{index}.command").as_str(), "true"]);
}
for (index, event) in switched_off_events().enumerate() {
let name: String = format!("named-{index}");
let switched = run(repo.as_path(), &["hook", "list", event]);
let shown: String = String::from_utf8_lossy(&switched.stdout).into_owned();
assert!(shown.contains("event-disabled") && shown.contains(name.as_str()), "{event}: {shown}");
let plain = crate::test_support::git_output(repo.as_path(), &["hook", "list", event]);
let plain_shown: String = String::from_utf8_lossy(&plain.stdout).into_owned();
assert!(plain_shown.contains(name.as_str()) && !plain_shown.contains("event-disabled"), "{event}: {plain_shown}");
}
remove(root.as_path());
}
fn documented_events() -> Option<Vec<String>> {
let output = Command::new("man")
.args(["--pager=cat", "githooks"])
.env("MANWIDTH", "200")
.env("MANPAGER", "cat")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text: String = String::from_utf8_lossy(&output.stdout).into_owned();
let mut events: Vec<String> = Vec::new();
for line in text.lines() {
let Some(name) = line.strip_prefix(" ") else {
continue;
};
if !name.is_empty() && name.chars().all(|character| { return character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'; }) {
events.push(String::from(name));
}
}
return Some(events);
}
#[test]
fn every_documented_hook_event_is_listed() {
let Some(documented) = documented_events() else {
eprintln!("skipped: the githooks manual page is not installed, so completeness was not checked");
return;
};
assert!(documented.iter().any(|name| { return name == "pre-commit"; }), "the manual was not read: {documented:?}");
let missing: Vec<&String> = documented.iter().filter(|name| { return !HOOK_EVENTS.contains(&name.as_str()); }).collect();
assert!(missing.is_empty(), "documented by this Git but not switched off: {missing:?}");
}