a73x

src/hooks.rs

Ref:   Size: 28.4 KiB   History

//! The `commit-msg` hook that stamps `Patch: <id>` trailers — layer 1 of merge
//! recording.
//!
//! See: docs/superpowers/specs/2026-08-09-merge-recording-design.md
//!
//! [`merge_scan`](crate::merge_scan) records a merge when it finds `Patch: <id>`
//! on the patch's base branch. Something has to put it there. This does, for
//! commits made after `git-collab init` ran on this machine — which is all a
//! hook can ever cover, since `.git/hooks` is not cloned. Sync-time scanning
//! stays the mechanism that works for everyone, retroactively; this only
//! removes the need to type the trailer.
//!
//! The installed hook is a four-line shell shim that calls back into
//! `git-collab hooks run-commit-msg`, so the part that can be wrong is Rust and
//! is tested (`tests/commit_msg_hook_test.rs`).
//!
//! Three properties, in the order they matter:
//!
//! **It fails open.** Every path here returns without touching the message
//! rather than reporting an error, and the shim discards our exit status on top
//! of that. A `commit-msg` hook that can block a commit is a hook that gets
//! deleted, and it would be blocking commits to add a convenience.
//!
//! **It never clobbers an existing hook.** If a `commit-msg` hook is already
//! there and we did not write it, installation declines and says so. See
//! [`install`] for why declining rather than chaining.
//!
//! **It is idempotent.** A message already mentioning a `Patch:` trailer is
//! left exactly as it is, which is what makes `git commit --amend` — the hook
//! re-running over its own output — a no-op.

use std::path::{Path, PathBuf};

use git2::Repository;

use crate::error::Error;
use crate::state::{self, PatchState, PatchStatus};
use crate::trailer;

/// Marks a hook file as ours. Installation only ever overwrites a file
/// containing this, so a hook we did not write cannot be lost.
const MARKER: &str = "git-collab-managed-hook";

/// The subcommand the shim calls. Also what [`install`] looks for inside a
/// foreign hook to tell "someone wired us in by hand" apart from "someone
/// else's hook entirely".
const SHIM_SUBCOMMAND: &str = "hooks run-commit-msg";

/// The trailer key written into messages. Lowercase [`trailer::PATCH_TOKEN`] is
/// what reads it back; the match is case-insensitive.
const TRAILER_KEY: &str = "Patch";

/// Git's default comment character, used when `core.commentChar` is unset or
/// is something we cannot resolve to a single character (`auto`).
const DEFAULT_COMMENT_CHAR: char = '#';

// ---------------------------------------------------------------------------
// The script
// ---------------------------------------------------------------------------

/// The shim installed as `commit-msg`.
///
/// `exe` is baked in as an absolute path because a hook runs in whatever
/// environment invoked git — a GUI client, an editor, a cron job — and those
/// routinely have a `PATH` that never included `~/.local/bin`. The bare name is
/// kept as a fallback for the case the binary moved, and if neither resolves
/// the `|| true` makes that a silent no-op rather than a failed commit.
pub fn hook_script(exe: &Path) -> String {
    format!(
        "#!/bin/sh\n\
         # {marker}: stamps `{key}: <id>` trailers onto commit messages.\n\
         #\n\
         # Written by `git-collab init`, which rewrites this file in place —\n\
         # edits here are not preserved. Safe to delete: it is a convenience,\n\
         # and merges are recorded by scanning at sync time regardless.\n\
         #\n\
         # This must never fail a commit, so the exit status below is discarded\n\
         # deliberately — do not \"fix\" it.\n\
         GIT_COLLAB='{exe}'\n\
         [ -x \"$GIT_COLLAB\" ] || GIT_COLLAB=git-collab\n\
         \"$GIT_COLLAB\" {shim} \"$1\" >/dev/null 2>&1 || true\n\
         exit 0\n",
        marker = MARKER,
        key = TRAILER_KEY,
        exe = exe.display().to_string().replace('\'', "'\\''"),
        shim = SHIM_SUBCOMMAND,
    )
}

