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

nordisk/gunnar

crates/gunnar-policy/tests/ai_trailers.rs

//! The attribution-trailer gate, both directions (owner ruling 2026-09-28:
//! "yes block AI trailers").
//!
//! What is pinned here, one test each:
//!
//! * a commit new to the forge carrying a trailer is refused, and the refusal
//!   names the commit's full sha, the offending line, and the fix;
//! * a historical trailer commit already reachable from ANOTHER branch is
//!   accepted when pushed to a new branch name;
//! * a message that merely mentions the tool in prose passes;
//! * the seed — the `installer-seed` MACHINE principal pushing into an EMPTY
//!   repository — passes with historical trailers, while a user pushing the
//!   same history into an empty repository is refused;
//! * the exemption cannot be borrowed: not by a user with the seed's id, not
//!   by another machine, not by the seed itself once the repository has a
//!   reference;
//! * "not new in this pack" is not "old": a commit a refused push left in the
//!   store is refused again;
//! * a skewed committer clock cannot turn old history into a refusal.

mod common;

use common::{expect_allow, expect_reject, refname, Repo, Scenario};
use gunnar_policy::trailer::is_ai_attribution;
use gunnar_policy::{
    AiTrailerGate, AiTrailerPolicy, Gate, PolicyConfig, PolicyEngine, Principal, RefUpdate,
};

const TRAILER: &str = "Co-Authored-By: Claude <noreply@anthropic.com>";

fn gate() -> AiTrailerGate {
    AiTrailerGate::new(AiTrailerPolicy::default())
}

fn with_trailer(subject: &str) -> String {
    format!("{subject}\n\nA body line.\n\n{TRAILER}")
}

#[test]
fn a_new_trailer_commit_is_refused_and_the_refusal_names_the_sha_the_line_and_the_fix() {
    let repo = Repo::sha1();
    let base = repo.commit("base", &[]);
    repo.set_ref("refs/heads/main", base);
    let clean = repo.commit("clean work", &[base]);
    let bad = repo.commit(&with_trailer("bad work"), &[clean]);

    let scenario = Scenario::new(repo.objects.clone())
        .pushing(RefUpdate::update(refname("refs/heads/main"), base, bad))
        .introducing([clean, bad]);
    let verdict = expect_reject(&gate(), &scenario.context(), &bad.to_string()).unwrap();
    let reason = verdict.reason().unwrap();
    assert!(reason.contains(TRAILER), "the line is not quoted: {reason}");
    assert!(
        reason.contains("line 5"),
        "the line number is missing: {reason}"
    );
    assert!(
        reason.contains("git commit --amend"),
        "no fix offered: {reason}"
    );
    assert!(
        reason.contains(&format!("git rebase -i {bad}^")),
        "{reason}"
    );
    assert!(
        !reason.contains(&clean.to_string()),
        "the clean commit is blamed: {reason}"
    );
    assert_eq!(verdict.ref_name(), Some("refs/heads/main"));
}

#[test]
fn every_offender_in_one_push_is_named_tip_first() {
    let repo = Repo::sha1();
    let base = repo.commit("base", &[]);
    repo.set_ref("refs/heads/main", base);
    let first = repo.commit(&with_trailer("first"), &[base]);
    let second = repo.commit("Generated with Claude Code\n\nmore", &[first]);

    let scenario = Scenario::new(repo.objects.clone()).pushing(RefUpdate::update(
        refname("refs/heads/main"),
        base,
        second,
    ));
    let verdict = expect_reject(&gate(), &scenario.context(), &second.to_string()).unwrap();
    let reason = verdict.reason().unwrap();
    assert!(reason.starts_with(&format!("commit {second}")), "{reason}");
    assert!(
        reason.contains(&format!(
            "1 more commit(s) in this push carry one too: {first}"
        )),
        "{reason}"
    );
}

