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

nordisk/gunnar

crates/gunnar-cli/tests/ssh_end_to_end.rs

#![cfg(feature = "server")]

//! **The prize: stock `git` pushing to and cloning from a running `gunnar
//! serve`, over a real SSH connection.**
//!
//! Before `gunnar-cli` existed, nothing in the project had proved this.
//! `gunnar-wire` proved the protocol over pipes and a subprocess; `gunnar-ssh`
//! proved authentication and exec dispatch against a `BannerTransport` that
//! wrote one line and hung up. The two had never been joined, so "gunnar is a
//! git server" was an inference, not a measurement.
//!
//! What each test here exercises, end to end and with no mock anywhere:
//!
//! ```text
//!   git ── ssh ──▶ russh ── publickey ──▶ AuthTable
//!                    └── exec ── sandbox ──▶ RepoCatalog ── grant check
//!                          └── channel ──▶ gunnar-wire ──▶ gunnar-store (fs)
//!                                             receive-pack ──▶ gunnar-policy
//! ```
//!
//! Measurement, not assertion, is served three ways: the push is asserted by
//! **cloning the bytes back out** rather than by a zero exit code; a principal
//! with only `Read` is asserted to be **refused** a push, so the grant check
//! is seen to say no; and an unregistered key is asserted to be refused
//! authentication, so the identity table is seen to say no. A test that only
//! ever pushed with a correct key could not tell a working server from one
//! that allows everything.

mod common;

use common::{local_repo, raw_ssh, tools_present, Harness};
use gunnar_core::{Grant, HashKind, Level, Principal, PrincipalKind, RepoRef};

/// push → clone → the file comes back byte for byte.
fn round_trip(hash: HashKind) {
    let harness = Harness::start("team", "project", hash);
    let content = format!("gunnar round trip over {hash}\n");

    // ── push ────────────────────────────────────────────────────────────────
    let work = harness.dir.path().join("work");
    let head = local_repo(&harness, &work, hash, &content);
    harness
        .git(
            &work,
            &[
                "push",
                &harness.url("team", "project"),
                "HEAD:refs/heads/main",
            ],
        )
        .expect_ok("git push to gunnar over ssh");

    // The server's own view: the ref moved, and it moved to the right commit.
    let store = harness
        .stores
        .repo_store(
            &harness
                .stores
                .catalog()
                .open(&RepoRef::new("team", "project").unwrap())
                .unwrap(),
        )
        .expect("the repository is open");
    // `RefStore::iter` → `GitOps::refs`, and `Target::Object` → `RefRow::oid`
    // (2026-08-10). `oid: None` is what a symbolic row looks like now, so the
    // "symbolic after a push" refusal is still made and still by name.
    let refs = gunnar_store::git_storage_trait::GitOps::refs(store.serve()).expect("read the refs");
    let main = refs
        .iter()
        .find(|row| row.name == "refs/heads/main")
        .unwrap_or_else(|| panic!("refs/heads/main is not in the store after a push: {refs:?}"));
    let Some(stored) = &main.oid else {
        panic!("refs/heads/main is symbolic after a push: {main:?}")
    };
    assert_eq!(
        gix_hash::ObjectId::try_from(stored.as_slice())
            .expect("a well-sized oid")
            .to_string(),
        head,
        "the ref gunnar stored is not the commit git pushed"
    );

    // ── clone ───────────────────────────────────────────────────────────────
    let back = harness.dir.path().join("back");
    harness
        .git(
            harness.dir.path(),
            &[
                "clone",
                "--quiet",
                &harness.url("team", "project"),
                back.to_str().unwrap(),
            ],
        )
        .expect_ok("git clone from gunnar over ssh");

    // Applied output, not a state round-trip: the working tree on disk after a
    // clone, compared with the bytes that went in. A clone that "succeeded" and
    // produced an empty repository exits zero and looks identical to this one
    // from the status code alone.
    let cloned = std::fs::read_to_string(back.join("hello.txt"))
        .expect("the cloned working tree has hello.txt in it");
    assert_eq!(cloned, content, "the cloned file is not the pushed file");

    let cloned_head = harness.git(&back, &["rev-parse", "HEAD"]);
    cloned_head.expect_ok("git rev-parse in the clone");
    assert_eq!(
        cloned_head.stdout.trim(),
        head,
        "the clone's HEAD is not the commit that was pushed"
    );
}

#[test]
fn real_git_pushes_and_clones_over_ssh_sha1() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    round_trip(HashKind::Sha1);
}

