tests/commit_msg_hook_test.rs
Ref: Size: 28.1 KiB History
//! Layer 1 of merge recording: the `commit-msg` hook that stamps `Patch: <id>`.
//!
//! See: docs/superpowers/specs/2026-08-09-merge-recording-design.md
//!
//! Two things are being tested here and they are not the same thing:
//!
//! 1. **The round trip.** Whatever writes the trailer has to produce something
//! [`git_collab::trailer::parse_trailers`] reads back. Writer and reader are
//! separate implementations of "what is a trailer", and between them sits
//! git's own message cleanup — which strips comments, truncates at the
//! scissors line and collapses blank runs, and so can move our trailer into
//! a paragraph that no longer parses. So every round-trip test below drives
//! a *real* `git commit` through the *real* installed hook and reads the
//! stored message back out of the object database. A test that called the
//! stamping function with a hand-built string would agree with itself and
//! prove nothing about what git stores.
//!
//! 2. **Failing open.** A `commit-msg` hook that can block a commit gets
//! deleted by its user the same day. Every failure mode has to leave the
//! message byte-identical and let the commit through.
mod common;
use std::path::Path;
use std::process::{Command, Output};
use serde_json::Value;
use common::TestRepo;
use git_collab::trailer::{parse_trailers, ISSUE_TOKEN, PATCH_TOKEN};
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
/// A repo with the hook installed, on branch `feature`, with exactly one open
/// patch recorded against that branch. Returns the repo and the patch's full
/// 40-char id.
///
/// The first commit on the branch is made *before* the patch exists — that is
/// forced by `patch create`, which needs a commit to point at, and it is why
/// the hook can only ever stamp the commits that come after it.
fn repo_with_hook_and_patch() -> (TestRepo, String) {
let repo = TestRepo::new("Alice", "alice@example.com");
repo.run_ok(&["init"]);
repo.git(&["checkout", "-b", "feature"]);
repo.commit_file("f.txt", "one", "first commit");
let out = repo.run_ok(&["patch", "create", "-t", "feature work", "-B", "feature"]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap_or_else(|| panic!("unexpected patch create output: {}", out))
.to_string();
let id = full_id(&repo, &short);
(repo, id)
}
fn full_id(repo: &TestRepo, short: &str) -> String {
let json: Value =
serde_json::from_str(&repo.run_ok(&["patch", "show", short, "--json"])).unwrap();
json["id"].as_str().unwrap().to_string()
}
/// The path of the installed `commit-msg` hook.
fn hook_path(repo: &TestRepo) -> std::path::PathBuf {
repo.dir.path().join(".git/hooks/commit-msg")
}
/// Commit `file` with `message` and return the message git actually stored.
fn commit_and_read_back(repo: &TestRepo, file: &str, message: &str) -> String {
repo.commit_file(file, "content", message);
repo.git(&["log", "-1", "--format=%B"])
}
/// Assert the stored message carries exactly one `Patch:` trailer, that our
/// own parser reads it back, and that the value it reads names `id`.
///
/// This is the round trip: git wrote the final message, our parser reads it.
fn assert_round_trips(stored: &str, id: &str) {
let found = parse_trailers(stored, PATCH_TOKEN);
assert_eq!(
found,
vec![id.to_string()],
"parse_trailers did not read back the trailer the hook wrote.\n\
stored message was:\n---\n{}\n---",
stored
);
}
fn assert_no_patch_trailer(stored: &str) {
assert!(
parse_trailers(stored, PATCH_TOKEN).is_empty(),
"expected no Patch: trailer, message was:\n---\n{}\n---",
stored
);
}
/// Write an editor script that prepends `message` to whatever git put in
/// COMMIT_EDITMSG — i.e. exactly what a human typing into `$EDITOR` produces,
/// comment block and scissors line and all. Returns its path.
fn editor_writing(repo: &TestRepo, message: &str) -> std::path::PathBuf {
let path = repo.dir.path().join(".git").join("test-editor.sh");
std::fs::write(
&path,
format!(
"#!/bin/sh\nprintf '%s' '{}' > \"$1.new\"\ncat \"$1\" >> \"$1.new\"\nmv \"$1.new\" \"$1\"\n",
message.replace('\'', "'\\''")
),
)
.unwrap();
let mut perms = std::fs::metadata(&path).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
std::fs::set_permissions(&path, perms).unwrap();
path
}
fn git_with_editor(repo: &TestRepo, editor: &Path, args: &[&str]) -> Output {
let mut command = Command::new("git");
repo.apply_env(&mut command);
command
.env("GIT_EDITOR", editor)
.args(args)
.current_dir(repo.dir.path())
.output()
.expect("failed to run git")
}
// ---------------------------------------------------------------------------
// Installation
// ---------------------------------------------------------------------------
#[test]
fn init_installs_the_commit_msg_hook() {
let repo = TestRepo::new("Alice", "alice@example.com");
let out = repo.run_ok(&["init"]);
let path = hook_path(&repo);
assert!(path.exists(), "init did not install a hook:\n{}", out);
let mode =
std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&path).unwrap().permissions());
assert!(
mode & 0o111 != 0,
"hook is not executable (mode {:o})",
mode
);
assert!(
out.contains("commit-msg hook"),
"init did not report the hook:\n{}",
out
);
}
#[test]
fn init_installs_the_hook_even_with_no_remotes() {
// `init` returns early when there are no remotes. The hook has nothing to
// do with remotes, and a fresh local repo is exactly where someone runs
// `init` first.
let repo = TestRepo::new("Alice", "alice@example.com");
assert!(repo.git(&["remote"]).trim().is_empty());
repo.run_ok(&["init"]);
assert!(
hook_path(&repo).exists(),
"no hook in a repo with no remotes"
);
}
#[test]
fn init_twice_leaves_exactly_one_hook_and_one_shim_line() {
let repo = TestRepo::new("Alice", "alice@example.com");
repo.run_ok(&["init"]);
let first = std::fs::read_to_string(hook_path(&repo)).unwrap();
let out = repo.run_ok(&["init"]);
let second = std::fs::read_to_string(hook_path(&repo)).unwrap();
assert_eq!(first, second, "second init rewrote the hook");
assert_eq!(
second.matches("run-commit-msg").count(),
1,
"second init appended another shim line:\n{}",
second
);
assert!(
out.contains("already installed"),
"second init did not report the hook as already installed:\n{}",
out
);
}
#[test]
fn init_refuses_to_touch_an_existing_foreign_hook() {
let repo = TestRepo::new("Alice", "alice@example.com");
let path = hook_path(&repo);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let theirs = "#!/bin/sh\n# my own hook\nexit 0\n";
std::fs::write(&path, theirs).unwrap();
let out = repo.run_ok(&["init"]);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
theirs,
"init modified a hook it did not write"
);
assert!(
out.contains("not installed") && out.contains("already exists"),
"init did not say clearly that it declined to install:\n{}",
out
);
assert!(
out.contains("run-commit-msg"),
"init did not offer the line to add by hand:\n{}",
out
);
}
#[test]
fn init_recognizes_a_foreign_hook_that_already_calls_git_collab() {
// Someone who took the refusal message's advice has a hook of their own
// that invokes us. Reporting that as "not installed" and re-offering the
// line would be wrong, and appending it would be worse.
let repo = TestRepo::new("Alice", "alice@example.com");
let path = hook_path(&repo);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let theirs =
"#!/bin/sh\ngit-collab hooks run-commit-msg \"$1\" >/dev/null 2>&1 || true\nexit 0\n";
std::fs::write(&path, theirs).unwrap();
let out = repo.run_ok(&["init"]);
assert_eq!(std::fs::read_to_string(&path).unwrap(), theirs);
assert!(
out.contains("already invokes git-collab"),
"init did not recognize its own shim line in a foreign hook:\n{}",
out
);
}
#[test]
fn hooks_install_honours_core_hooks_path() {
// git only runs hooks from core.hooksPath when it is set. Installing into
// .git/hooks anyway would leave a hook that never runs, and no symptom.
let repo = TestRepo::new("Alice", "alice@example.com");
repo.git(&["config", "core.hooksPath", "my-hooks"]);
repo.run_ok(&["hooks", "install"]);
assert!(
repo.dir.path().join("my-hooks/commit-msg").exists(),
"hook was not installed into core.hooksPath"
);
assert!(
!hook_path(&repo).exists(),
"hook was installed into .git/hooks, which git is not reading"
);
}
#[test]
fn installing_from_a_linked_worktree_writes_to_the_main_hooks_dir() {
// A linked worktree's gitdir is `.git/worktrees/<name>`, and git runs hooks
// from the common dir regardless. Installing into the worktree's own gitdir
// would leave a hook that never runs — and worktrees are where this project
// does most of its work.
let repo = TestRepo::new("Alice", "alice@example.com");
let elsewhere = tempfile::TempDir::new().unwrap();
let tree = elsewhere.path().join("wt");
repo.git(&["worktree", "add", tree.to_str().unwrap(), "-b", "side"]);
let mut command = Command::new(env!("CARGO_BIN_EXE_git-collab"));
repo.apply_env(&mut command);
let out = command
.args(["hooks", "install"])
.current_dir(&tree)
.output()
.unwrap();
assert!(
out.status.success(),
"hooks install failed in a worktree: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
hook_path(&repo).exists(),
"hook did not land in the main repo's hooks dir; install said: {}",
String::from_utf8_lossy(&out.stdout)
);
assert!(
!repo
.dir
.path()
.join(".git/worktrees/wt/hooks/commit-msg")
.exists(),
"hook landed in the worktree gitdir, where git never looks"
);
}
#[test]
fn hooks_status_reports_what_the_hook_would_stamp() {
// The hook is silent by design, so "nothing happened" is its only symptom
// when something is wrong. This is the command that explains it.
let (repo, id) = repo_with_hook_and_patch();
let out = repo.run_ok(&["hooks", "status"]);
assert!(
out.contains("installed"),
"status did not report installation:\n{}",
out
);
assert!(
out.contains(&id[..8]),
"status did not name the patch it would stamp:\n{}",
out
);
repo.git(&["checkout", "main"]);
let out = repo.run_ok(&["hooks", "status"]);
assert!(
out.contains("no open patch"),
"status did not explain why nothing would be stamped:\n{}",
out
);
}
// ---------------------------------------------------------------------------
// The round trip: writer vs reader, over real commit-message shapes
// ---------------------------------------------------------------------------
#[test]
fn round_trip_single_line_message() {
let (repo, id) = repo_with_hook_and_patch();
let stored = commit_and_read_back(&repo, "a.txt", "just a subject");
assert_round_trips(&stored, &id);
assert!(
stored.starts_with("just a subject\n\n"),
"trailer was glued onto the subject paragraph:\n---\n{}\n---",
stored
);
}
#[test]
fn round_trip_body_with_blank_lines() {
let (repo, id) = repo_with_hook_and_patch();
let stored = commit_and_read_back(
&repo,
"a.txt",
"subject\n\nfirst paragraph\n\nsecond paragraph",
);
assert_round_trips(&stored, &id);
}
#[test]
fn round_trip_last_paragraph_is_prose() {
// The parser refuses a final paragraph containing any prose line, so the
// trailer must start a paragraph of its own here.
let (repo, id) = repo_with_hook_and_patch();
let stored = commit_and_read_back(
&repo,
"a.txt",
"subject\n\nThanks Bob. This explains the change.",
);
assert_round_trips(&stored, &id);
}
#[test]
fn round_trip_message_already_ending_in_an_issue_trailer() {
// The trailer has to join the existing block, not start a new paragraph:
// a new paragraph would make `Issue:` stop being in the final paragraph,
// which silently breaks the commit-issue link the author wrote by hand.
let (repo, id) = repo_with_hook_and_patch();
let issue = repo.issue_open("something to fix");
let stored = commit_and_read_back(&repo, "a.txt", &format!("subject\n\nIssue: {}", issue));
assert_round_trips(&stored, &id);
assert_eq!(
parse_trailers(&stored, ISSUE_TOKEN),
vec![issue],
"stamping the Patch: trailer broke the Issue: trailer:\n---\n{}\n---",
stored
);
}
#[test]
fn round_trip_message_with_a_comment_block() {
// What `git commit` without -m actually hands the hook: the message, then
// a blank line, then git's own `#` commentary.
//
// The message ends in a trailer here on purpose. Without that, this test
// passes even if the trailer is appended *below* the commentary: the
// commentary vanishes, the blank line we wrote survives, and the trailer
// ends up in a paragraph of its own either way. It is the existing `Issue:`
// trailer that makes position observable — appended below the comments, our
// trailer is separated from it by the blank line that preceded them, which
// pushes `Issue:` out of the final paragraph and unlinks it.
let (repo, id) = repo_with_hook_and_patch();
let issue = repo.issue_open("something to fix");
let editor = editor_writing(
&repo,
&format!("subject\n\nbody prose here\n\nIssue: {}\n", issue),
);
std::fs::write(repo.dir.path().join("a.txt"), "content").unwrap();
repo.git(&["add", "a.txt"]);
let out = git_with_editor(&repo, &editor, &["commit"]);
assert!(
out.status.success(),
"commit failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stored = repo.git(&["log", "-1", "--format=%B"]);
assert_round_trips(&stored, &id);
assert_eq!(
parse_trailers(&stored, ISSUE_TOKEN),
vec![issue],
"the Issue: trailer did not survive stamping:\n---\n{}\n---",
stored
);
assert!(
!stored.contains('#'),
"commentary leaked into the stored message:\n---\n{}\n---",
stored
);
}
#[test]
fn round_trip_message_with_a_scissors_line_and_a_diff() {
// `commit -v` puts a scissors line and a raw diff below the message. The
// trailer must land above the scissors, or git truncates it away.
let (repo, id) = repo_with_hook_and_patch();
let editor = editor_writing(&repo, "subject\n\nbody prose here\n");
std::fs::write(repo.dir.path().join("a.txt"), "content").unwrap();
repo.git(&["add", "a.txt"]);
let out = git_with_editor(&repo, &editor, &["commit", "-v"]);
assert!(
out.status.success(),
"commit failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stored = repo.git(&["log", "-1", "--format=%B"]);
assert_round_trips(&stored, &id);
assert!(
!stored.contains("diff --git"),
"the diff leaked into the stored message:\n---\n{}\n---",
stored
);
}
#[test]
fn an_empty_message_still_aborts_the_commit() {
// Stamping an otherwise-empty message would make it non-empty, and git
// would commit something the author meant to abandon. Nothing else in
// this suite can catch that: the abort *is* the assertion.
let (repo, _id) = repo_with_hook_and_patch();
let before = repo.git(&["rev-parse", "HEAD"]);
let editor = editor_writing(&repo, "");
std::fs::write(repo.dir.path().join("a.txt"), "content").unwrap();
repo.git(&["add", "a.txt"]);
let out = git_with_editor(&repo, &editor, &["commit"]);
assert!(
!out.status.success(),
"an empty commit message was committed anyway:\n{}",
String::from_utf8_lossy(&out.stdout)
);
assert_eq!(
before,
repo.git(&["rev-parse", "HEAD"]),
"HEAD moved despite the empty message"
);
}
// ---------------------------------------------------------------------------
// Idempotence
// ---------------------------------------------------------------------------
#[test]
fn amending_a_stamped_commit_does_not_add_a_second_trailer() {
let (repo, id) = repo_with_hook_and_patch();
commit_and_read_back(&repo, "a.txt", "subject");
repo.git(&["commit", "--amend", "--no-edit"]);
let stored = repo.git(&["log", "-1", "--format=%B"]);
assert_round_trips(&stored, &id);
assert_eq!(
stored.matches("Patch:").count(),
1,
"amend added a second trailer:\n---\n{}\n---",
stored
);
}
#[test]
fn a_hand_written_short_trailer_is_left_alone() {
// Someone who wrote `Patch: 2575fe16` by hand must not get a second,
// 40-char trailer stapled underneath it.
let (repo, id) = repo_with_hook_and_patch();
let stored = commit_and_read_back(&repo, "a.txt", &format!("subject\n\nPatch: {}", &id[..8]));
assert_eq!(
parse_trailers(&stored, PATCH_TOKEN),
vec![id[..8].to_string()],
"the hand-written trailer was not left alone:\n---\n{}\n---",
stored
);
}
// ---------------------------------------------------------------------------
// Which patch gets stamped
// ---------------------------------------------------------------------------
#[test]
fn a_branch_with_no_open_patch_is_not_stamped() {
let repo = TestRepo::new("Alice", "alice@example.com");
repo.run_ok(&["init"]);
repo.git(&["checkout", "-b", "unrelated"]);
let stored = commit_and_read_back(&repo, "a.txt", "subject");
assert_no_patch_trailer(&stored);
}
#[test]
fn two_open_patches_on_one_branch_stamp_nothing() {
// Ambiguous, so silence: guessing would record a merge of a patch that
// never landed, and there is no way to ask at commit time.
let (repo, _id) = repo_with_hook_and_patch();
repo.commit_file("b.txt", "two", "second commit");
repo.run_ok(&["patch", "create", "-t", "another patch", "-B", "feature"]);
let stored = commit_and_read_back(&repo, "c.txt", "subject");
assert_no_patch_trailer(&stored);
}
#[test]
fn a_closed_patch_on_the_branch_is_not_stamped() {
let (repo, id) = repo_with_hook_and_patch();
repo.patch_close(&id[..8]);
let stored = commit_and_read_back(&repo, "a.txt", "subject");
assert_no_patch_trailer(&stored);
}
#[test]
fn a_detached_head_is_not_stamped() {
let (repo, _id) = repo_with_hook_and_patch();
let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
repo.git(&["checkout", "--detach", &head]);
let stored = commit_and_read_back(&repo, "a.txt", "subject");
assert_no_patch_trailer(&stored);
}
// ---------------------------------------------------------------------------
// Failing open
// ---------------------------------------------------------------------------
#[test]
fn a_commit_succeeds_when_the_git_collab_binary_is_gone() {
// Uninstalled, moved, or a PATH that a GUI git client never had. The hook
// outlives the binary and must not take the commit down with it.
let (repo, _id) = repo_with_hook_and_patch();
let path = hook_path(&repo);
let script = std::fs::read_to_string(&path).unwrap();
let broken = script.replace(
env!("CARGO_BIN_EXE_git-collab"),
"/nonexistent/bin/git-collab",
);
assert_ne!(script, broken, "hook does not embed the binary path");
std::fs::write(&path, broken).unwrap();
// A PATH with git on it and nothing else — so `git commit` still runs, and
// the hook's fallback to a bare `git-collab` finds nothing.
let bin = tempfile::TempDir::new().unwrap();
let git = String::from_utf8(
Command::new("sh")
.args(["-c", "command -v git"])
.output()
.unwrap()
.stdout,
)
.unwrap();
std::os::unix::fs::symlink(git.trim(), bin.path().join("git")).unwrap();
std::fs::write(repo.dir.path().join("a.txt"), "content").unwrap();
repo.git(&["add", "a.txt"]);
let mut command = Command::new("git");
repo.apply_env(&mut command);
let out = command
.env("PATH", bin.path())
.args(["commit", "-m", "subject"])
.current_dir(repo.dir.path())
.output()
.unwrap();
assert!(
out.status.success(),
"a missing binary blocked the commit:\n{}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
repo.git(&["log", "-1", "--format=%B"]).trim(),
"subject",
"the message was not left byte-identical"
);
}
#[test]
fn a_commit_succeeds_when_the_collab_refs_are_corrupt() {
let (repo, id) = repo_with_hook_and_patch();
// Point the patch's event ref at an object that is not there at all.
let ref_file = repo
.dir
.path()
.join(".git/refs/collab/patches")
.join(&id)
.join("events");
std::fs::create_dir_all(ref_file.parent().unwrap()).unwrap();
std::fs::write(&ref_file, "0000000000000000000000000000000000000001\n").unwrap();
let stored = commit_and_read_back(&repo, "a.txt", "subject");
assert_eq!(stored.trim(), "subject", "corrupt refs changed the message");
}
#[test]
fn the_hook_exits_zero_outside_a_git_repository() {
// git-collab itself exits 1 when it cannot open a repo. The shim has to
// swallow that, or a hook copied into a non-repo (or a repo whose gitdir
// has gone) blocks every commit.
let repo = TestRepo::new("Alice", "alice@example.com");
repo.run_ok(&["init"]);
let script = std::fs::read_to_string(hook_path(&repo)).unwrap();
let elsewhere = tempfile::TempDir::new().unwrap();
let hook = elsewhere.path().join("commit-msg");
std::fs::write(&hook, &script).unwrap();
let msg = elsewhere.path().join("MSG");
std::fs::write(&msg, "subject\n").unwrap();
let out = Command::new("sh")
.arg(&hook)
.arg(&msg)
.current_dir(elsewhere.path())
.env("HOME", elsewhere.path())
.output()
.unwrap();
assert!(
out.status.success(),
"hook exited {:?} outside a repo:\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(std::fs::read_to_string(&msg).unwrap(), "subject\n");
}
#[test]
fn the_hook_exits_zero_when_the_message_file_is_missing() {
let repo = TestRepo::new("Alice", "alice@example.com");
repo.run_ok(&["init"]);
let out = Command::new("sh")
.arg(hook_path(&repo))
.arg(repo.dir.path().join("no-such-file"))
.current_dir(repo.dir.path())
.output()
.unwrap();
assert!(
out.status.success(),
"a missing message file failed the hook"
);
}
// ---------------------------------------------------------------------------
// End to end: the three layers connected
// ---------------------------------------------------------------------------
#[test]
fn a_hook_stamped_commit_is_recorded_as_merged_by_sync() {
// The whole point of layer 1. No hand-written trailer, no `patch merge`.
let bare = tempfile::TempDir::new().unwrap();
let status = Command::new("git")
.args(["init", "--bare", "-b", "main"])
.arg(bare.path())
.status()
.unwrap();
assert!(status.success());
let repo = TestRepo::new("Alice", "alice@example.com");
repo.git(&["remote", "add", "origin", bare.path().to_str().unwrap()]);
repo.git(&["push", "-u", "origin", "main"]);
repo.run_ok(&["init"]);
repo.git(&["checkout", "-b", "feature"]);
repo.commit_file("f.txt", "one", "first commit");
let out = repo.run_ok(&["patch", "create", "-t", "feature work", "-B", "feature"]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
// A commit made after the patch exists — the one the hook can stamp.
repo.commit_file("f.txt", "two", "more work");
repo.run_ok(&["patch", "revise", &short, "-b", "second pass"]);
let stored = repo.git(&["log", "-1", "--format=%B"]);
assert!(
!parse_trailers(&stored, PATCH_TOKEN).is_empty(),
"the hook did not stamp the commit:\n---\n{}\n---",
stored
);
repo.git(&["checkout", "main"]);
repo.git(&["merge", "--no-ff", "-m", "merge feature", "feature"]);
repo.run_ok(&["sync"]);
let json: Value =
serde_json::from_str(&repo.run_ok(&["patch", "show", &short, "--json"])).unwrap();
assert_eq!(
json["status"].as_str().unwrap().to_lowercase(),
"merged",
"sync did not record the merge the hook made possible"
);
}
// ---------------------------------------------------------------------------
// Keeping a worktree-resident hook out of `git status`
// ---------------------------------------------------------------------------
/// When `core.hooksPath` points inside the working tree, the installed shim
/// lands in the working tree — and it is machine-local (it embeds the binary
/// path), so it belongs in `.git/info/exclude`, never in the shared
/// `.gitignore`. The oracle is git itself: after install, `git status` must
/// not see the file.
#[test]
fn installing_into_a_worktree_hooks_path_excludes_the_hook_from_status() {
let repo = TestRepo::new("Alice", "alice@example.com");
repo.git(&["config", "core.hooksPath", ".githooks"]);
repo.run_ok(&["hooks", "install"]);
assert!(
repo.dir.path().join(".githooks/commit-msg").exists(),
"hook was not installed into core.hooksPath"
);
let exclude =
std::fs::read_to_string(repo.dir.path().join(".git/info/exclude")).unwrap_or_default();
assert!(
exclude.lines().any(|l| l == ".githooks/commit-msg"),
"install did not add the hook to .git/info/exclude:\n{exclude}"
);
let status = repo.git(&["status", "--porcelain"]);
assert!(
!status.contains(".githooks"),
"the installed hook shows up as untracked:\n{status}"
);
}
/// A second install must not duplicate the exclude line.
#[test]
fn installing_twice_writes_the_exclude_line_once() {
let repo = TestRepo::new("Alice", "alice@example.com");
repo.git(&["config", "core.hooksPath", ".githooks"]);
repo.run_ok(&["hooks", "install"]);
repo.run_ok(&["hooks", "install"]);
let exclude =
std::fs::read_to_string(repo.dir.path().join(".git/info/exclude")).unwrap_or_default();
let count = exclude
.lines()
.filter(|l| *l == ".githooks/commit-msg")
.count();
assert_eq!(count, 1, "exclude line duplicated:\n{exclude}");
}
/// The default install goes to `.git/hooks`, which git never shows in status;
/// nothing should be written to the exclude file for it.
#[test]
fn installing_into_the_default_hooks_dir_leaves_exclude_alone() {
let repo = TestRepo::new("Alice", "alice@example.com");
repo.run_ok(&["hooks", "install"]);
let exclude =
std::fs::read_to_string(repo.dir.path().join(".git/info/exclude")).unwrap_or_default();
assert!(
!exclude.contains("commit-msg"),
"a .git/hooks install polluted the exclude file:\n{exclude}"
);
}
/// The exclude file is the user's; adding our line must not clobber theirs.
#[test]
fn a_pre_existing_exclude_keeps_its_content() {
let repo = TestRepo::new("Alice", "alice@example.com");
let info = repo.dir.path().join(".git/info");
std::fs::create_dir_all(&info).unwrap();
std::fs::write(info.join("exclude"), "scratch.log\n").unwrap();
repo.git(&["config", "core.hooksPath", ".githooks"]);
repo.run_ok(&["hooks", "install"]);
let exclude = std::fs::read_to_string(info.join("exclude")).unwrap();
assert!(
exclude.lines().any(|l| l == "scratch.log"),
"pre-existing exclude content was lost:\n{exclude}"
);
assert!(
exclude.lines().any(|l| l == ".githooks/commit-msg"),
"hook line missing from a pre-existing exclude:\n{exclude}"
);
}