/// The line someone with their own `commit-msg` hook adds to it by hand.
pub fn shim_line() -> String {
    format!(
        "git-collab {} \"$1\" >/dev/null 2>&1 || true",
        SHIM_SUBCOMMAND
    )
}

// ---------------------------------------------------------------------------
// Installation
// ---------------------------------------------------------------------------

/// Where git looks for hooks in this repo.
///
/// `core.hooksPath` has to be honoured: when it is set, git runs hooks from
/// there and nowhere else, so installing into `.git/hooks` anyway would leave a
/// hook that never runs and produces no symptom but silence — the one failure
/// mode this feature cannot afford, since silence is also what it looks like
/// when everything is fine.
///
/// The common dir, not the gitdir: in a linked worktree those differ, and
/// hooks live with the main repository, so installing into the worktree's own
/// gitdir would put the hook somewhere git never looks.
pub fn hooks_dir(repo: &Repository) -> PathBuf {
    if let Ok(config) = repo.config() {
        if let Ok(configured) = config.get_path("core.hooksPath") {
            if configured.is_absolute() {
                return configured;
            }
            if let Some(workdir) = repo.workdir() {
                return workdir.join(configured);
            }
        }
    }
    common_dir(repo).join("hooks")
}

/// The repository's common directory.
///
/// git2 0.19 exposes no `commondir`, so this reads the `commondir` file git
/// writes into a linked worktree's gitdir — the same file git itself reads.
/// Its content is usually relative to the gitdir. A plain repository has no
/// such file, and there the gitdir *is* the common dir.
fn common_dir(repo: &Repository) -> PathBuf {
    let git_dir = repo.path();
    let Ok(contents) = std::fs::read_to_string(git_dir.join("commondir")) else {
        return git_dir.to_path_buf();
    };
    let relative = Path::new(contents.trim());
    if relative.as_os_str().is_empty() {
        return git_dir.to_path_buf();
    }
    if relative.is_absolute() {
        relative.to_path_buf()
    } else {
        git_dir.join(relative)
    }
}

/// The path of the `commit-msg` hook for this repo.
pub fn hook_path(repo: &Repository) -> PathBuf {
    hooks_dir(repo).join("commit-msg")
}

/// What [`install`] did, or declined to do.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstallOutcome {
    Installed(PathBuf),
    /// Ours already, byte-for-byte. Rewriting it would be equivalent; not
    /// rewriting it is what makes repeated `init` provably inert.
    AlreadyInstalled(PathBuf),
    /// Someone else's hook that already calls us. Reported rather than
    /// "corrected", so that following the advice in [`InstallOutcome::Foreign`]
    /// does not produce a nag on every subsequent `init`.
    ForeignHookCallsUs(PathBuf),
    /// Someone else's hook. Nothing was written.
    Foreign(PathBuf),
}

/// Install the `commit-msg` hook, unless a hook we did not write is there.
///
/// **Declines rather than chains,** and the choice is not close. Chaining means
/// moving the user's file to a name of our invention and calling it from ours:
/// a destructive filesystem change made by a command whose whole job is to edit
/// config. It breaks hooks that inspect `$0`, it is invisible in `git config`,
/// and it loses outright against `husky`/`pre-commit`/`lefthook`, all of which
/// own `commit-msg` and regenerate it — silently reverting our chain, or
/// double-invoking the user's hook after we renamed something they still
/// reference. Against that, the cost of declining is one line the user pastes
/// if they want it, and *nothing else stops working*: sync-time scanning still
/// records every merge, on every machine, retroactively. The hook is a
/// convenience. Convenience is not worth a chance of eating someone's hook.
pub fn install(repo: &Repository) -> Result<InstallOutcome, Error> {
    let path = hook_path(repo);
    let script = hook_script(&current_exe());

    if path.exists() {
        let existing = std::fs::read_to_string(&path).unwrap_or_default();
        if existing.contains(MARKER) {
            if existing == script {
                return Ok(InstallOutcome::AlreadyInstalled(path));
            }
            // Ours, but from another install (a different binary path, or an
            // older version of this script). Refreshing it is safe precisely
            // because the marker says nobody else's work is in there.
            write_executable(&path, &script)?;
            exclude_from_status(repo, &path);
            return Ok(InstallOutcome::Installed(path));
        }
        if existing.contains(SHIM_SUBCOMMAND) {
            return Ok(InstallOutcome::ForeignHookCallsUs(path));
        }
        return Ok(InstallOutcome::Foreign(path));
    }

    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    write_executable(&path, &script)?;
    exclude_from_status(repo, &path);
    Ok(InstallOutcome::Installed(path))
}