/// sha256 is the default for new repositories, and the wire records that
/// answering `sha1` to a sha256 repository yields an *empty* response rather
/// than an error. That failure mode is invisible to a sha1-only suite, so the
/// sha256 path is exercised by a sha256 fixture rather than inferred from a
/// sha1 green.
#[test]
fn real_git_pushes_and_clones_over_ssh_sha256() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    round_trip(HashKind::Sha256);
}

/// **The red half of the grant check.** A principal holding only `Read` may
/// clone and must not push.
///
/// Without this, every assertion above would pass equally well against a server
/// that skipped the ACL entirely.
#[test]
fn a_read_only_principal_may_clone_and_may_not_push() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let harness = Harness::start("team", "project", HashKind::Sha1);

    // Seed the repository using the write-capable key the harness set up.
    let work = harness.dir.path().join("work");
    local_repo(&harness, &work, HashKind::Sha1, "seed\n");
    harness
        .git(
            &work,
            &[
                "push",
                &harness.url("team", "project"),
                "HEAD:refs/heads/main",
            ],
        )
        .expect_ok("the seeding push");

    // A second principal, with Read only, and its own key.
    let private = gunnar_core::mint_ed25519().expect("mint");
    let parsed = gunnar_core::ssh_key::PrivateKey::from_openssh(&private).expect("parse");
    let public = parsed.public_key().to_openssh().expect("render");
    let key_path = harness.dir.path().join("id_reader");
    std::fs::write(&key_path, &private).expect("write");
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
    }
    harness
        .stores
        .auth()
        .add_principal(Principal::new("reader", "reader", PrincipalKind::User).unwrap())
        .expect("add reader");
    harness
        .stores
        .auth()
        .register_key("reader", &public, None)
        .expect("register reader's key");
    harness
        .stores
        .auth()
        .grant(Grant::repo(
            "reader",
            RepoRef::new("team", "project").unwrap(),
            Level::Read,
        ))
        .expect("grant read");

    let reader_ssh = format!(
        "ssh -i {} -o IdentitiesOnly=yes -o IdentityAgent=none -o StrictHostKeyChecking=no \
         -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o LogLevel=ERROR",
        key_path.display()
    );
    let run = |cwd: &std::path::Path, args: &[&str]| {
        let out = std::process::Command::new("git")
            .current_dir(cwd)
            .env("GIT_SSH_COMMAND", &reader_ssh)
            .env("GIT_TERMINAL_PROMPT", "0")
            .env("HOME", harness.dir.path())
            .args(args)
            .output()
            .expect("git");
        (
            out.status.success(),
            String::from_utf8_lossy(&out.stderr).into_owned(),
        )
    };

    // Green: Read is enough to clone.
    let dest = harness.dir.path().join("reader-clone");
    let (ok, err) = run(
        harness.dir.path(),
        &[
            "clone",
            "--quiet",
            &harness.url("team", "project"),
            dest.to_str().unwrap(),
        ],
    );
    assert!(ok, "a Read principal must be able to clone: {err}");

    // Red: Write is not.
    std::fs::write(dest.join("more.txt"), "more\n").unwrap();
    run(&dest, &["add", "more.txt"]);
    run(
        &dest,
        &[
            "-c",
            "user.name=r",
            "-c",
            "user.email=r@x",
            "commit",
            "-m",
            "more",
        ],
    );
    let (ok, err) = run(&dest, &["push", "origin", "HEAD:refs/heads/main"]);
    assert!(
        !ok,
        "a Read-only principal PUSHED. The grant check is not being applied."
    );
    assert!(
        err.contains("repository not found, or you do not have access"),
        "the refusal must be gunnar's, not a transport accident: {err}"
    );
}

/// **The red half of authentication.** A key nobody registered is refused.
#[test]
fn an_unregistered_key_is_refused() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let harness = Harness::start("team", "project", HashKind::Sha1);

    let private = gunnar_core::mint_ed25519().expect("mint");
    let key_path = harness.dir.path().join("id_stranger");
    std::fs::write(&key_path, &private).expect("write");
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
    }

    let out = std::process::Command::new("git")
        .current_dir(harness.dir.path())
        .env(
            "GIT_SSH_COMMAND",
            format!(
                "ssh -i {} -o IdentitiesOnly=yes -o IdentityAgent=none -o \
                 StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes \
                 -o LogLevel=ERROR",
                key_path.display()
            ),
        )
        .env("GIT_TERMINAL_PROMPT", "0")
        .env("HOME", harness.dir.path())
        .args([
            "clone",
            "--quiet",
            &harness.url("team", "project"),
            "stranger-clone",
        ])
        .output()
        .expect("git");
    assert!(
        !out.status.success(),
        "an unregistered key CLONED. Authentication is not being applied."
    );
}

