xtask/src/trailer_guard.rs
//! `cargo xtask trailer-guard` — find machine-attribution trailers before a
//! remote does.
//!
//! **Read-only, and deliberately NOT part of `cargo xtask ci`.** It is a tool an
//! operator reaches for, not a gate that runs on every build: it walks git
//! history, which is slow, and a repository that has been clean for a year does
//! not need it re-proved on every commit. `ci` staying fast is the reason it is
//! opt-in.
//!
//! ```text
//! cargo xtask trailer-guard # tracked files + all commit messages
//! cargo xtask trailer-guard --commits N # only the last N commits
//! ```
//!
//! # Why this exists
//!
//! This repository's history carries one author per commit and no third-party
//! attribution trailer. The rule was broken once, on 2026-08-01: five commits
//! and one branch carried such a trailer and had to be rewritten and
//! force-pushed after they were already on a shared remote. The expensive half
//! was the history rewrite on a shared `main`; the cheap half is one command
//! that takes a few seconds, which is this one.
//!
//! # It finds, it does not rewrite
//!
//! Rewriting means `filter-branch`/`filter-repo` over shared history, which is
//! the expensive half of the 2026-08-01 incident rather than the cheap half.
//! **A finder is safe to run anywhere, on anything, at any time**, so it ships
//! first and alone. If a rewriter is ever wanted it belongs behind its own verb,
//! its own confirmation, and its own backup.
use std::path::Path;
use std::process::Command;
/// The needles. **Data, not description** — each entry is a literal trailer
/// string that has been seen in the wild, and the list is what the scanner
/// matches, nothing more.
///
/// Matched case-insensitively against whole lines. Kept narrow on purpose: a
/// bare vendor name would hit this very file, every note that discusses the
/// rule, and any legitimate prose about the tooling — a scanner that cries wolf
/// gets switched off, which is worse than not having one.
const MARKERS: &[&str] = &[
"co-authored-by: claude",
"co-authored-by: anthropic",
"generated with [claude",
"generated with claude",
"🤖 generated",
"claude was here",
"noreply@anthropic.com",
];
/// Tracked files that must spell the needles out to do their job. Exact
/// paths, not a pattern, so a new file does not inherit the exemption.
const NAMES_THE_NEEDLES: &[&str] = &[
"xtask/src/trailer_guard.rs",
"crates/gunnar-policy/src/trailer.rs",
"crates/gunnar-policy/src/gates/ai_trailers.rs",
"crates/gunnar-policy/tests/ai_trailers.rs",
"crates/gunnar-policy/tests/common/mod.rs",
"crates/gunnar-cli/tests/ai_trailers_refused.rs",
];
fn hits(haystack: &str) -> Vec<(usize, String)> {
let mut out = Vec::new();
for (n, line) in haystack.lines().enumerate() {
let lower = line.to_ascii_lowercase();
if MARKERS.iter().any(|m| lower.contains(m)) {
out.push((n + 1, line.trim().to_string()));
}
}
out
}
fn git(root: &Path, args: &[&str]) -> Option<String> {
let out = Command::new("git")
.current_dir(root)
.args(args)
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}
pub fn run(root: &Path, limit: Option<usize>) -> Result<(), String> {
let mut found = 0usize;
// ── commit messages ────────────────────────────────────────────────────
//
// `%B` is the whole message including the trailer block, which is where a
// `Co-Authored-By` actually lives; `%s` would miss every one of them.
let range = limit.map(|n| format!("-{n}"));
let mut args = vec!["log", "--format=%H%x00%B%x00%x00"];
if let Some(r) = &range {
args.push(r);
}
let log = git(root, &args).ok_or("git log failed")?;
let mut commits = 0usize;
for record in log.split("\0\0\n").filter(|s| !s.trim().is_empty()) {
let (sha, body) = record.split_once('\0').unwrap_or((record, ""));
commits += 1;
for (_, line) in hits(body) {
println!("commit {} {line}", &sha[..sha.len().min(12)]);
found += 1;
}
}
// ── tracked files ──────────────────────────────────────────────────────
let files = git(root, &["ls-files", "-z"]).ok_or("git ls-files failed")?;
let mut scanned = 0usize;
for rel in files.split('\0').filter(|s| !s.is_empty()) {
// These files name the needles they look for, so they would match
// themselves: this finder, and the forge's refusing gate
// (`gunnar-policy`'s `ai-trailers`) with the tests that prove it.
if NAMES_THE_NEEDLES.iter().any(|path| rel.ends_with(path)) {
continue;
}
let Ok(text) = std::fs::read_to_string(root.join(rel)) else {
continue; // binary or unreadable: not a place a trailer hides
};
scanned += 1;
for (n, line) in hits(&text) {
println!("{rel}:{n} {line}");
found += 1;
}
}
println!("trailer-guard: {commits} commit(s), {scanned} tracked text file(s), {found} hit(s)");
if found > 0 {
return Err(format!(
"{found} attribution trailer(s) found. These must not reach a remote. \
Nothing was modified — this verb only reports."
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// The needles are found where they really appear.
#[test]
fn a_trailer_is_found() {
let body =
"Fix the thing\n\nSome prose.\n\nCo-Authored-By: Claude <noreply@anthropic.com>\n";
let h = hits(body);
assert_eq!(h.len(), 1, "expected one hit, got {h:?}");
assert_eq!(h[0].0, 5, "wrong line number: {h:?}");
}
/// Case and spacing do not hide one.
#[test]
fn case_does_not_hide_a_trailer() {
assert_eq!(hits("CO-AUTHORED-BY: CLAUDE").len(), 1);
assert_eq!(hits(" co-authored-by: claude ").len(), 1);
assert_eq!(hits("Generated with Claude Code").len(), 1);
}
/// **The half that makes it usable.** A scanner that fires on ordinary prose
/// about the rule gets switched off, and a switched-off scanner is worse
/// than none — every line below is the kind of sentence a note about the
/// rule contains.
#[test]
fn prose_about_the_rule_is_not_a_hit() {
for ok in [
"One author per commit, and no attribution trailer.",
"No Co-Authored-By trailers, ever.",
"The tooling must not add a trailer.",
"This verb reports trailers; it never rewrites history.",
] {
assert!(hits(ok).is_empty(), "false positive on {ok:?}");
}
}
/// A clean message is clean.
#[test]
fn an_ordinary_message_is_clean() {
assert!(hits("The head of a pack goes out as one copy\n\nBecause it can.\n").is_empty());
}
/// A throwaway repository, removed on drop.
struct Repo(std::path::PathBuf);
impl Repo {
fn new(name: &str) -> Repo {
let root =
std::env::temp_dir().join(format!("gunnar-trailer-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let r = Repo(root);
r.git(&["init", "-q"]);
r
}
fn git(&self, args: &[&str]) {
let ok = Command::new("git")
.current_dir(&self.0)
.args(["-c", "user.name=t", "-c", "user.email=t@mock.invalid"])
.args(args)
.output()
.unwrap()
.status
.success();
assert!(ok, "git {args:?}");
}
fn commit(&self, file: &str, text: &str, msg: &str) {
let p = self.0.join(file);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, text).unwrap();
self.git(&["add", "-A"]);
self.git(&["commit", "-q", "-m", msg]);
}
}
impl Drop for Repo {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A clean history and tree pass.
#[test]
fn a_clean_repository_passes() {
let r = Repo::new("clean");
r.commit(
"src/lib.rs",
"pub fn x() {}\n",
"The first commit\n\nNo attribution here.",
);
assert_eq!(run(&r.0, None), Ok(()));
}
/// A trailer in an old commit is found, and `--commits 1` looks past it only
/// because it was told to.
#[test]
fn a_trailer_in_history_is_found_unless_the_window_excludes_it() {
let r = Repo::new("history");
r.commit(
"a.txt",
"a\n",
"Old work\n\nCo-Authored-By: Claude <noreply@anthropic.com>",
);
r.commit("b.txt", "b\n", "New work");
let e = run(&r.0, None).unwrap_err();
assert!(e.starts_with("1 attribution trailer(s) found"), "{e}");
assert_eq!(
run(&r.0, Some(1)),
Ok(()),
"the newest commit alone is clean"
);
}
/// A tracked file carrying one is found, the files that name the needles
/// are skipped, and a binary file is not read as text.
#[test]
fn a_trailer_in_a_tracked_file_is_found_and_the_needle_files_are_skipped() {
let r = Repo::new("files");
r.commit(
"xtask/src/trailer_guard.rs",
"// generated with claude\n",
"The finder names its needles",
);
std::fs::write(r.0.join("blob.bin"), [0xffu8, 0xfe, 0x00, 0x80]).unwrap();
r.commit("notes.md", "fine\n", "A note");
assert_eq!(run(&r.0, None), Ok(()));
r.commit("docs/x.md", "line one\n🤖 Generated by a robot\n", "A doc");
let e = run(&r.0, None).unwrap_err();
assert!(e.starts_with("1 attribution trailer(s) found"), "{e}");
}
/// Outside a repository the guard fails rather than reporting zero hits.
#[test]
fn outside_a_repository_it_fails() {
let d = std::env::temp_dir().join(format!("gunnar-trailer-none-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
assert_eq!(run(&d, None), Err("git log failed".to_string()));
let _ = std::fs::remove_dir_all(&d);
}
}