/// Keep a worktree-resident hook out of `git status`.
///
/// With a relative `core.hooksPath` the shim lands inside the working tree,
/// where it would sit as permanently-untracked noise. It is machine-local —
/// it embeds this binary's absolute path — so it belongs in
/// `.git/info/exclude`, the repo-local ignore that never gets committed, not
/// in the shared `.gitignore`.
///
/// Best-effort by design: a hook that runs matters more than a clean status,
/// so nothing here can fail the install.
fn exclude_from_status(repo: &Repository, hook: &Path) {
    // A hook under the git dir (the default `.git/hooks`) is invisible to
    // status already; only one in the working tree proper needs excluding.
    if hook.starts_with(repo.path()) || hook.starts_with(common_dir(repo)) {
        return;
    }
    let Some(workdir) = repo.workdir() else {
        return;
    };
    let Ok(relative) = hook.strip_prefix(workdir) else {
        return;
    };
    let Some(pattern) = relative.to_str() else {
        return;
    };

    let exclude = common_dir(repo).join("info").join("exclude");
    let mut content = std::fs::read_to_string(&exclude).unwrap_or_default();
    if content.lines().any(|line| line == pattern) {
        return;
    }
    if !content.is_empty() && !content.ends_with('\n') {
        content.push('\n');
    }
    content.push_str(pattern);
    content.push('\n');
    if let Some(parent) = exclude.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    let _ = std::fs::write(&exclude, content);
}

fn write_executable(path: &Path, contents: &str) -> Result<(), Error> {
    std::fs::write(path, contents)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = std::fs::metadata(path)?.permissions();
        perms.set_mode(0o755);
        std::fs::set_permissions(path, perms)?;
    }
    Ok(())
}

/// The absolute path of the running binary, falling back to the bare name if
/// the OS will not say (which the shim then resolves through `PATH`).
fn current_exe() -> PathBuf {
    std::env::current_exe().unwrap_or_else(|_| PathBuf::from("git-collab"))
}

/// Print what [`install`] did, in `init`'s reporting style.
/// The `--json` shape of an install: what happened, and to which file.
///
/// The outcome is named rather than inferred from the exit code, because three
/// of the four are successes and they mean different things — "I wrote it",
/// "it was already mine", "somebody else's already calls me", and "somebody
/// else's is in the way and I touched nothing".
pub fn install_json(outcome: &InstallOutcome) -> serde_json::Value {
    let (name, path) = match outcome {
        InstallOutcome::Installed(path) => ("installed", path),
        InstallOutcome::AlreadyInstalled(path) => ("already-installed", path),
        InstallOutcome::ForeignHookCallsUs(path) => ("foreign-hook-calls-us", path),
        InstallOutcome::Foreign(path) => ("foreign", path),
    };
    serde_json::json!({
        "action": "hooks.install",
        "outcome": name,
        "path": path.display().to_string(),
    })
}