/// A repository that is not in the catalog is refused, and refused with the
/// same message as "no permission": a client that could tell the two apart
/// could enumerate private repositories one guess at a time — the ACL model forbids it.
#[test]
fn an_unknown_repository_is_refused_indistinguishably() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let harness = Harness::start("team", "project", HashKind::Sha1);
    let out = harness.git(
        harness.dir.path(),
        &[
            "clone",
            "--quiet",
            &harness.url("team", "nonexistent"),
            "nope",
        ],
    );
    out.expect_failure("cloning a repository that does not exist");
    assert!(
        out.stderr
            .contains("repository not found, or you do not have access"),
        "the message must not distinguish absence from denial: {}",
        out.stderr
    );
}

/// **The guard for the exit-status ordering bug.**
///
/// A git service that writes a correct response and then loses its exit status
/// is worse than one that fails outright: the server did the work, the client
/// reports failure, and the two disagree about what happened. This asserts the
/// status directly, at the `ssh` level, where the symptom actually lives.
///
/// Measured red, twice: **255** with a complete 244-byte advertisement on
/// stdout before `gunnar-cli` grew a 250 ms `linger` timer to invert the race,
/// and **255** again with that timer deleted and the ordering bug put back into
/// `gunnar-ssh`. The timer is gone; the fix is that `gunnar-ssh` splits the
/// russh channel instead of consuming it, so nothing closes it on drop.
#[test]
fn the_remote_command_reports_an_exit_status() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let harness = Harness::start("team", "project", HashKind::Sha1);
    let out = raw_ssh(&harness, &[], "git-upload-pack 'team/project.git'");
    assert!(
        !out.stdout.is_empty(),
        "gunnar answered nothing at all, so the status below would be about the wrong thing"
    );
    assert_eq!(
        out.status.code(),
        Some(0),
        "the remote git command did not deliver an exit status; ssh exited {:?}. gunnar served \
         {} bytes and then lost the status, which makes every successful push look failed.",
        out.status.code(),
        out.stdout.len()
    );
}

/// **The client picks the protocol version, and both answers are asserted by
/// what came back on the wire.**
///
/// A client asks for protocol v2 by sending `GIT_PROTOCOL=version=2` as an SSH
/// environment request. gunnar allowlists that one variable
/// (`gunnar_ssh::ENV_ALLOWLIST`) and passes it to `gunnar-wire`.
///
/// The two cases are told apart by their **first bytes**, not by whether they
/// succeeded: a v2 session opens with the pkt-line `version 2`, and a v0 session
/// opens with a ref advertisement. Asserting only "it worked" would let a v0
/// case quietly become a second v2 case (or the reverse) and nobody would see
/// it, which is exactly how this defect survived.
#[test]
fn the_client_chooses_the_protocol_version() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let harness = Harness::start("team", "project", HashKind::Sha1);

    // Seed the repository so a v0 advertisement has a real ref in it and the
    // two answers cannot be told apart merely by one of them being empty.
    let work = harness.dir.path().join("work");
    let head = local_repo(&harness, &work, HashKind::Sha1, "protocol\n");
    harness
        .git(
            &work,
            &[
                "push",
                &harness.url("team", "project"),
                "HEAD:refs/heads/main",
            ],
        )
        .expect_ok("the seeding push");

    // ── v2: the client asks, and gunnar answers in v2 ───────────────────────
    let v2 = raw_ssh(
        &harness,
        &["-o", "SetEnv=GIT_PROTOCOL=version=2"],
        "git-upload-pack 'team/project.git'",
    );
    let v2_out = String::from_utf8_lossy(&v2.stdout).into_owned();
    assert_eq!(
        v2.status.code(),
        Some(0),
        "stderr: {}",
        String::from_utf8_lossy(&v2.stderr)
    );
    // `000e` is the pkt-line length of "version 2\n": ten bytes of payload
    // plus the four-byte header. This is the v2 capability advertisement and
    // nothing else produces it.
    assert!(
        v2_out.starts_with("000eversion 2\n"),
        "gunnar did not speak v2 to a client that asked for it; it answered: {:?}",
        &v2_out[..v2_out.len().min(160)]
    );
    assert!(
        v2_out.contains("ls-refs") && v2_out.contains("fetch"),
        "a v2 advertisement must offer the v2 commands: {v2_out:?}"
    );
    // A v2 session advertises capabilities, NOT refs; the refs come later, in
    // answer to `ls-refs`. Seeing the commit here would mean v0 with a version
    // line bolted on.
    assert!(
        !v2_out.contains(&head),
        "a v2 capability advertisement must not carry refs: {v2_out:?}"
    );

    // ── v0: the client asks for nothing, and gunnar answers in v0 ───────────
    let v0 = raw_ssh(&harness, &[], "git-upload-pack 'team/project.git'");
    let v0_out = String::from_utf8_lossy(&v0.stdout).into_owned();
    assert_eq!(
        v0.status.code(),
        Some(0),
        "stderr: {}",
        String::from_utf8_lossy(&v0.stderr)
    );
    assert!(
        !v0_out.starts_with("000eversion 2\n"),
        "gunnar spoke v2 to a client that never asked; the v0 fallback is dead: {v0_out:?}"
    );
    // The v0 answer is a ref advertisement: the pushed commit and the ref that
    // points at it, in the first pkt-line.
    assert!(
        v0_out.contains(&head) && v0_out.contains("refs/heads/main"),
        "the v0 answer is not a ref advertisement: {v0_out:?}"
    );

    // And the two really are different answers to the same question.
    assert_ne!(v2_out, v0_out);
}

