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

nordisk/edda

crates/storage-git-gix/tests/a_submodule_does_not_break_serving.rs

//! **A repository with a submodule can be served.**
//!
//! A gitlink — tree mode `160000` — names a commit in *another* repository. It
//! is not a missing object and it never becomes one: `git fsck --full --strict`
//! is clean, `git rev-list --objects` does not list it, and `git` does not
//! follow it when it computes a pack's object set.
//!
//! `graph::closure` followed it anyway, and the walk records an id in the
//! selection *before* it tries to read the object, so the absent commit landed
//! in the answer. `emit_pack` was then asked for an object the store does not
//! hold and refused, which reaches a client as the storage layer's generic
//! *"the repository is unavailable; try again"*.
//!
//! MEASURED on oden, 2026-08-11, before the fix: a mirror of `nornir.git`
//! (12 370 objects, five `160000` entries) pushed into a gunnar server on the
//! gix engine and then failed **every** clone and fetch, deterministically, with
//!
//! ```text
//! err=protocol error: 11528b4ccb3775bb06d36064a2a81e35edee5863 was selected
//!     for emission and this store does not hold it
//! ```
//!
//! `11528b4ccb…` is `160000 commit … agent-ac279a0226c689894`. Both the source
//! and the served store were `fsck --full --strict` clean throughout.
//!
//! Real `git` builds the fixture and real `git` checks the result, and this test
//! **fails** rather than skips when `git` is absent — see `round_trip.rs`'s
//! module doc for why (`S-026`).

use std::path::Path;
use std::process::Command;

use git_storage_trait::{GitOps, GitServe, RefUpdate};
use storage_git_gix::GixStore;

/// Run `git` in `dir`, failing loudly. Never skips.
fn git(dir: &Path, args: &[&str]) -> String {
    let out = Command::new("git")
        .current_dir(dir)
        .args(args)
        .output()
        .unwrap_or_else(|e| panic!("`git {}` could not be run: {e}", args.join(" ")));
    assert!(
        out.status.success(),
        "`git {}` failed: {}",
        args.join(" "),
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8_lossy(&out.stdout).trim().to_string()
}

/// A repository whose tree carries a gitlink to a commit that is **not** in it,
/// plus the pack holding everything that genuinely is.
///
/// The submodule commit is built in a throwaway repository and then referenced
/// by id, which is exactly the shape a real submodule has: a `160000` entry
/// whose target lives somewhere else entirely.
fn fixture_with_gitlink(root: &Path) -> (Vec<u8>, String, String) {
    // The "other" repository. Only its commit id survives.
    let other = root.join("other");
    std::fs::create_dir_all(&other).unwrap();
    git(&other, &["init", "-q", "--initial-branch=main"]);
    git(&other, &["config", "user.email", "o@o"]);
    git(&other, &["config", "user.name", "o"]);
    std::fs::write(other.join("x"), b"other").unwrap();
    git(&other, &["add", "x"]);
    git(&other, &["commit", "-qm", "other"]);
    let sub = git(&other, &["rev-parse", "HEAD"]);

    let src = root.join("src");
    std::fs::create_dir_all(&src).unwrap();
    git(&src, &["init", "-q", "--initial-branch=main"]);
    git(&src, &["config", "user.email", "s@s"]);
    git(&src, &["config", "user.name", "s"]);
    std::fs::write(src.join("f"), b"hello").unwrap();
    git(&src, &["add", "f"]);
    // `update-index --cacheinfo` writes the gitlink without needing the
    // submodule to be present on disk — which is the point: it is not here.
    git(
        &src,
        &[
            "update-index",
            "--add",
            "--cacheinfo",
            &format!("160000,{sub},asub"),
        ],
    );
    git(&src, &["commit", "-qm", "with a submodule"]);
    let head = git(&src, &["rev-parse", "HEAD"]);

    // The source is consistent by git's own reckoning, gitlink and all.
    git(&src, &["fsck", "--full", "--strict"]);

    let pack = {
        let out = Command::new("git")
            .current_dir(&src)
            .args(["pack-objects", "--revs", "--stdout", "-q"])
            .stdin(std::process::Stdio::piped())
            .stdout(std::process::Stdio::piped())
            .spawn()
            .expect("git pack-objects");
        {
            use std::io::Write as _;
            let mut stdin = out.stdin.as_ref().expect("stdin");
            writeln!(stdin, "{head}").expect("write rev");
        }
        let out = out.wait_with_output().expect("pack-objects");
        assert!(out.status.success(), "git pack-objects failed");
        out.stdout
    };
    (pack, head, sub)
}

#[test]
fn a_gitlink_is_not_selected_for_emission() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let (pack, head, sub) = fixture_with_gitlink(tmp.path());

    let git_dir = tmp.path().join("served.git");
    let store = GixStore::open_or_init(&git_dir, gix_hash::Kind::Sha1).expect("open the store");
    store.put_pack(&pack).expect("store the pushed pack");
    store
        .put_refs(&[RefUpdate::set("refs/heads/main", head.clone())])
        .expect("set the ref");

    let head_id = gix_hash::ObjectId::from_hex(head.as_bytes()).expect("head oid");
    let sub_id = gix_hash::ObjectId::from_hex(sub.as_bytes()).expect("sub oid");

    // The premise: the submodule's commit really is absent. Without this the
    // test could pass on a store that happened to contain it, proving nothing.
    assert!(
        !store.has(sub_id.as_slice()).expect("has()"),
        "the submodule commit is IN this store, so this fixture does not pose \
         the question the test is about"
    );

    let selection = store
        .select(&[head_id.as_slice()], &[])
        .expect("select must not fail on a repository with a submodule")
        .expect("select answered None");

    assert!(
        !selection
            .objects
            .iter()
            .any(|o| o.as_slice() == sub_id.as_slice()),
        "the gitlink's commit {sub} was selected for emission. It lives in another \
         repository and this store will never hold it, so emit_pack refuses and every \
         clone of this repository dies as \"the repository is unavailable\"."
    );

    // And the ordinary contents are still all there — a walk that fixed the
    // gitlink by stopping early would pass the assertion above and be worse.
    assert!(
        selection
            .objects
            .iter()
            .any(|o| o.as_slice() == head_id.as_slice()),
        "the commit itself is missing from the selection, so the walk is not walking"
    );
    assert!(
        selection.objects.len() >= 3,
        "a commit, its tree and one blob is three objects; the selection has {}",
        selection.objects.len()
    );
}