use super::*;
use crate::owner_lock::{DEFAULT_POLL_DELAY, SilentWait, acquire_owner_lock};
use crate::hook_dispatch_plan::ConfigParameters;
use crate::test_support::{executable, fixture, git_context, git_text, remove, repository};
use std::path::PathBuf;
fn plan(lock: &Path, skip_lock: bool, disabled: &[&str]) -> HookDispatchPlan {
return HookDispatchPlan {
git_path: String::from(crate::test_support::REAL_GIT),
hooks_path: String::new(),
disabled_events: disabled
.iter()
.map(|name| {
return String::from(*name);
})
.collect(),
config_parameters: ConfigParameters::Absent,
worktree_root: String::new(),
lease: String::new(),
hook_lock_path: lock.display().to_string(),
skip_hook_lock: skip_lock,
};
}
fn install_hook(repo: &Path, lock: &Path, record: &Path, exit_code: u8) {
let script: String = format!(
"#!/bin/sh\n{{\n printf 'name=%s\\n' \"$GIT_AUTHOR_NAME\"\n printf 'email=%s\\n' \"$GIT_AUTHOR_EMAIL\"\n printf 'date=%s\\n' \"$GIT_AUTHOR_DATE\"\n printf 'index=%s\\n' \"$GIT_INDEX_FILE\"\n printf 'editor=%s\\n' \"$GIT_EDITOR\"\n if [ -d '{}' ]; then printf 'locked=yes\\n'; else printf 'locked=no\\n'; fi\n}} > '{}'\nexit {exit_code}\n",
lock.display(),
record.display(),
);
executable(
repo.join(".git")
.join("hooks")
.join("post-commit")
.as_path(),
script.as_bytes(),
);
}
fn recorded(record: &Path) -> String {
return std::fs::read_to_string(record).expect("hook record");
}
fn logging_git(root: &Path) -> (PathBuf, PathBuf) {
let log: PathBuf = root.join("calls.log");
let path: PathBuf = root.join("git");
let content: String = format!(
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexec {} \"$@\"\n",
log.display(),
crate::test_support::REAL_GIT,
);
executable(path.as_path(), content.as_bytes());
return (path, log);
}
#[test]
fn author_environment_reads_name_email_and_raw_date() {
assert_eq!(
author_environment("Jane Roe\u{0}jane@example.invalid\u{0}1700000000 +0000\n"),
vec![
(String::from("GIT_AUTHOR_NAME"), String::from("Jane Roe")),
(
String::from("GIT_AUTHOR_EMAIL"),
String::from("jane@example.invalid")
),
(
String::from("GIT_AUTHOR_DATE"),
String::from("@1700000000 +0000")
),
]
);
}
#[test]
fn author_environment_defaults_missing_fields_to_empty() {
assert_eq!(
author_environment("Only\0"),
vec![
(String::from("GIT_AUTHOR_NAME"), String::from("Only")),
(String::from("GIT_AUTHOR_EMAIL"), String::new()),
(String::from("GIT_AUTHOR_DATE"), String::from("@")),
]
);
}
#[test]
fn disabled_post_commit_runs_nothing() {
let root: PathBuf = fixture("post-commit-disabled");
let repo: PathBuf = repository(root.as_path(), "repo");
let lock: PathBuf = repo.join(".git").join("cli-git").join("hook.lock");
let record: PathBuf = root.join("hook.txt");
install_hook(repo.as_path(), lock.as_path(), record.as_path(), 0);
let landed: String = git_text(repo.as_path(), &["rev-parse", "HEAD"]);
let disabled_plan: HookDispatchPlan = plan(lock.as_path(), false, &["post-commit"]);
let context = git_context();
let outcome: PostCommitHookOutcome = run_post_commit_hook(PostCommitHookRequest {
context: &context,
worktree: repo.as_path(),
real_index: repo.join(".git").join("index").as_path(),
landed_oid: landed.as_str(),
plan: &disabled_plan,
})
.expect("disabled run");
assert_eq!(outcome, PostCommitHookOutcome::Disabled);
assert!(!record.exists());
remove(root.as_path());
}
#[test]
fn hook_runs_under_the_lock_with_the_native_environment() {
let root: PathBuf = fixture("post-commit-environment");
let repo: PathBuf = repository(root.as_path(), "repo");
let lock: PathBuf = repo.join(".git").join("cli-git").join("hook.lock");
let record: PathBuf = root.join("hook.txt");
install_hook(repo.as_path(), lock.as_path(), record.as_path(), 0);
let landed: String = git_text(repo.as_path(), &["rev-parse", "HEAD"]);
let index: PathBuf = repo.join(".git").join("index");
let locked_plan: HookDispatchPlan = plan(lock.as_path(), false, &[]);
let context = git_context();
let outcome: PostCommitHookOutcome = run_post_commit_hook(PostCommitHookRequest {
context: &context,
worktree: repo.as_path(),
real_index: index.as_path(),
landed_oid: landed.as_str(),
plan: &locked_plan,
})
.expect("run");
assert_eq!(outcome, PostCommitHookOutcome::Ran(0));
let text: String = recorded(record.as_path());
assert!(text.contains("locked=yes"), "{text}");
assert!(
text.contains(format!("index={}", index.display()).as_str()),
"{text}"
);
assert!(text.contains("editor=:"), "{text}");
let author: String = git_text(
repo.as_path(),
&["show", "--no-patch", "--format=%an", landed.as_str()],
);
assert!(text.contains(format!("name={author}").as_str()), "{text}");
assert!(!lock.exists(), "the hook lock is released after the hook");
remove(root.as_path());
}
#[test]
fn skipped_hook_lock_runs_without_the_lock() {
let root: PathBuf = fixture("post-commit-skip-lock");
let repo: PathBuf = repository(root.as_path(), "repo");
let lock: PathBuf = repo.join(".git").join("cli-git").join("hook.lock");
let record: PathBuf = root.join("hook.txt");
install_hook(repo.as_path(), lock.as_path(), record.as_path(), 0);
let landed: String = git_text(repo.as_path(), &["rev-parse", "HEAD"]);
let skipped_plan: HookDispatchPlan = plan(lock.as_path(), true, &[]);
let context = git_context();
run_post_commit_hook(PostCommitHookRequest {
context: &context,
worktree: repo.as_path(),
real_index: repo.join(".git").join("index").as_path(),
landed_oid: landed.as_str(),
plan: &skipped_plan,
})
.expect("run");
assert!(recorded(record.as_path()).contains("locked=no"));
remove(root.as_path());
}
#[test]
fn a_failing_hook_status_is_reported_not_raised() {
let root: PathBuf = fixture("post-commit-status");
let repo: PathBuf = repository(root.as_path(), "repo");
let lock: PathBuf = repo.join(".git").join("cli-git").join("hook.lock");
let record: PathBuf = root.join("hook.txt");
install_hook(repo.as_path(), lock.as_path(), record.as_path(), 3);
let landed: String = git_text(repo.as_path(), &["rev-parse", "HEAD"]);
let skipped_plan: HookDispatchPlan = plan(lock.as_path(), true, &[]);
let context = git_context();
let outcome: PostCommitHookOutcome = run_post_commit_hook(PostCommitHookRequest {
context: &context,
worktree: repo.as_path(),
real_index: repo.join(".git").join("index").as_path(),
landed_oid: landed.as_str(),
plan: &skipped_plan,
})
.expect("run");
assert_eq!(outcome, PostCommitHookOutcome::Ran(3));
remove(root.as_path());
}
#[test]
fn a_lock_that_cannot_be_taken_fails_before_the_hook() {
let root: PathBuf = fixture("post-commit-lock-fails");
let repo: PathBuf = repository(root.as_path(), "repo");
let blocker: PathBuf = repo.join(".git").join("blocker");
std::fs::write(blocker.as_path(), b"not a directory").expect("blocker");
let lock: PathBuf = blocker.join("hook.lock");
let record: PathBuf = root.join("hook.txt");
install_hook(repo.as_path(), lock.as_path(), record.as_path(), 0);
let landed: String = git_text(repo.as_path(), &["rev-parse", "HEAD"]);
let locked_plan: HookDispatchPlan = plan(lock.as_path(), false, &[]);
let context = git_context();
let failure: PostCommitHookError = run_post_commit_hook(PostCommitHookRequest {
context: &context,
worktree: repo.as_path(),
real_index: repo.join(".git").join("index").as_path(),
landed_oid: landed.as_str(),
plan: &locked_plan,
})
.expect_err("lock failure");
assert!(!failure.0.is_empty());
assert!(!record.exists(), "the hook must not start without its lock");
remove(root.as_path());
}
#[test]
fn an_unreadable_landed_commit_fails_before_the_hook() {
let root: PathBuf = fixture("post-commit-author-fails");
let repo: PathBuf = repository(root.as_path(), "repo");
let lock: PathBuf = repo.join(".git").join("cli-git").join("hook.lock");
let record: PathBuf = root.join("hook.txt");
install_hook(repo.as_path(), lock.as_path(), record.as_path(), 0);
let missing: &str = "0123456789abcdef0123456789abcdef01234567";
let skipped_plan: HookDispatchPlan = plan(lock.as_path(), true, &[]);
let context = git_context();
let failure: PostCommitHookError = run_post_commit_hook(PostCommitHookRequest {
context: &context,
worktree: repo.as_path(),
real_index: repo.join(".git").join("index").as_path(),
landed_oid: missing,
plan: &skipped_plan,
})
.expect_err("author lookup");
assert!(!failure.0.is_empty());
assert!(!record.exists());
remove(root.as_path());
}
#[test]
fn the_run_starts_show_then_the_hook_runner_with_the_global_options() {
let root: PathBuf = fixture("post-commit-argv");
let repo: PathBuf = repository(root.as_path(), "repo");
let lock: PathBuf = repo.join(".git").join("cli-git").join("hook.lock");
let record: PathBuf = root.join("hook.txt");
install_hook(repo.as_path(), lock.as_path(), record.as_path(), 0);
let landed: String = git_text(repo.as_path(), &["rev-parse", "HEAD"]);
let (stand_in, log): (PathBuf, PathBuf) = logging_git(root.as_path());
let mut context = git_context();
context.real_git = stand_in;
context.global_prefix = vec![
std::ffi::OsString::from("-c"),
std::ffi::OsString::from("maintenance.auto=false"),
];
let skipped_plan: HookDispatchPlan = plan(lock.as_path(), true, &[]);
run_post_commit_hook(PostCommitHookRequest {
context: &context,
worktree: repo.as_path(),
real_index: repo.join(".git").join("index").as_path(),
landed_oid: landed.as_str(),
plan: &skipped_plan,
})
.expect("run");
let calls: String = std::fs::read_to_string(log).expect("calls");
let lines: Vec<&str> = calls.lines().collect();
assert_eq!(lines.len(), 2, "{calls}");
assert!(
lines[0].starts_with("show --no-patch --date=raw"),
"{calls}"
);
assert_eq!(
lines[1], "-c maintenance.auto=false hook run --ignore-missing post-commit",
"{calls}"
);
remove(root.as_path());
}
#[test]
fn the_hook_waits_while_another_owner_holds_the_lock() {
let root: PathBuf = fixture("post-commit-waits");
let repo: PathBuf = repository(root.as_path(), "repo");
let lock: PathBuf = repo.join(".git").join("cli-git").join("hook.lock");
let record: PathBuf = root.join("hook.txt");
install_hook(repo.as_path(), lock.as_path(), record.as_path(), 0);
let landed: String = git_text(repo.as_path(), &["rev-parse", "HEAD"]);
std::fs::create_dir_all(lock.parent().expect("lock parent")).expect("lock parent");
let holder: OwnerLock =
acquire_owner_lock(lock.as_path(), DEFAULT_POLL_DELAY, &mut SilentWait).expect("held");
let waiter_repo: PathBuf = repo.clone();
let waiter_lock: PathBuf = lock.clone();
let waiter = std::thread::spawn(move || {
let locked_plan: HookDispatchPlan = plan(waiter_lock.as_path(), false, &[]);
let context = git_context();
return run_post_commit_hook(PostCommitHookRequest {
context: &context,
worktree: waiter_repo.as_path(),
real_index: waiter_repo.join(".git").join("index").as_path(),
landed_oid: landed.as_str(),
plan: &locked_plan,
});
});
std::thread::sleep(std::time::Duration::from_millis(600));
assert!(
!record.exists(),
"the hook must not run while another owner holds the lock"
);
holder.release().expect("released");
let outcome = waiter.join().expect("waiter thread").expect("run");
assert_eq!(outcome, PostCommitHookOutcome::Ran(0));
assert!(recorded(record.as_path()).contains("locked=yes"));
remove(root.as_path());
}