pub fn report_install(outcome: &InstallOutcome) {
    match outcome {
        InstallOutcome::Installed(path) => {
            println!("Installed commit-msg hook ({})", path.display());
        }
        InstallOutcome::AlreadyInstalled(path) => {
            println!("commit-msg hook already installed ({})", path.display());
        }
        InstallOutcome::ForeignHookCallsUs(path) => {
            println!(
                "commit-msg hook already invokes git-collab ({})",
                path.display()
            );
        }
        InstallOutcome::Foreign(path) => {
            println!(
                "commit-msg hook not installed: {} already exists and was not written by git-collab.",
                path.display()
            );
            println!("  Nothing was changed. To stamp Patch: trailers from your own hook, add:");
            println!("    {}", shim_line());
        }
    }
}

// ---------------------------------------------------------------------------
// Which patch a commit on HEAD belongs to
// ---------------------------------------------------------------------------

/// What the hook would do for HEAD as it stands.
#[derive(Debug, Clone)]
pub enum Target {
    /// Exactly one open patch records this branch: the only case that stamps.
    One {
        branch: String,
        patch: Box<PatchState>,
    },
    /// No open patch records this branch.
    NoPatch { branch: String },
    /// Several do. Ambiguous, so nothing is stamped — see [`target`].
    Ambiguous { branch: String, count: usize },
    /// HEAD is not on a branch (detached, mid-rebase, mid-bisect).
    NoBranch,
    /// The patches could not be read at all.
    Unreadable(String),
}

/// Which patch, if any, a commit made right now belongs to.
///
/// Matching is by branch name, against `PatchState::branch`. That field is
/// documented as provenance only — nothing else resolves through it any more,
/// because patches are addressed by their own revision refs — and this is the
/// one place a branch name is the only thing available: at `commit-msg` time
/// the commit does not exist yet, so there is no commit to look up.
///
/// The consequence is that the hook inherits the known weakness of
/// branch-addressing (see the revision-refs spec, issue `659f0350`): an
/// ephemeral or renamed worktree branch does not match, and two worktrees on
/// the same generated name look like one branch. Both land in a *silent* case
/// below, which is why silence is the right answer for anything but an exact
/// single match: guessing would record a merge of a patch that never landed,
/// and a `commit-msg` hook has nobody to ask.
pub fn target(repo: &Repository) -> Target {
    let Some(branch) = head_branch(repo) else {
        return Target::NoBranch;
    };
    let patches = match state::list_patches(repo) {
        Ok(p) => p,
        Err(e) => return Target::Unreadable(e.to_string()),
    };
    let mut matched: Vec<PatchState> = patches
        .into_iter()
        .filter(|p| p.status == PatchStatus::Open && p.branch == branch)
        .collect();
    match matched.len() {
        0 => Target::NoPatch { branch },
        1 => Target::One {
            branch,
            patch: Box::new(matched.remove(0)),
        },
        count => Target::Ambiguous { branch, count },
    }
}

/// The branch HEAD points at, or `None` when HEAD is detached.
///
/// Reads the symbolic ref rather than `Repository::head`, which errors on an
/// unborn branch — the very first commit in a repo, where erroring would be
/// wrong and where a hook must be as quiet as anywhere else.
fn head_branch(repo: &Repository) -> Option<String> {
    let head = repo.find_reference("HEAD").ok()?;
    let target = head.symbolic_target()?;
    target
        .strip_prefix("refs/heads/")
        .map(|name| name.to_string())
}

// ---------------------------------------------------------------------------
// Stamping
// ---------------------------------------------------------------------------

/// The hook body: stamp the message file in place, or leave it exactly alone.
///
/// Returns nothing, on purpose. There is no error here worth a caller's
/// attention: every failure means "the message is unchanged", which is a state
/// the caller already handles because it is also the common case.
pub fn run_commit_msg(repo: &Repository, file: &Path) {
    let Ok(contents) = std::fs::read_to_string(file) else {
        return;
    };
    let Target::One { patch, .. } = target(repo) else {
        return;
    };
    let Some(stamped) = stamp(&contents, comment_char(repo), &patch.id) else {
        return;
    };
    let _ = replace_atomically(file, &stamped);
}