#[test]
fn an_old_trailer_commit_on_another_branch_is_accepted_on_a_new_branch() {
    let repo = Repo::sha1();
    let base = repo.commit("base", &[]);
    let old = repo.commit(&with_trailer("historical"), &[base]);
    repo.set_ref("refs/heads/main", base);
    repo.set_ref("refs/heads/lane/2026-08", old);

    // The historical commit itself, at a new name.
    let same = Scenario::new(repo.objects.clone())
        .pushing(RefUpdate::create(refname("refs/heads/again"), old));
    expect_allow(&gate(), &same.context()).unwrap();

    // New clean work on top of it: only the new commit is judged.
    let on_top = repo.commit("clean follow-up", &[old]);
    let follow = Scenario::new(repo.objects.clone())
        .pushing(RefUpdate::create(refname("refs/heads/follow"), on_top))
        .introducing([on_top]);
    expect_allow(&gate(), &follow.context()).unwrap();

    // And moving `main` onto it — a fast-forward over history the forge holds.
    let ff = Scenario::new(repo.objects.clone()).pushing(RefUpdate::update(
        refname("refs/heads/main"),
        base,
        old,
    ));
    expect_allow(&gate(), &ff.context()).unwrap();
}

#[test]
fn a_tag_holding_old_history_counts_as_forge_history() {
    let repo = Repo::sha1();
    let old = repo.commit(&with_trailer("released"), &[]);
    let tag = repo.tag("v1", old, gix_object::Kind::Commit);
    repo.set_ref("refs/tags/v1", tag);

    let scenario = Scenario::new(repo.objects.clone())
        .pushing(RefUpdate::create(refname("refs/heads/from-tag"), old));
    expect_allow(&gate(), &scenario.context()).unwrap();
}

#[test]
fn a_prose_mention_passes() {
    let repo = Repo::sha1();
    let base = repo.commit("base", &[]);
    repo.set_ref("refs/heads/main", base);
    let tip = repo.commit(
        "Switch the review bot to Claude\n\n\
         The Anthropic SDK is bumped, and the notes say no Co-Authored-By trailers, ever.\n\
         Co-Authored-By: Anna Svensson <anna@x14.se>",
        &[base],
    );
    let scenario = Scenario::new(repo.objects.clone())
        .pushing(RefUpdate::update(refname("refs/heads/main"), base, tip))
        .introducing([tip]);
    expect_allow(&gate(), &scenario.context()).unwrap();
}

/// The false-positive corpus of `cargo xtask trailer-guard`
/// (`xtask/src/trailer_guard.rs`, `prose_about_the_rule_is_not_a_hit`),
/// replayed verbatim against the refusing matcher, plus its positives.
#[test]
fn the_trailer_guard_corpus_agrees() {
    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.",
        "The head of a pack goes out as one copy",
    ] {
        assert!(!is_ai_attribution(ok), "false positive on {ok:?}");
    }
    for hit in [
        "Co-Authored-By: Claude <noreply@anthropic.com>",
        "CO-AUTHORED-BY: CLAUDE",
        "   co-authored-by: claude   ",
        "Generated with Claude Code",
        "\u{1f916} Generated with [Claude Code](https://claude.com/claude-code)",
    ] {
        assert!(is_ai_attribution(hit), "missed {hit:?}");
    }
}

// ─────────────────────────────── the seed ───────────────────────────────────

/// A mirror's history: trailer commits deep in it, the way the house has them.
fn historical(repo: &Repo) -> gix_hash::ObjectId {
    let root = repo.commit("root", &[]);
    let t1 = repo.commit(&with_trailer("2026-08-01 incident"), &[root]);
    let mid = repo.commit("ordinary", &[t1]);
    repo.commit(
        "tip\n\n\u{1f916} Generated with [Claude Code](https://claude.com/claude-code)",
        &[mid],
    )
}

#[test]
fn the_seed_into_an_empty_repository_passes_and_a_user_push_into_one_is_refused() {
    let repo = Repo::sha1();
    let tip = historical(&repo);

    let mut seed = Scenario::new(repo.objects.clone())
        .pushing(RefUpdate::create(refname("refs/heads/master"), tip));
    seed.principal = Principal::machine("installer-seed");
    expect_allow(&gate(), &seed.context()).unwrap();

    let user = Scenario::new(repo.objects.clone())
        .pushed_by("rickard")
        .pushing(RefUpdate::create(refname("refs/heads/master"), tip));
    expect_reject(
        &gate(),
        &user.context(),
        "carries an AI attribution trailer",
    )
    .unwrap();
}

