Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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