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

nordisk/gunnar

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