/// **The prize regression: `gunnar clone` from a running `gunnar serve`.**
///
/// This was impossible until `gunnar-ssh` learned to deliver `GIT_PROTOCOL`.
/// `gunnar push` worked (measured 2026-08-03, `unpack ok`, 3 objects, 209
/// bytes) and `gunnar clone` did not:
///
/// ```text
/// Could not decode server reply: Currently we require feature
/// "multi_ack_detailed", which is not supported by the server
/// ```
///
/// `gunnar-client` fetches through `nornir-git` → `gix`'s blocking transport,
/// which asks for v2 (`set_env("GIT_PROTOCOL", "version=2")`) and, when the
/// server does not answer in v2, falls back to a v0 client that demands
/// `multi_ack_detailed`. `gunnar_wire::caps` did not advertise it, and since
/// smart HTTP landed it does — smart HTTP cannot negotiate in single-ACK mode, so the mode
/// was built rather than declined. Delivering the variable still makes the
/// session v2, which is what this test wants; what changed is that the v0
/// fallback is no longer a dead end if it is ever taken.
///
/// The **real `gunnar` binary** is run here, not the library behind it, because
/// "gunnar clones from gunnar" is a claim about the program.
#[test]
#[cfg(feature = "client")]
fn gunnar_clones_from_gunnar() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let harness = Harness::start("team", "project", HashKind::Sha1);

    // Seed with stock git, so what `gunnar clone` reads back was demonstrably
    // put there by something other than gunnar's own client.
    let work = harness.dir.path().join("work");
    let content = "gunnar clones from gunnar\n";
    let head = local_repo(&harness, &work, HashKind::Sha1, content);
    harness
        .git(
            &work,
            &[
                "push",
                &harness.url("team", "project"),
                "HEAD:refs/heads/main",
            ],
        )
        .expect_ok("the seeding push");

    let dest = harness.dir.path().join("gunnar-clone");
    let out = std::process::Command::new(env!("CARGO_BIN_EXE_gunnar-cli"))
        .env("HOME", harness.dir.path())
        .args([
            "clone",
            &harness.url("team", "project"),
            dest.to_str().unwrap(),
            "-i",
            harness.key_path.to_str().unwrap(),
        ])
        .output()
        .expect("the gunnar binary runs");
    assert!(
        out.status.success(),
        "gunnar clone from gunnar serve failed (exit {:?})\n  stdout: {}\n  stderr: {}",
        out.status.code(),
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );

    // Applied output, not an exit code: the worktree on disk. A clone that
    // "succeeded" into an empty repository exits zero and looks identical from
    // the status alone.
    let cloned = std::fs::read_to_string(dest.join("hello.txt"))
        .expect("the cloned working tree has hello.txt in it");
    assert_eq!(cloned, content, "gunnar cloned the wrong bytes");

    let rev = harness.git(&dest, &["rev-parse", "HEAD"]);
    rev.expect_ok("git rev-parse in gunnar's clone");
    assert_eq!(
        rev.stdout.trim(),
        head,
        "gunnar's clone is not at the commit that was pushed"
    );
}

