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