#[test]
fn the_seed_exemption_cannot_be_borrowed() {
    let repo = Repo::sha1();
    let tip = historical(&repo);
    let push = || {
        Scenario::new(repo.objects.clone())
            .pushing(RefUpdate::create(refname("refs/heads/master"), tip))
    };

    // A USER principal named like the seed.
    let impostor = push().pushed_by("installer-seed");
    expect_reject(&gate(), &impostor.context(), "AI attribution trailer").unwrap();

    // A machine that is not the seed.
    let mut ci = push();
    ci.principal = Principal::machine("ci");
    expect_reject(&gate(), &ci.context(), "AI attribution trailer").unwrap();

    // A repository whose policy names no seed principal.
    let mut seed = push();
    seed.principal = Principal::machine("installer-seed");
    let no_seed = AiTrailerGate::new(AiTrailerPolicy {
        seed_principal: None,
        ..AiTrailerPolicy::default()
    });
    expect_reject(&no_seed, &seed.context(), "AI attribution trailer").unwrap();

    // The seed itself, once the repository has any reference: the phase is over.
    let base = repo.commit("seeded", &[]);
    repo.set_ref("refs/heads/main", base);
    let fresh = repo.commit(&with_trailer("after the seed"), &[base]);
    let mut later = Scenario::new(repo.objects.clone())
        .pushing(RefUpdate::update(refname("refs/heads/main"), base, fresh))
        .introducing([fresh]);
    later.principal = Principal::machine("installer-seed");
    expect_reject(&gate(), &later.context(), &fresh.to_string()).unwrap();
}

// ─────────────────────────── what "new" means ───────────────────────────────

#[test]
fn a_commit_a_refused_push_left_in_the_store_is_refused_again() {
    // The pack's new-object set is not the measure: the first refused push
    // stored the objects, so on the second attempt the commit is not new in
    // the pack. It is still new to the forge — no reference reaches it.
    let repo = Repo::sha1();
    let base = repo.commit("base", &[]);
    repo.set_ref("refs/heads/main", base);
    let bad = repo.commit(&with_trailer("second attempt"), &[base]);

    let retry = Scenario::new(repo.objects.clone())
        .pushing(RefUpdate::create(refname("refs/heads/sneaky"), bad))
        .introducing([]);
    expect_reject(&gate(), &retry.context(), &bad.to_string()).unwrap();
}

#[test]
fn a_skewed_clock_does_not_turn_old_history_into_a_refusal() {
    // The OLD branch's tip is dated BEFORE the trailer commit under it, so a
    // date-ordered walk meets the trailer commit before the OLD paint does.
    // A root, so the NEW side runs out first and only the refusal-path tail
    // walk can clear it.
    let repo = Repo::sha1();
    let old = repo.commit_at(&with_trailer("old, clock ahead"), &[], 9_000);
    let skewed_tip = repo.commit_at("old tip, clock behind", &[old], 2_000);
    repo.set_ref("refs/heads/history", skewed_tip);

    let feature = repo.commit_at("new clean work", &[old], 10_000);
    let scenario = Scenario::new(repo.objects.clone())
        .pushing(RefUpdate::create(refname("refs/heads/feature"), feature))
        .introducing([feature]);
    expect_allow(&gate(), &scenario.context()).unwrap();
}

#[test]
fn a_new_annotated_tag_over_a_trailer_commit_is_refused() {
    let repo = Repo::sha1();
    let base = repo.commit("base", &[]);
    repo.set_ref("refs/heads/main", base);
    let bad = repo.commit(&with_trailer("tagged"), &[base]);
    let tag = repo.tag("v2", bad, gix_object::Kind::Commit);
    let scenario = Scenario::new(repo.objects.clone())
        .pushing(RefUpdate::create(refname("refs/tags/v2"), tag))
        .introducing([bad, tag]);
    expect_reject(&gate(), &scenario.context(), &bad.to_string()).unwrap();
}

#[test]
fn a_delete_is_not_judged() {
    let repo = Repo::sha1();
    let old = repo.commit(&with_trailer("history"), &[]);
    repo.set_ref("refs/heads/gone", old);
    let scenario = Scenario::new(repo.objects.clone())
        .pushing(RefUpdate::delete(refname("refs/heads/gone"), old));
    expect_allow(&gate(), &scenario.context()).unwrap();
}

// ─────────────────────────── the per-repo toggle ────────────────────────────