/// **The policy gate is wired, and is seen to say no.**
///
/// `gunnar-wire`'s `PushGate` seam is filled by `gunnar-policy`'s engine in
/// `gunnar_cli::server::transport::PolicyGate`. Nothing else in the project
/// exercises that join over a real connection, and a gate that always allowed
/// would be invisible in every other test in this file; they all push
/// perfectly ordinary commits.
///
/// Both directions: a fast-forward under the same rule must still be accepted,
/// or the assertion would pass equally against a gate that refuses everything.
#[test]
fn a_protected_branch_refuses_a_force_push_and_still_accepts_a_fast_forward() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let harness = Harness::start("team", "project", HashKind::Sha1);

    // Protect refs/heads/main: no force-push, no delete.
    let config = gunnar_policy::PolicyConfig {
        protection: vec![gunnar_policy::ProtectionRule::protect("refs/heads/main").unwrap()],
        ..Default::default()
    };
    let repo = harness
        .stores
        .catalog()
        .open(&RepoRef::new("team", "project").unwrap())
        .unwrap();
    let git_dir = harness
        .stores
        .repo_store(&repo)
        .unwrap()
        .git_dir()
        .to_path_buf();
    std::fs::write(
        git_dir.join(gunnar_cli::server::transport::POLICY_FILE),
        config.to_json().unwrap(),
    )
    .expect("write the policy document");

    let work = harness.dir.path().join("work");
    local_repo(&harness, &work, HashKind::Sha1, "one\n");
    let url = harness.url("team", "project");
    harness
        .git(&work, &["push", &url, "HEAD:refs/heads/main"])
        .expect_ok("creating a protected branch is allowed (allow_create defaults to true)");

    // Green: a fast-forward is still accepted under the same rule.
    std::fs::write(work.join("hello.txt"), "two\n").unwrap();
    harness.git(&work, &["add", "hello.txt"]).expect_ok("add");
    harness
        .git(&work, &["commit", "--quiet", "-m", "second"])
        .expect_ok("commit");
    harness
        .git(&work, &["push", &url, "HEAD:refs/heads/main"])
        .expect_ok("a fast-forward onto a protected branch must still be accepted");

    // Red: rewriting history is refused, by the gate, with the gate's reason.
    harness
        .git(&work, &["reset", "--hard", "HEAD~1"])
        .expect_ok("reset");
    std::fs::write(work.join("hello.txt"), "divergent\n").unwrap();
    harness.git(&work, &["add", "hello.txt"]).expect_ok("add");
    harness
        .git(&work, &["commit", "--quiet", "-m", "divergent"])
        .expect_ok("commit");
    let out = harness.git(&work, &["push", "--force", &url, "HEAD:refs/heads/main"]);
    out.expect_failure("a force-push onto a protected branch");
    assert!(
        out.stderr.contains("branch-protection"),
        "the refusal must name the gate that made it, or a pusher cannot act on it: {}",
        out.stderr
    );
}