/// Replace `file` with `contents` by rename, never by truncate-and-write.
///
/// git reads this file the moment we exit. A crash or a full disk halfway
/// through a truncating write would hand it a message that is neither the
/// author's nor ours; a rename either happened or did not.
fn replace_atomically(file: &Path, contents: &str) -> std::io::Result<()> {
    let dir = file.parent().unwrap_or_else(|| Path::new("."));
    let tmp = dir.join(format!(".git-collab-commit-msg.{}", std::process::id()));
    std::fs::write(&tmp, contents)?;
    if let Err(e) = std::fs::rename(&tmp, file) {
        let _ = std::fs::remove_file(&tmp);
        return Err(e);
    }
    Ok(())
}

/// `core.commentChar`, or `#`.
///
/// `auto` (and any multi-character `core.commentString`) resolves to `#`, which
/// is what git itself starts from. Getting this wrong costs a missed stamp at
/// worst — the trailer lands in a paragraph that does not parse — never a
/// mangled message.
fn comment_char(repo: &Repository) -> char {
    repo.config()
        .ok()
        .and_then(|c| c.get_string("core.commentChar").ok())
        .and_then(|s| {
            let mut chars = s.chars();
            let first = chars.next()?;
            chars.next().is_none().then_some(first)
        })
        .unwrap_or(DEFAULT_COMMENT_CHAR)
}

/// Insert `Patch: <id>` into a raw commit-message file, or return `None` to
/// leave it untouched.
///
/// The input is the file git hands a `commit-msg` hook, which is **not** the
/// message git will store: it still carries git's `#` commentary, and below a
/// scissors line it may carry a whole diff. Both are stripped afterwards, and
/// that stripping is what makes placement subtle — appending at the end of the
/// file puts the trailer *after* the commentary, and once the commentary
/// vanishes the trailer joins whatever paragraph preceded it. If that paragraph
/// is prose, the trailer is unreadable to [`trailer::parse_trailers`], and the
/// merge it was supposed to record silently never happens.
///
/// So the insertion point is the end of the message *as git will store it*: the
/// last line that is neither commentary nor below the scissors. Two shapes:
///
/// - the final paragraph is already a trailer block (and is not the subject) —
///   join it, because starting a new paragraph would push the trailers that
///   were there out of the final paragraph and unlink them;
/// - anything else — a blank line, then the trailer.
///
/// Returns `None` — writing nothing at all — when:
///
/// - the message has no content yet. Stamping an empty message would make it
///   non-empty and commit work the author was in the middle of abandoning;
///   git aborts on an empty message and that abort has to keep working.
/// - a `Patch:` line is already there, anywhere. This is what makes `git commit
///   --amend` idempotent, and it also leaves a hand-written short-id trailer
///   alone rather than stapling a 40-char one underneath it.
/// - the file uses CRLF. Rebuilding it would rewrite every line ending in the
///   file, which is a much larger edit than the one asked for.
pub fn stamp(contents: &str, comment_char: char, id: &str) -> Option<String> {
    if contents.contains("\r\n") {
        return None;
    }

    let lines: Vec<&str> = contents.lines().collect();
    let cut = lines
        .iter()
        .position(|line| is_scissors(line, comment_char))
        .unwrap_or(lines.len());
    let body = &lines[..cut];

    // The message as git will store it, so that every decision below is made
    // against what the reader will eventually see.
    let stored: String = body
        .iter()
        .filter(|line| !is_comment(line, comment_char))
        .copied()
        .collect::<Vec<&str>>()
        .join("\n");

    if stored.trim().is_empty() {
        return None;
    }
    if trailer::contains_trailer_line(&stored, trailer::PATCH_TOKEN) {
        return None;
    }

    let last_content = body
        .iter()
        .rposition(|line| !is_comment(line, comment_char) && !line.trim().is_empty())?;
    let paragraph = trailer::final_paragraph(&stored)?;
    let join = paragraph.is_trailer_block && !paragraph.starts_the_message;

    let mut out: Vec<String> = lines.iter().map(|line| line.to_string()).collect();
    let at = last_content + 1;
    let new_line = format!("{}: {}", TRAILER_KEY, id);
    if join {
        out.insert(at, new_line);
    } else {
        out.splice(at..at, [String::new(), new_line]);
    }

    let mut result = out.join("\n");
    // `lines()` drops the final newline; commit message files have one, and a
    // message file that did not should not grow one.
    if contents.ends_with('\n') {
        result.push('\n');
    }
    Some(result)
}

