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"]));
assert_eq!(tokens(&built(&["config", "--edit"])), with_prefix(&[], &["config", "--edit"]));
assert_eq!(tokens(&built(&["config", "--global", "-e"])), with_prefix(&[], &["config", "--global", "-e"]));
assert_eq!(tokens(&built(&["config", "--", "-e"])), vec!["config", "--", "-e"]);
}
#[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 is_event_spelling(name: &str) -> bool {
return !name.is_empty()
&& name.chars().all(|character| {
return character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-';
});
}
fn events_from_manual() -> 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();
return Some(
text.lines()
.filter_map(|line| { return line.strip_prefix(" "); })
.filter(|name| { return is_event_spelling(name); })
.map(String::from)
.collect(),
);
}
fn events_from_source() -> Option<Vec<String>> {
let directories: [&str; 2] = ["/usr/share/doc/git", "/usr/share/doc/git-doc"];
let text: String = directories
.iter()
.find_map(|directory| { return std::fs::read_to_string(Path::new(directory).join("githooks.adoc")).ok(); })?;
let lines: Vec<&str> = text.lines().collect();
return Some(
lines
.windows(2)
.filter(|pair| {
return is_event_spelling(pair[0]) && !pair[1].is_empty() && pair[1].chars().all(|c| { return c == '~'; });
})
.map(|pair| { return String::from(pair[0]); })
.collect(),
);
}
fn documented_events() -> Vec<String> {
let found: Option<Vec<String>> = events_from_source().or_else(events_from_manual);
return found.expect(
"no copy of the githooks documentation is installed (looked for githooks.adoc under /usr/share/doc/git and \
a rendered page through `man githooks`), so the list of hook events cannot be checked for completeness",
);
}
#[test]
fn every_documented_hook_event_is_listed() {
let documented: Vec<String> = documented_events();
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:?}");
}
#[test]
fn the_source_and_the_rendered_page_list_the_same_events() {
let (Some(source), Some(manual)) = (events_from_source(), events_from_manual()) else {
return;
};
let mut from_source: Vec<String> = source;
let mut from_manual: Vec<String> = manual;
from_source.sort();
from_manual.sort();
assert_eq!(from_source, from_manual);
}