/// **`gunnar clone` does not leave Git-LFS pointers where the binaries belong,
/// and the guard that says so is WIRED into the real binary.**
///
/// `crates/gunnar-client/tests/lfs_checkout.rs` proves the guard's logic against
/// stock git in both object formats. It cannot prove that `gunnar clone` calls
/// it — a guard nobody reaches is the most reliable green there is. This does,
/// through the actual `gunnar` binary over a real SSH connection to a real
/// `gunnar serve`, which is the only path a user takes.
///
/// The fixture needs no `git lfs` on the box: the LFS pointer is 131 bytes of
/// ordinary text, and it is committed **before** `.gitattributes` exists on disk
/// so no clean filter can touch it on the way in. What arrives on the server is
/// exactly what a real LFS repository holds.
///
/// The pointer names an object this server has never held — `gunnar serve` is
/// a git server, not an LFS one, and this test does not change what it serves.
/// Since nornir-git 0.1.11 the `gunnar` client runs the smudge driver on its
/// checkout like git does, so on a box with git-lfs installed (a `[filter
/// "lfs"]` in `/etc/gitconfig`) it would now ask git-lfs to download that object
/// and fail on the download — a different test. The clone therefore runs as a
/// real client with git-lfs installed runs when it is meant to keep pointers:
/// `GIT_LFS_SKIP_SMUDGE=1`, under which git-lfs's smudge hands the stored
/// pointer back. On a box without git-lfs the variable is inert and the writer
/// leaves the same pointer. Either way the checkout holds the stored blob,
/// which is what the guard is about.
///
/// Both directions of `filter.lfs.required`, because a guard that refused
/// everything would pass the first half on its own:
///
/// * `true`  → the clone is refused, the pointer is **not** on disk, and the
///   ordinary file next to it still is.
/// * `false` → the clone succeeds, the pointer is on disk, and gunnar has still
///   said so out loud. This is the case stock git handles in silence.
#[test]
fn gunnar_clone_refuses_to_leave_an_lfs_pointer_and_says_so_when_it_does_not() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let harness = Harness::start("team", "project", HashKind::Sha256);

    let work = harness.dir.path().join("work");
    local_repo(&harness, &work, HashKind::Sha256, "ordinary\n");
    let pointer = "version https://git-lfs.github.com/spec/v1\n\
                   oid sha256:6287e9e54d555d802e714aee1a0362e5467d51b99cc07f8627cd4b9a1df1e98a\n\
                   size 200000\n";
    std::fs::write(work.join("big.bin"), pointer).unwrap();
    harness
        .git(&work, &["add", "big.bin"])
        .expect_ok("stage the pointer while no .gitattributes exists");
    std::fs::write(
        work.join(".gitattributes"),
        "*.bin filter=lfs diff=lfs merge=lfs -text\n",
    )
    .unwrap();
    harness
        .git(&work, &["add", ".gitattributes"])
        .expect_ok("stage the attributes");
    harness
        .git(
            &work,
            &["commit", "--quiet", "-m", "an asset behind a filter"],
        )
        .expect_ok("commit");
    harness
        .git(
            &work,
            &[
                "push",
                &harness.url("team", "project"),
                "HEAD:refs/heads/main",
            ],
        )
        .expect_ok("the seeding push");

    // `gunnar clone` reads git's own knob from git's own places. Writing it into
    // this harness's HOME rather than trusting /etc/gitconfig keeps the test
    // saying the same thing on a box with no git-lfs installed.
    let clone = |required: bool, dest: &std::path::Path| {
        std::fs::write(
            harness.dir.path().join(".gitconfig"),
            format!("[filter \"lfs\"]\n\trequired = {required}\n"),
        )
        .unwrap();
        std::process::Command::new(env!("CARGO_BIN_EXE_gunnar-cli"))
            .env("HOME", harness.dir.path())
            .env("GIT_LFS_SKIP_SMUDGE", "1")
            .args([
                "clone",
                &harness.url("team", "project"),
                dest.to_str().unwrap(),
                "-i",
                harness.key_path.to_str().unwrap(),
            ])
            .output()
            .expect("the gunnar binary runs")
    };

    // ── required=true: refused, and nothing wrong left on disk ──────────────
    let refused_at = harness.dir.path().join("refused");
    let out = clone(true, &refused_at);
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(
        !out.status.success(),
        "gunnar clone exited 0 with a required filter it cannot run, so the checkout is \
         wrong and silent\n  stderr: {stderr}"
    );
    for expected in ["lfs", "big.bin", "restore --source=HEAD"] {
        assert!(
            stderr.contains(expected),
            "the refusal does not mention {expected:?}: {stderr}"
        );
    }
    assert!(
        !refused_at.join("big.bin").exists(),
        "the pointer was left on disk, which is the wrong checkout the refusal exists to \
         prevent"
    );
    assert_eq!(
        std::fs::read_to_string(refused_at.join("hello.txt")).ok(),
        Some("ordinary\n".to_string()),
        "gunnar removed a file that had no filter on it"
    );

    // ── required=false: allowed, and still not silent ───────────────────────
    let warned_at = harness.dir.path().join("warned");
    let out = clone(false, &warned_at);
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(
        out.status.success(),
        "filter.lfs.required=false is git's own way of accepting unfiltered content and must \
         not be fatal\n  stderr: {stderr}"
    );
    assert_eq!(
        std::fs::read_to_string(warned_at.join("big.bin")).ok(),
        Some(pointer.to_string()),
        "the pointer must be checked out as-is when the filter is not required"
    );
    assert!(
        stderr.contains("lfs") && stderr.contains("big.bin"),
        "gunnar wrote a pointer without saying so — the one outcome that is not \
         acceptable: {stderr}"
    );
}