#[test]
fn it_is_on_by_default_and_an_administrator_can_turn_it_off_per_repository() {
    // No stored document at all, and a stored document written before the
    // field existed: both are ON.
    for config in [
        PolicyConfig::default(),
        PolicyConfig::from_json(r#"{"protection":[]}"#).unwrap(),
    ] {
        assert!(config.ai_trailers.refuse);
        assert!(PolicyEngine::from_config(&config)
            .gate_names()
            .contains(&"ai-trailers"));
    }

    let off = PolicyConfig::from_json(r#"{"ai_trailers":{"refuse":false}}"#).unwrap();
    assert_eq!(off.ai_trailers, AiTrailerPolicy::off());
    let engine = PolicyEngine::from_config(&off);
    assert!(engine.is_empty(), "{engine:?}");

    let repo = Repo::sha1();
    let bad = repo.commit(&with_trailer("allowed here"), &[]);
    let scenario = Scenario::new(repo.objects.clone())
        .pushing(RefUpdate::create(refname("refs/heads/main"), bad))
        .introducing([bad]);
    assert!(engine.evaluate(&scenario.context()).is_allowed());
    assert!(!PolicyEngine::from_config(&PolicyConfig::default())
        .evaluate(&scenario.context())
        .is_allowed());

    // It round-trips, and a typo is an error rather than a gate quietly off.
    assert_eq!(
        PolicyConfig::from_json(&off.to_json().unwrap()).unwrap(),
        off
    );
    assert!(PolicyConfig::from_json(r#"{"ai_trailers":{"refuze":false}}"#).is_err());
}

#[test]
fn it_does_not_make_the_ingest_enumerate_objects() {
    assert!(!gate().reads_new_objects());
    assert!(!PolicyEngine::from_config(&PolicyConfig::default()).reads_new_objects());
    let with_limits = PolicyConfig {
        limits: gunnar_policy::LimitPolicy::objects(10),
        ..PolicyConfig::default()
    };
    assert!(PolicyEngine::from_config(&with_limits).reads_new_objects());
}

/// GUN-PERF (2026-10-31): a push of many references into an empty repository (an
/// import: git.git is 81 000 commits under 1 005 tags) reads each new commit ONCE.
///
/// The gate used to walk every update on its own, so each tag re-read the whole
/// history below it: 328 s of server CPU for the first push of git.git, between the
/// connectivity check and the reference updates. One walk from every pushed tip
/// answers the allow; only a refusal goes back to the per-update walks to name the
/// reference.
#[test]
fn a_many_ref_push_reads_each_new_commit_once_and_not_once_per_reference() {
    const COMMITS: usize = 200;
    let repo = Repo::sha1();
    let mut chain = Vec::new();
    let mut parent: Option<gix_hash::ObjectId> = None;
    for i in 0..COMMITS {
        let c = repo.commit(&format!("commit {i}"), parent.as_slice());
        chain.push(c);
        parent = Some(c);
    }
    let counting = reads::Counting::new(repo.objects.clone());
    let read = counting.reads();
    let mut scenario = Scenario::new(counting);
    for (i, c) in chain.iter().enumerate().step_by(2) {
        scenario = scenario.pushing(RefUpdate::create(refname(&format!("refs/tags/t{i}")), *c));
    }
    let tip = *chain.last().expect("a chain");
    scenario = scenario.pushing(RefUpdate::create(refname("refs/heads/main"), tip));
    let updates = scenario.updates.len();
    expect_allow(&gate(), &scenario.context()).unwrap();
    let n = read.load(std::sync::atomic::Ordering::Relaxed);
    assert!(
        n <= 2 * (COMMITS + updates),
        "{n} object reads for {COMMITS} new commits under {updates} references: the history is walked once per reference"
    );

    // The refusal is unchanged: a trailer under one reference still refuses it by name.
    let bad = repo.commit(&with_trailer("late"), &[tip]);
    let refused = Scenario::new(repo.objects.clone())
        .pushing(RefUpdate::create(refname("refs/tags/t0"), chain[0]))
        .pushing(RefUpdate::create(refname("refs/heads/main"), bad));
    let verdict = expect_reject(&gate(), &refused.context(), &bad.to_string()).unwrap();
    assert_eq!(verdict.ref_name(), Some("refs/heads/main"));
}

/// A store that counts `GitServe::read` and hands everything to a `MemGitServe`.
mod reads {
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::sync::Arc;

    use anyhow::Result;
    use gunnar_store::git_storage_trait::{
        Caps, Extent, GcReport, GitOps, GitServe, ObjType, Oid, PackStats, ReachSet, RefCas,
        RefRow, RefUpdate, Stored, TxId,
    };
    use gunnar_store::mem::MemGitServe;

    pub struct Counting {
        inner: MemGitServe,
        reads: Arc<AtomicUsize>,
    }

    impl Counting {
        pub fn new(inner: MemGitServe) -> Self {
            Counting {
                inner,
                reads: Arc::new(AtomicUsize::new(0)),
            }
        }
        pub fn reads(&self) -> Arc<AtomicUsize> {
            Arc::clone(&self.reads)
        }
    }

    impl GitOps for Counting {
        fn put(&self, pack: &[u8], refs: &[RefUpdate]) -> Result<TxId> {
            self.inner.put(pack, refs)
        }
        fn put_pack(&self, bytes: &[u8]) -> Result<TxId> {
            self.inner.put_pack(bytes)
        }
        fn put_refs(&self, updates: &[RefUpdate]) -> Result<TxId> {
            self.inner.put_refs(updates)
        }
        fn get(&self, oid: Oid<'_>) -> Result<Option<Stored>> {
            self.inner.get(oid)
        }
        fn has(&self, oid: Oid<'_>) -> Result<bool> {
            self.inner.has(oid)
        }
        fn size(&self, oid: Oid<'_>) -> Result<Option<u64>> {
            self.inner.size(oid)
        }
        fn extents(&self, oids: &[Oid<'_>]) -> Result<Vec<Option<Extent>>> {
            self.inner.extents(oids)
        }
        fn refs(&self) -> Result<Vec<RefRow>> {
            self.inner.refs()
        }
        fn update_ref(
            &self,
            name: &str,
            old: Option<Oid<'_>>,
            new: Option<Oid<'_>>,
        ) -> Result<TxId> {
            self.inner.update_ref(name, old, new)
        }
        fn put_refs_cas(&self, edits: &[RefCas<'_>]) -> Result<TxId> {
            self.inner.put_refs_cas(edits)
        }
        fn reachable(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> Result<Vec<Vec<u8>>> {
            self.inner.reachable(want, have)
        }
        fn gc(&self) -> Result<GcReport> {
            self.inner.gc()
        }
    }

    impl GitServe for Counting {
        fn read(&self, oid: Oid<'_>) -> Result<Option<(ObjType, Vec<u8>)>> {
            self.reads.fetch_add(1, Ordering::Relaxed);
            self.inner.read(oid)
        }
        fn header(&self, oid: Oid<'_>) -> Result<Option<(ObjType, u64)>> {
            self.inner.header(oid)
        }
        fn sizes(&self, oids: &[Oid<'_>]) -> Result<Vec<Option<u64>>> {
            self.inner.sizes(oids)
        }
        fn head(&self) -> Result<Option<RefRow>> {
            self.inner.head()
        }
        fn set_head(&self, target: &str) -> Result<TxId> {
            self.inner.set_head(target)
        }
        fn emit_pack(
            &self,
            want: &[Oid<'_>],
            have: &[Oid<'_>],
            caps: &Caps,
            out: &mut dyn std::io::Write,
        ) -> Result<PackStats> {
            self.inner.emit_pack(want, have, caps, out)
        }
        fn select(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> Result<Option<ReachSet>> {
            self.inner.select(want, have)
        }
    }
}

/// GUN-PERF: receive-pack asks the gate once PER REFERENCE (`PushGate::check` takes one
/// command), so the one-walk answer above never applied to a real push. The gate of one
/// push remembers what it already cleared, and a later reference stops where an earlier
/// walk was judged clean: 101 references over 200 commits, checked one at a time, still
/// read each commit about once.
#[test]
fn references_checked_one_at_a_time_read_each_new_commit_about_once() {
    const COMMITS: usize = 200;
    let repo = Repo::sha1();
    let mut chain = Vec::new();
    let mut parent: Option<gix_hash::ObjectId> = None;
    for i in 0..COMMITS {
        let c = repo.commit(&format!("commit {i}"), parent.as_slice());
        chain.push(c);
        parent = Some(c);
    }
    let counting = reads::Counting::new(repo.objects.clone());
    let read = counting.reads();
    let shared: std::sync::Arc<dyn gunnar_store::git_storage_trait::GitServe> =
        std::sync::Arc::new(counting);
    let one_push = gate();
    let mut updates = 0;
    // `main` first, as git sends it, then the tags, oldest history first.
    let mut refs = vec![("refs/heads/main".to_string(), *chain.last().unwrap())];
    for (i, c) in chain.iter().enumerate().step_by(2) {
        refs.push((format!("refs/tags/t{i}"), *c));
    }
    for (name, tip) in refs {
        let scenario =
            Scenario::new(Shared(shared.clone())).pushing(RefUpdate::create(refname(&name), tip));
        expect_allow(&one_push, &scenario.context()).unwrap();
        updates += 1;
    }
    let n = read.load(std::sync::atomic::Ordering::Relaxed);
    assert!(
        n <= 2 * (COMMITS + updates),
        "{n} object reads for {COMMITS} new commits under {updates} references checked one at a time"
    );

    // A trailer under a later reference is still found and refused by that reference.
    let bad = repo.commit(&with_trailer("late"), &[chain[10]]);
    let refused = Scenario::new(Shared(shared.clone()))
        .pushing(RefUpdate::create(refname("refs/heads/late"), bad));
    expect_reject(&one_push, &refused.context(), &bad.to_string()).unwrap();
}

/// One store behind several scenarios, as one push hands the same store to every check.
struct Shared(std::sync::Arc<dyn gunnar_store::git_storage_trait::GitServe>);

mod shared_impl {
    use super::Shared;
    use anyhow::Result;
    use gunnar_store::git_storage_trait::{
        Caps, Extent, GcReport, GitOps, GitServe, ObjType, Oid, PackStats, ReachSet, RefCas,
        RefRow, RefUpdate, Stored, TxId,
    };

    impl GitOps for Shared {
        fn put(&self, pack: &[u8], refs: &[RefUpdate]) -> Result<TxId> {
            self.0.put(pack, refs)
        }
        fn put_pack(&self, bytes: &[u8]) -> Result<TxId> {
            self.0.put_pack(bytes)
        }
        fn put_refs(&self, updates: &[RefUpdate]) -> Result<TxId> {
            self.0.put_refs(updates)
        }
        fn get(&self, oid: Oid<'_>) -> Result<Option<Stored>> {
            self.0.get(oid)
        }
        fn has(&self, oid: Oid<'_>) -> Result<bool> {
            self.0.has(oid)
        }
        fn size(&self, oid: Oid<'_>) -> Result<Option<u64>> {
            self.0.size(oid)
        }
        fn extents(&self, oids: &[Oid<'_>]) -> Result<Vec<Option<Extent>>> {
            self.0.extents(oids)
        }
        fn refs(&self) -> Result<Vec<RefRow>> {
            self.0.refs()
        }
        fn update_ref(
            &self,
            name: &str,
            old: Option<Oid<'_>>,
            new: Option<Oid<'_>>,
        ) -> Result<TxId> {
            self.0.update_ref(name, old, new)
        }
        fn put_refs_cas(&self, edits: &[RefCas<'_>]) -> Result<TxId> {
            self.0.put_refs_cas(edits)
        }
        fn reachable(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> Result<Vec<Vec<u8>>> {
            self.0.reachable(want, have)
        }
        fn gc(&self) -> Result<GcReport> {
            self.0.gc()
        }
    }

    impl GitServe for Shared {
        fn read(&self, oid: Oid<'_>) -> Result<Option<(ObjType, Vec<u8>)>> {
            self.0.read(oid)
        }
        fn header(&self, oid: Oid<'_>) -> Result<Option<(ObjType, u64)>> {
            self.0.header(oid)
        }
        fn sizes(&self, oids: &[Oid<'_>]) -> Result<Vec<Option<u64>>> {
            self.0.sizes(oids)
        }
        fn head(&self) -> Result<Option<RefRow>> {
            self.0.head()
        }
        fn set_head(&self, target: &str) -> Result<TxId> {
            self.0.set_head(target)
        }
        fn emit_pack(
            &self,
            want: &[Oid<'_>],
            have: &[Oid<'_>],
            caps: &Caps,
            out: &mut dyn std::io::Write,
        ) -> Result<PackStats> {
            self.0.emit_pack(want, have, caps, out)
        }
        fn select(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> Result<Option<ReachSet>> {
            self.0.select(want, have)
        }
    }
}