crates/gunnar-client/tests/lfs_checkout.rs
//! **A `gunnar` checkout that would leave Git-LFS pointers where the binaries
//! belong is refused, not written.**
//!
//! Stock git 2.53.0 is the judge in every test here, and the same fixture is
//! built for **both object formats** — `tunnr` is a SHA-256 corpus and the
//! LFS-carrying one, so a sha1-only green would say nothing about it.
//!
//! # What the fixture is
//!
//! A repository with `*.bin filter=lfs`, a 200 000-byte asset that git-lfs
//! stores as a 131-byte pointer blob, and one ordinary file next to it so a test
//! can tell "the checkout was refused" from "the checkout was wrecked".
//!
//! # The writer under test
//!
//! `nornir_git::gitio::materialize_worktree`, reached here through
//! `set_head_and_checkout`. It is the same function `gunnar clone` reaches over
//! SSH. Until nornir-git 0.1.11 it hand-wrote each index blob and ran no
//! filter, so it wrote the 131-byte pointer where stock git wrote the asset —
//! the defect this file used to pin. Since 0.1.11 (edda 142c637, "one worktree
//! writer") it IS gix's checkout and runs the configured smudge driver, so a
//! client with git-lfs installed gets the asset over SSH as it does over a
//! local URL, and the first test below now pins that parity instead.
//!
//! # Where the pointer still lands, and how these tests put it there
//!
//! The guard (`gunnar_client::guard_checkout`) is about a checkout that holds
//! the STORED pointer where a filter was declared — and a real client with
//! git-lfs installed still produces one: told `GIT_LFS_SKIP_SMUDGE=1`, git-lfs's
//! smudge runs and hands the pointer back unchanged (the shell that cloned the
//! constellation exports exactly that for the forge's LFS pdfs). Every test
//! about the pointer therefore installs the driver on its own clone and sets
//! that variable; every test about the asset clears it. It is the PROCESS's
//! environment that git-lfs reads — gix spawns the driver as a child of this
//! test binary — so both go through [`smudge_env`], which holds one lock for
//! the length of the test. The server is not involved in any of it: these are
//! the client's checkouts.
mod common;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Mutex, MutexGuard};
use common::{format_flag, git};
use gix::hash::Kind;
/// The size of the asset, and of the pointer that stands in for it. Both are
/// asserted rather than assumed: a test that does not know which one it is
/// looking at cannot tell a working smudge from a dead one.
const ASSET_BYTES: usize = 200_000;
/// **The `lfs` filter driver, scoped to ONE repository** — what `git lfs
/// install --local` writes into `.git/config`, and nothing global.
///
/// These tests used to lean on the box's GLOBAL `git lfs install` (a
/// `[filter "lfs"]` section in `~/.gitconfig` or `/etc/gitconfig`) for every
/// checkout that was meant to be FILTERED: `build_lfs_repo` ran `git lfs
/// install --local`, which configures the SOURCE repository only, and a clone
/// does not inherit its origin's config. MEASURED on dwarf 2026-09-29, where
/// git-lfs 3.8.0 is on PATH but was never globally installed: both "filtered"
/// clones wrote the 131-byte pointer, and the two tests that need a filtered
/// checkout were red on the box rather than on the code
/// (`stock_git_writes_…`: `left: Some(131) right: Some(200000)`, the same in
/// `a_checkout_whose_filter_did_run_…`). Installing the filter globally would
/// have made them green by changing every other repository on the machine —
/// and made the "unfiltered" tests below depend on it too. So a test that
/// wants a filtered checkout says so, on the clone it makes, with these keys.
const LFS_FILTER: [(&str, &str); 4] = [
("filter.lfs.clean", "git-lfs clean -- %f"),
("filter.lfs.smudge", "git-lfs smudge -- %f"),
("filter.lfs.process", "git-lfs filter-process"),
("filter.lfs.required", "true"),
];
/// [`LFS_FILTER`] as `key=value` lines, the shape both `git clone -c` and
/// gix's in-memory overrides take.
fn lfs_filter_overrides() -> Vec<String> {
LFS_FILTER.iter().map(|(k, v)| format!("{k}={v}")).collect()
}
/// Write [`LFS_FILTER`] into `repo`'s own `.git/config`, so a later open of it
/// (the guard's) reads the same driver the checkout ran with.
fn configure_lfs_filter_locally(repo: &Path) {
for (key, value) in LFS_FILTER {
git(repo, &["config", "--local", key, value]);
}
}
/// What git-lfs is told about the smudge, for the length of one test.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Smudge {
/// `GIT_LFS_SKIP_SMUDGE` removed: the smudge writes the asset.
Run,
/// `GIT_LFS_SKIP_SMUDGE=1`: the smudge runs and writes the stored pointer.
Skip,
}
/// Serialises every test that reads or sets `GIT_LFS_SKIP_SMUDGE`. The tests in
/// this binary run on parallel threads and the variable is process-wide.
static SMUDGE_ENV_LOCK: Mutex<()> = Mutex::new(());
/// Holds [`SMUDGE_ENV_LOCK`] and puts `GIT_LFS_SKIP_SMUDGE` back as it found it.
struct SmudgeEnv {
before: Option<OsString>,
_lock: MutexGuard<'static, ()>,
}
impl Drop for SmudgeEnv {
fn drop(&mut self) {
match self.before.take() {
Some(v) => std::env::set_var("GIT_LFS_SKIP_SMUDGE", v),
None => std::env::remove_var("GIT_LFS_SKIP_SMUDGE"),
}
}
}
/// `GIT_LFS_SKIP_SMUDGE` as this test needs it, until the guard drops.
///
/// The driver gix runs during a checkout is a child of THIS process, so this
/// process's environment is what git-lfs reads; a `Command::env` would only
/// reach the stock-git clones. A lock poisoned by another test's panic is
/// taken anyway — the variable is reset here either way.
fn smudge_env(smudge: Smudge) -> SmudgeEnv {
let lock = SMUDGE_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let before = std::env::var_os("GIT_LFS_SKIP_SMUDGE");
match smudge {
Smudge::Run => std::env::remove_var("GIT_LFS_SKIP_SMUDGE"),
Smudge::Skip => std::env::set_var("GIT_LFS_SKIP_SMUDGE", "1"),
}
SmudgeEnv {
before,
_lock: lock,
}
}
/// `git lfs` is required by every test in this file. Skipping loudly beats a
/// vacuous green, and beats a red on a box that simply has no LFS.
fn require_git_lfs() -> bool {
let ok = Command::new("git")
.args(["lfs", "version"])
.output()
.is_ok_and(|o| o.status.success());
if !ok {
eprintln!("SKIP: git-lfs is not installed, so nothing here can be measured");
}
ok
}
/// A repository whose `.gitattributes` sends `*.bin` through the `lfs` filter.
///
/// Returns the worktree. The asset is `ASSET_BYTES` of one repeated byte, which
/// compresses to nothing and keeps the fixture cheap while still being 1500×
/// larger than the pointer that replaces it.
fn build_lfs_repo(root: &Path, kind: Kind) -> PathBuf {
let src = root.join("src");
std::fs::create_dir_all(&src).unwrap();
git(&src, &["init", "-q", format_flag(kind), "."]);
git(&src, &["config", "user.email", "test@gunnar.test"]);
git(&src, &["config", "user.name", "client-gaps"]);
git(&src, &["lfs", "install", "--local"]);
git(&src, &["lfs", "track", "*.bin"]);
std::fs::write(src.join("big.bin"), vec![7u8; ASSET_BYTES]).unwrap();
std::fs::write(src.join("plain.txt"), b"hello\n").unwrap();
git(&src, &["add", ".gitattributes", "big.bin", "plain.txt"]);
git(&src, &["commit", "-q", "-m", "an asset behind a filter"]);
// The fixture is only a fixture if the blob really is a pointer. A repo
// where `git lfs track` silently did nothing would make every assertion
// below pass while measuring the wrong thing.
let stored: usize = git(&src, &["cat-file", "-s", "HEAD:big.bin"])
.parse()
.expect("git cat-file -s printed a number");
assert!(
stored < 1000,
"{kind:?}: the blob is {stored} bytes, so git-lfs did not replace the asset with a \
pointer and this fixture measures nothing"
);
src
}
/// A bare-object clone with no worktree written, ready for a checkout under test.
fn clone_without_checkout(src: &Path, dest: &Path) -> String {
let out = Command::new("git")
.args(["clone", "-q", "--no-checkout"])
.arg(src)
.arg(dest)
.output()
.expect("git clone --no-checkout");
assert!(
out.status.success(),
"git clone --no-checkout: {}",
String::from_utf8_lossy(&out.stderr)
);
git(src, &["rev-parse", "HEAD"])
}
/// Run `materialize_worktree` — the writer `gunnar clone` uses over SSH.
fn gunnar_checkout(dest: &Path, sha: &str) {
let branch = if git(dest, &["symbolic-ref", "--short", "HEAD"]).is_empty() {
"main".to_string()
} else {
git(dest, &["symbolic-ref", "--short", "HEAD"])
};
nornir_git::gitio::set_head_and_checkout(dest, &branch, sha)
.unwrap_or_else(|e| panic!("materialize_worktree: {e:#}"));
}
/// A local clone through gix's own checkout (`prepare_clone` →
/// `fetch_then_checkout` → `main_worktree`, the calls
/// `nornir_git::gitio::clone_or_fetch` makes for a local URL), with
/// [`LFS_FILTER`] as this repository's in-memory config. gix reads filter
/// drivers from config only, and the repository it creates does not exist
/// until the clone starts, so the override is the one repo-scoped door; the
/// caller then writes the same keys to `.git/config` for the guard.
fn filtered_gix_clone(src: &Path, dest: &Path) -> Result<(), Box<dyn std::error::Error>> {
std::fs::create_dir_all(dest)?;
let (mut checkout, _) = gix::prepare_clone(src.to_str().ok_or("utf-8 path")?, dest)?
.with_in_memory_config_overrides(lfs_filter_overrides())
.fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?;
checkout.main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?;
Ok(())
}
fn size_of(path: &Path) -> Option<u64> {
std::fs::metadata(path).ok().map(|m| m.len())
}
/// **The judge and the gunnar writer agree: both write the asset.**
///
/// This test used to pin the defect — stock git wrote the asset and
/// `materialize_worktree` the pointer. nornir-git 0.1.11 made the writer gix's
/// own checkout, which runs the smudge driver, so the same clone with the same
/// driver now produces the same 200 000 bytes on both sides, and the guard has
/// nothing to say about it. The driver is installed on the gunnar clone itself
/// ([`configure_lfs_filter_locally`]), as `git lfs install --local` would, so
/// the verdict does not depend on what `/etc/gitconfig` holds. Both object
/// formats, both numbers asserted — "the two agree" alone would also pass if
/// both wrote the pointer.
#[test]
fn stock_git_and_the_gunnar_writer_both_write_the_asset() {
if !require_git_lfs() {
return;
}
let _env = smudge_env(Smudge::Run);
for kind in [Kind::Sha1, Kind::Sha256] {
let dir = tempfile::tempdir().unwrap();
let src = build_lfs_repo(dir.path(), kind);
// `git clone -c` writes each key into the NEW repository's config
// before the checkout, so the smudge runs on this clone and nowhere
// else. `GIT_LFS_SKIP_SMUDGE` is removed for the same reason: a shell
// that cloned the constellation with it exported (the forge's LFS
// pdfs need it) would otherwise tell git-lfs to write pointers here.
let by_git = dir.path().join("by-git");
let mut clone = Command::new("git");
clone.args(["clone", "-q"]);
for kv in lfs_filter_overrides() {
clone.arg("-c").arg(kv);
}
let out = clone
.arg(&src)
.arg(&by_git)
.env_remove("GIT_LFS_SKIP_SMUDGE")
.output()
.expect("git clone");
assert!(
out.status.success(),
"{kind:?}: git clone failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
size_of(&by_git.join("big.bin")),
Some(ASSET_BYTES as u64),
"{kind:?}: stock git did not run the smudge filter, so this box cannot judge \
anything and every other assertion here is meaningless"
);
let by_gunnar = dir.path().join("by-gunnar");
let sha = clone_without_checkout(&src, &by_gunnar);
configure_lfs_filter_locally(&by_gunnar);
gunnar_checkout(&by_gunnar, &sha);
assert_eq!(
size_of(&by_gunnar.join("big.bin")),
Some(ASSET_BYTES as u64),
"{kind:?}: materialize_worktree did not run the smudge driver stock git ran on the \
same clone — the two writers disagree again"
);
assert_eq!(
size_of(&by_gunnar.join("plain.txt")),
Some(6),
"{kind:?}: the unfiltered file must be written normally"
);
let report = gunnar_client::guard_checkout(&by_gunnar)
.expect("a checkout the filter ran on must not be refused");
assert!(
report.is_empty(),
"{kind:?}: the guard reported a path the smudge filled in: {report:?}"
);
}
}
/// The pointer the guard tests stand on: `big.bin` holds the stored blob, not
/// the asset. Asserted, not assumed — if git-lfs ignored `GIT_LFS_SKIP_SMUDGE`
/// the guard would find nothing and every refusal below would read as a
/// regression instead of a fixture that stopped being one.
fn assert_the_pointer_is_on_disk(kind: Kind, dest: &Path) {
let on_disk = size_of(&dest.join("big.bin")).expect("the checkout wrote big.bin");
assert!(
on_disk < 1000,
"{kind:?}: big.bin is {on_disk} bytes after a checkout with GIT_LFS_SKIP_SMUDGE=1, so \
the smudge did not hand the pointer back and this test measures nothing"
);
}
/// **The guard refuses, names the driver and the path, and leaves no pointer.**
///
/// `filter.lfs.required` is true on any box where `git lfs install` has run, and
/// git's own contract for it is "never write unfiltered content". gunnar keeps
/// that contract: the message is asserted for the driver, the path and the
/// recovery command, and the file is asserted **gone** — an error message beside
/// a 131-byte file that a build will open is not a refusal.
///
/// The pointer is put there the way a real client with git-lfs installed puts
/// it there: the driver on the clone, `GIT_LFS_SKIP_SMUDGE=1` in the
/// environment (see the module docs).
#[test]
fn a_required_filter_is_refused_and_the_pointer_is_not_left_behind() {
if !require_git_lfs() {
return;
}
let _env = smudge_env(Smudge::Skip);
for kind in [Kind::Sha1, Kind::Sha256] {
let dir = tempfile::tempdir().unwrap();
let src = build_lfs_repo(dir.path(), kind);
let dest = dir.path().join("clone");
let sha = clone_without_checkout(&src, &dest);
configure_lfs_filter_locally(&dest);
// The knob git reads, set where git reads it, rather than relying on
// whatever /etc/gitconfig happens to hold on the machine running this.
git(&dest, &["config", "filter.lfs.required", "true"]);
gunnar_checkout(&dest, &sha);
assert_the_pointer_is_on_disk(kind, &dest);
let err = gunnar_client::guard_checkout(&dest)
.expect_err("a required filter gunnar cannot run must be refused");
let shown = gunnar_client::chain(&err);
for expected in ["lfs", "big.bin", "restore --source=HEAD", "required"] {
assert!(
shown.contains(expected),
"{kind:?}: the refusal does not mention {expected:?}: {shown}"
);
}
assert_eq!(
size_of(&dest.join("big.bin")),
None,
"{kind:?}: the refusal left the pointer on disk, which is the wrong checkout it \
exists to prevent"
);
// gunnar's stated divergence from git: only the unfilterable paths go.
assert_eq!(
size_of(&dest.join("plain.txt")),
Some(6),
"{kind:?}: the guard removed a file that had no filter on it"
);
}
}
/// **Not required means loud, not fatal** — and `require_git_lfs` aside, this is
/// the case stock git handles *silently*, which is the one gunnar improves on.
///
/// The report is asserted non-empty and correctly labelled, and the checkout is
/// asserted intact: a warning that quietly deleted the file would be worse than
/// the defect.
#[test]
fn a_filter_that_is_not_required_warns_and_keeps_the_checkout() {
if !require_git_lfs() {
return;
}
let _env = smudge_env(Smudge::Skip);
for kind in [Kind::Sha1, Kind::Sha256] {
let dir = tempfile::tempdir().unwrap();
let src = build_lfs_repo(dir.path(), kind);
let dest = dir.path().join("clone");
let sha = clone_without_checkout(&src, &dest);
configure_lfs_filter_locally(&dest);
git(&dest, &["config", "filter.lfs.required", "false"]);
gunnar_checkout(&dest, &sha);
assert_the_pointer_is_on_disk(kind, &dest);
let report = gunnar_client::guard_checkout(&dest)
.expect("a filter that is not required must not be fatal");
assert_eq!(
report.paths.len(),
1,
"{kind:?}: expected exactly big.bin to be reported: {report:?}"
);
assert_eq!(report.paths[0].path, "big.bin");
assert_eq!(report.paths[0].driver, "lfs");
assert!(
!report.paths[0].required,
"{kind:?}: filter.lfs.required=false was not read from the repository config"
);
assert!(
size_of(&dest.join("big.bin")).is_some(),
"{kind:?}: a warning must not delete anything"
);
}
}
/// **A checkout the filter really did run on is clean**, which is what stops the
/// guard from refusing correct work.
///
/// gix's `main_worktree` — the writer a local-URL clone goes through — produces
/// the 200 000 bytes. The `.gitattributes` line is identical in both cases, so a
/// guard that read the declaration instead of the bytes would refuse this
/// checkout. That it does not is asserted here and nowhere else.
///
/// The clone is [`filtered_gix_clone`]: the same three gix calls
/// `nornir_git::gitio::clone_or_fetch` makes for a local URL, with the filter
/// handed to THIS repository instead of read off the box's global config.
#[test]
fn a_checkout_whose_filter_did_run_is_not_reported() {
if !require_git_lfs() {
return;
}
let _env = smudge_env(Smudge::Run);
for kind in [Kind::Sha1, Kind::Sha256] {
let dir = tempfile::tempdir().unwrap();
let src = build_lfs_repo(dir.path(), kind);
let dest = dir.path().join("clone");
filtered_gix_clone(&src, &dest).unwrap_or_else(|e| panic!("{kind:?}: local clone: {e:#}"));
configure_lfs_filter_locally(&dest);
assert_eq!(
size_of(&dest.join("big.bin")),
Some(ASSET_BYTES as u64),
"{kind:?}: this path is supposed to be the FILTERED one; if it stopped filtering \
the test below proves nothing"
);
let repo = gix::open(&dest).unwrap();
let report = gunnar_client::unfiltered_paths(&repo, &dest).unwrap();
assert!(
report.is_empty(),
"{kind:?}: a correctly filtered checkout was reported as unfiltered: {report:?}"
);
gunnar_client::guard_checkout(&dest)
.expect("a correctly filtered checkout must not be refused");
}
}
/// **A repository with no `.gitattributes` reports nothing** — the anti-vacuity
/// end of the same guard. Without this, a `unfiltered_paths` that always
/// returned empty would pass the test above.
#[test]
fn a_repository_without_attributes_reports_nothing() {
for kind in [Kind::Sha1, Kind::Sha256] {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("plain");
common::init_worktree(&src, kind);
common::commit_file(&src, "a.txt", "one\n", "first");
let repo = gix::open(&src).unwrap();
let report = gunnar_client::unfiltered_paths(&repo, &src).unwrap();
assert!(
report.is_empty(),
"{kind:?}: a repository with no filters at all reported {report:?}"
);
}
}
/// **The other clone path is guarded too**, and is seen to refuse over a real
/// `git upload-pack`.
///
/// `gunnar clone --depth N` does not go through `clone_or_fetch`; it goes
/// through `gunnar_client::clone_with_io`, which assembles the clone itself and
/// materialises its own worktree. A guard on one of the two paths is a guard a
/// user walks straight past, so this drives the second one end to end over
/// pipes.
///
/// The `required` verdict is read from this box's effective git config, exactly
/// as `gunnar` reads it, and both outcomes are asserted — a guard that refused
/// everything would pass one of the branches on its own. Skipped loudly rather
/// than passed vacuously when there is no `filter.lfs.required` to read.
///
/// `clone_with_io` creates the repository itself, so there is no clone-local
/// config to install the driver into before the checkout: the box's driver
/// (where `git lfs install` put one) is the one that runs, under
/// `GIT_LFS_SKIP_SMUDGE=1`, and hands back the stored pointer this test is
/// about. On a box with no driver at all the writer leaves the same pointer.
#[test]
fn the_shallow_clone_path_is_guarded_over_a_real_upload_pack() {
if !require_git_lfs() {
return;
}
let _env = smudge_env(Smudge::Skip);
// Read from OUTSIDE any repository: the clone below is a fresh repository,
// so what its guard sees is the system and global scopes only. Reading from
// the test's own working directory would also read the checkout's
// `.git/config` — where `git lfs install --local` may have put the driver
// (it has on dwarf) — and assert a verdict the clone never sees.
let outside = tempfile::tempdir().unwrap();
let required = Command::new("git")
.args(["config", "--get", "filter.lfs.required"])
.current_dir(outside.path())
.env_remove("GIT_DIR")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true");
let Some(required) = required else {
eprintln!(
"SKIP: filter.lfs.required is unset in this box's system and global git config \
(what a fresh clone sees), so there is no verdict to assert"
);
return;
};
for kind in [Kind::Sha1, Kind::Sha256] {
let dir = tempfile::tempdir().unwrap();
let src = build_lfs_repo(dir.path(), kind);
let url = src.to_string_lossy().to_string();
let dest = dir.path().join("shallow-clone");
let identity = {
let (stdout, stdin, mut proc) = common::GitChild::upload_pack(&src).into_halves();
let id = gunnar_client::fetch::remote_identity_with_io(stdout, stdin, &url)
.expect("the remote answers what it is");
let _ = proc.wait();
id
};
let (stdout, stdin, mut proc) = common::GitChild::upload_pack(&src).into_halves();
let outcome = gunnar_client::clone_with_io(
&identity,
stdout,
stdin,
&url,
&url,
&dest,
&gunnar_client::FetchOptions::new(),
);
let _ = proc.wait();
if required {
let err = outcome.expect_err(
"the second clone path wrote a required filter's raw blob and returned Ok",
);
let shown = gunnar_client::chain(&err);
assert!(
shown.contains("lfs") && shown.contains("big.bin"),
"{kind:?}: the shallow clone path refused for some other reason: {shown}"
);
assert!(
!dest.join("big.bin").exists(),
"{kind:?}: the shallow clone path left the pointer on disk"
);
} else {
outcome.unwrap_or_else(|e| {
panic!(
"{kind:?}: not-required must not be fatal: {}",
gunnar_client::chain(&e)
)
});
assert!(
dest.join("big.bin").exists(),
"{kind:?}: a warning must not delete anything"
);
assert_the_pointer_is_on_disk(kind, &dest);
}
}
}