a73x

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}"
    );
}