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