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