/// A git commentary line: comment character in column one, exactly as git
/// tests it.
fn is_comment(line: &str, comment_char: char) -> bool {
    line.starts_with(comment_char)
}

/// The `# ------------------------ >8 ------------------------` line that
/// `commit -v` writes, below which git truncates everything.
fn is_scissors(line: &str, comment_char: char) -> bool {
    let Some(rest) = line.strip_prefix(comment_char) else {
        return false;
    };
    let rest = rest.trim();
    rest.contains(">8")
        && !rest.is_empty()
        && rest
            .chars()
            .all(|c| c == '-' || c == '>' || c == '8' || c == ' ')
}

// ---------------------------------------------------------------------------
// Status
// ---------------------------------------------------------------------------

/// Explain what the hook is and what it would do right now.
///
/// The hook is silent by design, so when it is not working the only symptom is
/// that nothing happens — indistinguishable from nothing needing to happen.
/// This is the command that tells those apart, and it is the reason a
/// standalone `hooks` command group exists at all.
pub fn status(repo: &Repository) -> Result<(), Error> {
    let path = hook_path(repo);
    let contents = std::fs::read_to_string(&path).unwrap_or_default();
    if !path.exists() {
        println!(
            "commit-msg hook: not installed ({} does not exist)",
            path.display()
        );
        println!("Install it with: git-collab hooks install");
    } else if contents.contains(MARKER) {
        println!("commit-msg hook: installed ({})", path.display());
    } else if contents.contains(SHIM_SUBCOMMAND) {
        println!(
            "commit-msg hook: another hook that invokes git-collab ({})",
            path.display()
        );
    } else {
        println!(
            "commit-msg hook: not installed — {} is another hook",
            path.display()
        );
        println!("  To stamp Patch: trailers from it, add:");
        println!("    {}", shim_line());
    }

    match target(repo) {
        Target::One { branch, patch } => println!(
            "HEAD is on '{}': commits would be stamped `Patch: {}` (patch {:.8} {})",
            branch, patch.id, patch.id, patch.title
        ),
        Target::NoPatch { branch } => println!(
            "HEAD is on '{}': no open patch records this branch, so nothing would be stamped",
            branch
        ),
        Target::Ambiguous { branch, count } => println!(
            "HEAD is on '{}': {} open patches record this branch, so nothing would be stamped",
            branch, count
        ),
        Target::NoBranch => {
            println!("HEAD is not on a branch, so nothing would be stamped")
        }
        Target::Unreadable(e) => {
            println!(
                "Patches could not be read ({}), so nothing would be stamped",
                e
            )
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The round trip in miniature: whatever `stamp` writes, `parse_trailers`
    /// reads back. The integration tests drive this through real `git commit`,
    /// which is what actually proves it; these pin the shapes that are awkward
    /// to reach from there.
    fn assert_round_trip(input: &str) {
        let out = stamp(input, '#', "abc123").expect("expected a stamp");
        // Approximate git's cleanup: drop commentary and anything below the
        // scissors line. The integration tests use git's real implementation.
        let cut = out
            .lines()
            .position(|l| is_scissors(l, '#'))
            .unwrap_or(usize::MAX);
        let stored: String = out
            .lines()
            .take(cut)
            .filter(|l| !is_comment(l, '#'))
            .collect::<Vec<_>>()
            .join("\n");
        assert_eq!(
            trailer::parse_trailers(&stored, trailer::PATCH_TOKEN),
            vec!["abc123".to_string()],
            "did not read back from:\n{}",
            stored
        );
    }

    #[test]
    fn stamps_a_subject_only_message_in_its_own_paragraph() {
        let out = stamp("subject\n", '#', "abc123").unwrap();
        assert_eq!(out, "subject\n\nPatch: abc123\n");
        assert_round_trip("subject\n");
    }

    #[test]
    fn a_subject_that_looks_like_a_trailer_still_gets_a_blank_line() {
        // Joining here would fold the trailer into the subject: git takes the
        // whole first paragraph as the subject line.
        let out = stamp("Fix: thing\n", '#', "abc123").unwrap();
        assert_eq!(out, "Fix: thing\n\nPatch: abc123\n");
    }

    #[test]
    fn joins_an_existing_trailer_block() {
        let out = stamp("subject\n\nIssue: xyz\n", '#', "abc123").unwrap();
        assert_eq!(out, "subject\n\nIssue: xyz\nPatch: abc123\n");
        assert_round_trip("subject\n\nIssue: xyz\n");
    }

    #[test]
    fn writes_above_the_comment_block_not_below_it() {
        let input = "subject\n\nbody prose\n\n# commentary\n# more\n";
        let out = stamp(input, '#', "abc123").unwrap();
        assert_eq!(
            out,
            "subject\n\nbody prose\n\nPatch: abc123\n\n# commentary\n# more\n"
        );
        assert_round_trip(input);
    }

    #[test]
    fn writes_above_the_scissors_line() {
        let input = "subject\n\n# ------------------------ >8 ------------------------\ndiff --git a b\n+Patch: notmine\n";
        let out = stamp(input, '#', "abc123").unwrap();
        assert!(
            out.find("Patch: abc123").unwrap() < out.find(">8").unwrap(),
            "trailer landed below the scissors line:\n{}",
            out
        );
    }

    #[test]
    fn a_patch_trailer_below_the_scissors_does_not_count_as_present() {
        // It is part of a diff, not of the message, and git throws it away.
        let input =
            "subject\n# ------------------------ >8 ------------------------\n+Patch: notmine\n";
        assert!(stamp(input, '#', "abc123").is_some());
    }

    #[test]
    fn leaves_a_message_that_already_has_a_patch_trailer() {
        assert_eq!(stamp("subject\n\nPatch: abc123\n", '#', "abc123"), None);
        assert_eq!(stamp("subject\n\nPatch: abc\n", '#', "abc123"), None);
        // Unparseable, but written by a human all the same.
        assert_eq!(stamp("subject\n\nPatch: abc oops\n", '#', "abc123"), None);
        assert_eq!(stamp("subject\n\nPatch: abc\nprose\n", '#', "abc123"), None);
    }

    #[test]
    fn leaves_an_empty_message() {
        assert_eq!(stamp("", '#', "abc123"), None);
        assert_eq!(stamp("\n\n", '#', "abc123"), None);
        assert_eq!(stamp("# just commentary\n", '#', "abc123"), None);
    }

    #[test]
    fn leaves_a_crlf_message() {
        assert_eq!(stamp("subject\r\n", '#', "abc123"), None);
    }

    #[test]
    fn honours_a_non_default_comment_char() {
        let out = stamp("subject\n; commentary\n", ';', "abc123").unwrap();
        assert_eq!(out, "subject\n\nPatch: abc123\n; commentary\n");
    }

    #[test]
    fn the_script_carries_the_marker_and_exactly_one_shim_line() {
        let script = hook_script(Path::new("/usr/bin/git-collab"));
        assert!(script.contains(MARKER));
        assert_eq!(script.matches(SHIM_SUBCOMMAND).count(), 1);
        assert!(script.contains("|| true"));
    }
}