crates/gunnar-client/tests/real_git_shallow.rs
//! Shallow, against the real `git upload-pack` binary — **and against what
//! stock git's own client produced for the same request**.
//!
//! # The gap this closes
//!
//! `gunnar-wire` has served all five shallow wire arguments since it first
//! advertised the `shallow` capability. The client had none of them:
//! `gunnar clone --depth 1` was an unknown-argument error. gunnar could serve a
//! shallow clone it could not make, and `--depth 1` is what `actions/checkout`
//! passes by default, so that hole is also the CI case.
//!
//! # Why stock git is the judge, twice over
//!
//! Every arm here runs the same request through **git 2.53.0's own client**
//! against the same `git upload-pack`, and compares two things that a working
//! clone does not reveal on its own:
//!
//! * the **commit count** — how much history landed;
//! * the contents of **`.git/shallow`** — where the boundary ended up.
//!
//! A shallow fetch whose `deepen` line never reached the wire still succeeds,
//! still writes every ref, and still passes `git fsck`. It differs from a working
//! one in exactly those two places, which is why nothing here asserts on a
//! `Result` being `Ok`.
//!
//! # Both protocol versions, both hashes
//!
//! Every arm runs at v2 **and** at v0, and on sha1 **and** sha256. The v0/v2
//! split produced four separate wrong answers in gunnar in a single day, each one
//! invisible to a v2-only test, because the two versions carry the same arguments
//! in different frames behind differently-named capabilities. `is_really_v0`
//! below reads the client's own bytes to prove the v0 arms are v0 rather than
//! trusting the flag that asked for it.
mod common;
use std::io::Write;
use std::path::{Path, PathBuf};
use common::*;
use gix::hash::Kind;
use gunnar_client::fetch::{fetch_with_io_opts, open_or_init_bare, FetchOptions};
use gunnar_client::{DepthArgs, Shallow};
/// Fetch every branch into `refs/heads/*` of a bare repository, which is what
/// `git clone --bare` produces and therefore what the reference clone can be
/// compared against ref for ref.
const BARE_REFS: &[&str] = &["+refs/heads/*:refs/heads/*"];
/// Both protocol versions. `true` is v2.
const BOTH_PROTOCOLS: [bool; 2] = [true, false];
fn label(kind: Kind, v2: bool) -> String {
format!(
"{:?}/{}",
kind,
if v2 { "protocol-v2" } else { "protocol-v0" }
)
}
/// A source repository: `c1 → c2 → c3` on `main`, plus `side` off `c1`, with
/// commit times ten seconds apart so `deepen-since` has something to cut on.
struct Scene {
dir: tempfile::TempDir,
kind: Kind,
/// `c1`, `c2`, `c3` on `main`, oldest first.
main: Vec<String>,
/// The tip of `side`, a child of `c1`.
side: String,
}
/// The commit times used, so a cutoff in a test reads against something.
const T1: i64 = 1_700_001_000;
const T2: i64 = 1_700_002_000;
const T3: i64 = 1_700_003_000;
impl Scene {
fn new(kind: Kind) -> Self {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
init_worktree(&src, kind);
let c1 = commit_file_at(&src, "a.txt", "one\n", "c1", T1);
let c2 = commit_file_at(&src, "b.txt", "two\n", "c2", T2);
let c3 = commit_file_at(&src, "c.txt", "three\n", "c3", T3);
// `side` branches off `c1`, so `--shallow-exclude=side` has an ancestry
// to remove that is not all of `main`.
git(&src, &["checkout", "-q", "-b", "side", &c1]);
let side = commit_file_at(&src, "s.txt", "side\n", "s1", T2);
git(&src, &["checkout", "-q", "main"]);
Scene {
dir,
kind,
main: vec![c1, c2, c3],
side,
}
}
fn src(&self) -> PathBuf {
self.dir.path().join("src")
}
/// `file://` so git uses the smart transport (`--depth` needs it) rather
/// than a local hardlink clone.
fn file_url(&self) -> String {
format!("file://{}", self.src().display())
}
/// Clone with **stock git**, at the given protocol version, and return the
/// bare clone's path. The reference every gunnar arm is judged against.
///
/// `--no-single-branch` is not decoration. Every shallow option in git
/// (`--depth`, `--shallow-since`, `--shallow-exclude`) **implies
/// `--single-branch`**, so the default reference clone fetches only `main`
/// while gunnar's `+refs/heads/*:refs/heads/*` fetches `main` and `side`.
/// Comparing those two compares two different requests: MEASURED here on
/// 2026-08-05, git wrote one cut point and gunnar wrote two, and the
/// difference was the refspec, not the depth.
fn git_clone(&self, name: &str, v2: bool, extra: &[&str]) -> PathBuf {
let dest = self.dir.path().join(name);
let version = if v2 {
"protocol.version=2"
} else {
"protocol.version=0"
};
let url = self.file_url();
let mut args: Vec<&str> =
vec!["-c", version, "clone", "-q", "--bare", "--no-single-branch"];
args.extend_from_slice(extra);
let dest_s = dest.to_string_lossy().to_string();
args.push(&url);
args.push(&dest_s);
git(self.dir.path(), &args);
dest
}
/// Fetch with **gunnar** into a fresh bare repository, returning it and the
/// bytes the client wrote.
fn gunnar_fetch(
&self,
name: &str,
v2: bool,
options: &FetchOptions,
) -> (PathBuf, gunnar_client::FetchOutcome, String) {
let dest = self.dir.path().join(name);
let repo = open_or_init_bare(&dest, self.kind).unwrap();
self.gunnar_fetch_into(&repo, &dest, v2, options)
}
/// The same fetch, into a repository that already exists — how a deepening
/// or an unshallowing is done.
fn gunnar_fetch_into(
&self,
repo: &gix::Repository,
dest: &Path,
v2: bool,
options: &FetchOptions,
) -> (PathBuf, gunnar_client::FetchOutcome, String) {
let child = if v2 {
GitChild::upload_pack(&self.src())
} else {
GitChild::upload_pack_v0(&self.src())
};
let (stdout, stdin, mut proc) = child.into_halves();
let (tee, seen) = Tee::new(stdin);
let url = self.src().to_string_lossy().to_string();
let outcome = fetch_with_io_opts(repo, stdout, tee, &url, &url, BARE_REFS, options);
let _ = proc.wait();
let wire = String::from_utf8_lossy(&seen.lock().unwrap()).into_owned();
// `GUNNAR_DUMP_WIRE=1 cargo test … -- --nocapture` prints exactly what
// the client wrote. Kept because every defect in this file was found by
// reading it, and the v0 one is invisible any other way.
if std::env::var_os("GUNNAR_DUMP_WIRE").is_some() {
eprintln!("--- {} ---\n{}", label(self.kind, v2), readable(&wire));
}
match outcome {
Ok(outcome) => (dest.to_path_buf(), outcome, wire),
Err(e) => panic!(
"gunnar's shallow fetch failed at {}: {e}\nwhat the client wrote:\n{}",
label(self.kind, v2),
readable(&wire)
),
}
}
}
/// The client's writes, split back into pkt-lines.
///
/// # Why a substring search is not good enough here
///
/// v0 bakes the negotiated capabilities onto the first `want` line, and three of
/// them are literally named `deepen-since`, `deepen-not` and `deepen-relative`.
/// So `wire.contains("deepen")` is **true of every v0 fetch ever sent**,
/// including one that asked for no shallowness at all — MEASURED here on
/// 2026-08-05, where it turned the file's own negative control green for the
/// wrong reason. An argument is a whole pkt-line; that is what is compared.
///
/// Flush (`0000`), delim (`0001`) and response-end (`0002`) carry no payload and
/// are skipped.
fn pkt_lines(wire: &str) -> Vec<String> {
let bytes = wire.as_bytes();
let mut out = Vec::new();
let mut at = 0usize;
while at + 4 <= bytes.len() {
let Ok(header) = std::str::from_utf8(&bytes[at..at + 4]) else {
break;
};
let Ok(len) = usize::from_str_radix(header, 16) else {
break;
};
if len < 4 {
at += 4;
continue;
}
if at + len > bytes.len() {
break;
}
out.push(
String::from_utf8_lossy(&bytes[at + 4..at + len])
.trim_end_matches('\n')
.to_string(),
);
at += len;
}
out
}
/// Did the client send exactly this argument as its own pkt-line?
fn sent(wire: &str, argument: &str) -> bool {
pkt_lines(wire).iter().any(|line| line == argument)
}
/// Did the client send any argument beginning with `prefix`? Used for the
/// negative controls, where the point is that no `deepen…` **argument** went out
/// even though the v0 capability list names three of them.
fn sent_any(wire: &str, prefix: &str) -> bool {
pkt_lines(wire)
.iter()
.any(|line| line.starts_with(prefix) && !line.starts_with("want "))
}
/// The pkt-lines, one per output line, for a failure message.
fn readable(wire: &str) -> String {
pkt_lines(wire)
.iter()
.map(|l| format!(" | {l}"))
.collect::<Vec<_>>()
.join("\n")
}
/// Does this byte stream prove the conversation was protocol **v0**?
///
/// Read off the client's own writes, not off the flag that asked for v0. v0 puts
/// the capabilities on the first `want` line and has no `command=` verb at all;
/// v2 sends `command=fetch` and never puts capabilities on a want. Either
/// property alone can be satisfied by accident — a v2 stream contains the word
/// `want` too — so both are required.
fn is_really_v0(wire: &str) -> bool {
let lines = pkt_lines(wire);
!lines.iter().any(|l| l.starts_with("command="))
&& lines
.iter()
.any(|l| l.starts_with("want ") && l.contains("multi_ack_detailed"))
}
/// And the converse, so the v2 arms cannot silently be v0 either.
fn is_really_v2(wire: &str) -> bool {
pkt_lines(wire).iter().any(|l| l == "command=fetch")
}
fn assert_version(wire: &str, v2: bool, kind: Kind) {
if v2 {
assert!(
is_really_v2(wire),
"{}: asked for protocol v2 and the client never sent `command=fetch`. \
What it wrote:\n{}",
label(kind, v2),
readable(wire)
);
} else {
assert!(
is_really_v0(wire),
"{}: asked for protocol v0 and the client's bytes do not look like v0 \
(expected capabilities on the want line and no `command=`). What it wrote:\n{}",
label(kind, v2),
readable(wire)
);
}
}
/// The capabilities the client claimed on its v0 `want` line, or `None` on v2
/// (where want lines carry none).
fn want_line_caps(wire: &str) -> Option<Vec<String>> {
pkt_lines(wire)
.into_iter()
.find(|l| l.starts_with("want ") && l.split(' ').count() > 2)
.map(|l| l.split(' ').skip(2).map(str::to_string).collect())
}
/// `deepen-relative` lives in a different place in each protocol version, and
/// claiming it in the wrong one is a wrong answer in both directions.
///
/// * **v0**: it is a *capability* on the want line, and there is no such
/// argument line — stock git dies with *protocol error, expected to get object
/// ID, not 'deepen-relative'*.
/// * **v2**: it is an *argument*, and want lines carry no capabilities at all.
fn assert_relative_deepen(wire: &str, v2: bool, at: &str) {
assert!(
sent(wire, "deepen 1"),
"{at}: --deepen must send `deepen 1`:\n{}",
readable(wire)
);
if v2 {
assert!(
sent(wire, "deepen-relative"),
"{at}: on v2 `deepen-relative` is an argument line:\n{}",
readable(wire)
);
} else {
assert!(
!sent(wire, "deepen-relative"),
"{at}: v0 has no `deepen-relative` argument line; stock git dies on \
it:\n{}",
readable(wire)
);
assert!(
want_line_caps(wire)
.unwrap_or_default()
.iter()
.any(|c| c == "deepen-relative"),
"{at}: on v0 a relative deepening is requested by the want-line \
capability, and it is missing:\n{}",
readable(wire)
);
}
}
// ── --depth ─────────────────────────────────────────────────────────────────
/// `--depth 1`: the case `actions/checkout` runs by default.
#[test]
fn depth_one_lands_what_stock_git_lands() {
for kind in BOTH_HASH_KINDS {
for v2 in BOTH_PROTOCOLS {
let scene = Scene::new(kind);
let reference = scene.git_clone("ref.git", v2, &["--depth=1"]);
let (dest, outcome, wire) = scene.gunnar_fetch(
"gunnar.git",
v2,
&FetchOptions::new().with_shallow(Shallow::DepthAtRemote(
std::num::NonZeroU32::new(1).unwrap(),
)),
);
let at = label(kind, v2);
assert_version(&wire, v2, kind);
// The argument reached the wire. Without this the rest of the test
// can pass on a client that sent an ordinary fetch and happened to
// be pointed at a repository small enough not to notice.
assert!(
sent(&wire, "deepen 1"),
"{at}: the client never sent `deepen 1`. What it wrote:\n{}",
readable(&wire)
);
// The v0 half of the fix, and the reason this test sees a shallow
// clone at all. gix claims `deepen-relative` on every v0 want line,
// and `upload-pack.c` then counts the depth from a boundary this
// fresh clone does not have — which selects nothing and sends the
// WHOLE history with no `shallow` line. MEASURED 2026-08-05:
// 1170 bytes and three commits, against git's own 292 and one.
if !v2 {
assert!(
!want_line_caps(&wire)
.unwrap_or_default()
.iter()
.any(|c| c == "deepen-relative"),
"{at}: a NON-relative --depth must not claim `deepen-relative`, \
or stock git answers with the whole history:\n{}",
readable(&wire)
);
} else {
assert!(
want_line_caps(&wire).is_none(),
"{at}: v2 want lines carry no capabilities:\n{}",
readable(&wire)
);
}
assert_eq!(
commit_count(&dest, "refs/heads/main"),
commit_count(&reference, "refs/heads/main"),
"{at}: gunnar landed a different amount of history than git did"
);
assert_eq!(
commit_count(&dest, "refs/heads/main"),
1,
"{at}: --depth 1 must land exactly the tip"
);
assert_eq!(
shallow_file(&dest),
shallow_file(&reference),
"{at}: the boundary in .git/shallow differs from git's"
);
// Both tips, because both branches were asked for: `main`'s c3 and
// `side`'s only commit. Named rather than counted, so a boundary at
// the wrong commits cannot pass.
let mut expected = vec![scene.main[2].clone(), scene.side.clone()];
expected.sort();
assert_eq!(
shallow_file(&dest),
expected,
"{at}: the cut points must be the two tips themselves"
);
assert_eq!(
outcome
.shallow_boundary
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
shallow_file(&dest),
"{at}: FetchOutcome::shallow_boundary disagrees with the file on disk"
);
assert!(
git_try(&dest, &["fsck", "--strict", "--no-progress"]).is_ok(),
"{at}: fsck rejected the shallow clone"
);
}
}
}
/// `--depth 2` on the same history, so a passing `--depth 1` cannot be a client
/// that sends a constant.
#[test]
fn depth_two_cuts_one_commit_deeper() {
for kind in BOTH_HASH_KINDS {
for v2 in BOTH_PROTOCOLS {
let scene = Scene::new(kind);
let reference = scene.git_clone("ref.git", v2, &["--depth=2"]);
let (dest, _, wire) = scene.gunnar_fetch(
"gunnar.git",
v2,
&FetchOptions::new().with_shallow(Shallow::DepthAtRemote(
std::num::NonZeroU32::new(2).unwrap(),
)),
);
let at = label(kind, v2);
assert_version(&wire, v2, kind);
assert!(
sent(&wire, "deepen 2"),
"{at}: the client never sent `deepen 2`:\n{}",
readable(&wire)
);
assert_eq!(commit_count(&dest, "refs/heads/main"), 2, "{at}");
assert_eq!(
commit_count(&dest, "refs/heads/main"),
commit_count(&reference, "refs/heads/main"),
"{at}"
);
assert_eq!(shallow_file(&dest), shallow_file(&reference), "{at}");
// c2 on `main`, and c1 reached at depth 2 down `side`. c1 IS a root,
// and stock git marks it shallow anyway — `get_shallow_commits`
// cuts at the depth without asking whether the commit has parents.
// Asserted because it is surprising, and because gunnar's own
// upload-pack deliberately does the opposite (`gunnar_wire::shallow`
// filters roots out of `newly_shallow`), which is a server-side
// divergence from git this client test is the first thing to see.
let mut expected = vec![scene.main[1].clone(), scene.main[0].clone()];
expected.sort();
assert_eq!(
shallow_file(&dest),
expected,
"{at}: the cut points must be c2 (on main) and c1 (at depth 2 down side)"
);
}
}
}
// ── --unshallow and --deepen ────────────────────────────────────────────────
/// `--unshallow` on a depth-1 clone: the whole history arrives and the boundary
/// file goes away entirely.
#[test]
fn unshallow_completes_the_history_and_removes_the_boundary() {
for kind in BOTH_HASH_KINDS {
for v2 in BOTH_PROTOCOLS {
let scene = Scene::new(kind);
let at = label(kind, v2);
// git's own reference: clone --depth=1 then fetch --unshallow.
let reference = scene.git_clone("ref.git", v2, &["--depth=1"]);
assert_eq!(
shallow_file(&reference).len(),
2,
"{at}: fixture — a depth-1 clone of two branches has two cut points"
);
git(
&reference,
&[
"fetch",
"--unshallow",
"origin",
"+refs/heads/*:refs/heads/*",
],
);
let depth1 = FetchOptions::new().with_shallow(Shallow::DepthAtRemote(
std::num::NonZeroU32::new(1).unwrap(),
));
let dest = scene.dir.path().join("gunnar.git");
let repo = open_or_init_bare(&dest, kind).unwrap();
let (_, first, _) = scene.gunnar_fetch_into(&repo, &dest, v2, &depth1);
assert_eq!(
first.shallow_boundary.len(),
2,
"{at}: the depth-1 half of the fixture did not go shallow"
);
// `--unshallow` is `deepen 2147483647` on the wire, not an argument
// of its own.
let (_, outcome, wire) = scene.gunnar_fetch_into(
&repo,
&dest,
v2,
&FetchOptions::new().with_shallow(Shallow::undo()),
);
assert_version(&wire, v2, kind);
assert!(
sent(&wire, "deepen 2147483647"),
"{at}: --unshallow must go out as deepen 2147483647:\n{}",
readable(&wire)
);
assert!(
sent(&wire, &format!("shallow {}", scene.main[2])),
"{at}: the client must declare its existing boundary, or the server \
cannot know what to unshallow:\n{}",
readable(&wire)
);
assert_eq!(
commit_count(&dest, "refs/heads/main"),
3,
"{at}: --unshallow must land every commit"
);
assert_eq!(
commit_count(&dest, "refs/heads/main"),
commit_count(&reference, "refs/heads/main"),
"{at}"
);
assert!(
shallow_file(&dest).is_empty(),
"{at}: .git/shallow still holds {:?} after --unshallow",
shallow_file(&dest)
);
assert_eq!(
shallow_file(&dest),
shallow_file(&reference),
"{at}: git removed the boundary and gunnar did not, or the reverse"
);
assert!(
outcome.shallow_boundary.is_empty(),
"{at}: the outcome still reports a boundary"
);
assert!(
git_try(&dest, &["fsck", "--strict", "--no-progress"]).is_ok(),
"{at}: fsck rejected the unshallowed clone"
);
}
}
}
/// `--deepen 1` on a depth-1 clone. It counts from **this** repository's
/// boundary, which is what `deepen-relative` means, and is the argument
/// `--depth` must not be confused with: `--depth 1` on a depth-1 clone changes
/// nothing at all.
#[test]
fn deepen_is_relative_to_our_own_boundary_and_depth_is_not() {
for kind in BOTH_HASH_KINDS {
for v2 in BOTH_PROTOCOLS {
let scene = Scene::new(kind);
let at = label(kind, v2);
let depth1 = FetchOptions::new().with_shallow(Shallow::DepthAtRemote(
std::num::NonZeroU32::new(1).unwrap(),
));
// git's reference for `--deepen=1`.
let reference = scene.git_clone("ref.git", v2, &["--depth=1"]);
git(
&reference,
&[
"fetch",
"--deepen=1",
"origin",
"+refs/heads/*:refs/heads/*",
],
);
let dest = scene.dir.path().join("gunnar.git");
let repo = open_or_init_bare(&dest, kind).unwrap();
scene.gunnar_fetch_into(&repo, &dest, v2, &depth1);
let (_, deepened, wire) = scene.gunnar_fetch_into(
&repo,
&dest,
v2,
&FetchOptions::new().with_shallow(Shallow::Deepen(1)),
);
assert_version(&wire, v2, kind);
assert_relative_deepen(&wire, v2, &at);
assert_eq!(
commit_count(&dest, "refs/heads/main"),
2,
"{at}: one more commit than the depth-1 clone had"
);
assert_eq!(
commit_count(&dest, "refs/heads/main"),
commit_count(&reference, "refs/heads/main"),
"{at}"
);
assert_eq!(shallow_file(&dest), shallow_file(&reference), "{at}");
// Still shallow, one commit further back on each branch, and still
// agreeing with git — which the comparison above already asserted.
assert_eq!(
deepened
.shallow_boundary
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
shallow_file(&reference),
"{at}: the deepened boundary is not the one git arrived at"
);
// The negative control that gives the assertion above its meaning.
// `--depth` counts at the REMOTE, so running it on the deepened
// clone moves the boundary back to one commit — it *shrinks*, where
// `--deepen` only ever grows. If the two were the same argument this
// step would leave two commits.
//
// git's own answer is fetched first, so the number is git's and not
// a guess: MEASURED 2.53.0, `fetch --depth=1` on a two-commit shallow
// clone leaves one.
git(
&reference,
&["fetch", "--depth=1", "origin", "+refs/heads/*:refs/heads/*"],
);
let (_, again, _) = scene.gunnar_fetch_into(&repo, &dest, v2, &depth1);
assert_eq!(
commit_count(&dest, "refs/heads/main"),
commit_count(&reference, "refs/heads/main"),
"{at}: --depth on an already-deepened clone did not do what git does"
);
assert_eq!(
commit_count(&dest, "refs/heads/main"),
1,
"{at}: --depth counts at the remote, so it shrinks the boundary back"
);
assert_eq!(
again
.shallow_boundary
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
shallow_file(&reference),
"{at}"
);
}
}
}
// ── --shallow-since ─────────────────────────────────────────────────────────
/// `--shallow-since`, cut between c1 and c2.
#[test]
fn shallow_since_cuts_where_git_cuts() {
for kind in BOTH_HASH_KINDS {
for v2 in BOTH_PROTOCOLS {
let scene = Scene::new(kind);
let at = label(kind, v2);
// Between c1 and c2, so c1 is excluded and c2/c3 are kept.
let cutoff = T1 + 500;
let since = format!("--shallow-since=@{cutoff}");
let reference = scene.git_clone("ref.git", v2, &[&since]);
let shallow = DepthArgs {
since: Some(format!("@{cutoff}")),
..DepthArgs::default()
}
.to_shallow()
.expect("@<unix seconds> is a date git understands");
let (dest, _, wire) =
scene.gunnar_fetch("gunnar.git", v2, &FetchOptions::new().with_shallow(shallow));
assert_version(&wire, v2, kind);
assert!(
sent(&wire, &format!("deepen-since {cutoff}")),
"{at}: the client never sent `deepen-since {cutoff}`:\n{}",
readable(&wire)
);
assert_eq!(
commit_count(&dest, "refs/heads/main"),
commit_count(&reference, "refs/heads/main"),
"{at}: gunnar kept a different number of commits than git"
);
assert_eq!(
commit_count(&dest, "refs/heads/main"),
2,
"{at}: c2 and c3 are newer than the cutoff, c1 is not"
);
assert_eq!(shallow_file(&dest), shallow_file(&reference), "{at}");
// c2, because its parent c1 predates the cutoff — and s1 on `side`
// for the same reason, since c1 is its parent too.
let mut expected = vec![scene.main[1].clone(), scene.side.clone()];
expected.sort();
assert_eq!(
shallow_file(&dest),
expected,
"{at}: the boundary is every commit whose parent is older than the cutoff"
);
}
}
}
// ── --shallow-exclude ───────────────────────────────────────────────────────
/// `--shallow-exclude=side` — with the **short** ref name, which is the ordinary
/// spelling and the one that broke the server side of this on 2026-08-05:
/// `FullName::new("side")` failed, the exclusion was dropped, and the client got
/// the whole history with an empty `.git/shallow` and no error on either end.
///
/// The client half of the same trap is [`gunnar_client::DepthArgs`] parsing the
/// name: a `FullName` there would refuse `side` outright, which at least fails
/// loudly, or worse silently drop it.
#[test]
fn shallow_exclude_takes_a_short_ref_name_and_cuts_where_git_cuts() {
for kind in BOTH_HASH_KINDS {
for v2 in BOTH_PROTOCOLS {
let scene = Scene::new(kind);
let at = label(kind, v2);
let reference = scene.git_clone("ref.git", v2, &["--shallow-exclude=side"]);
let shallow = DepthArgs {
exclude: vec!["side".into()],
..DepthArgs::default()
}
.to_shallow()
.expect("a short ref name is what --shallow-exclude takes");
let (dest, _, wire) =
scene.gunnar_fetch("gunnar.git", v2, &FetchOptions::new().with_shallow(shallow));
assert_version(&wire, v2, kind);
assert!(
sent(&wire, "deepen-not side"),
"{at}: the client never sent `deepen-not side`, so nothing was \
excluded:\n{}",
readable(&wire)
);
assert_eq!(
commit_count(&dest, "refs/heads/main"),
commit_count(&reference, "refs/heads/main"),
"{at}: gunnar and git disagree on how much --shallow-exclude=side removes"
);
assert_eq!(
commit_count(&dest, "refs/heads/main"),
2,
"{at}: c1 is reachable from side, so main keeps c2 and c3"
);
assert_eq!(shallow_file(&dest), shallow_file(&reference), "{at}");
assert!(
!shallow_file(&dest).is_empty(),
"{at}: an empty .git/shallow is exactly what the dropped-exclusion \
bug produced"
);
// `side`'s own tip is a want too, and it was excluded, so it must not
// have arrived complete.
assert!(
!objects_via_git(&dest, "refs/heads/main").contains(&scene.side),
"{at}: the excluded branch's commit came across anyway"
);
}
}
}
// ── the command line, judged against git's own refusals ─────────────────────
/// Every combination `DepthArgs` refuses, paired with stock git refusing the
/// same one.
///
/// Both halves are asserted. A local refusal that git accepts would be gunnar
/// inventing a rule; a combination gunnar accepts and git refuses is a request
/// that can only ever come back as an error.
#[test]
fn the_flag_combinations_gunnar_refuses_are_the_ones_git_refuses() {
let scene = Scene::new(Kind::Sha1);
let shallow_clone = scene.git_clone("ref.git", true, &["--depth=1"]);
// 1. depth 0.
let err = DepthArgs {
depth: Some(0),
..DepthArgs::default()
}
.to_shallow()
.expect_err("depth 0 is not a positive number");
assert!(
err.to_string().contains("not a positive number"),
"gunnar's wording drifted: {err}"
);
let git_said = git_try(
scene.dir.path(),
&[
"clone",
"--bare",
"--depth=0",
&scene.file_url(),
"zero.git",
],
)
.expect_err("git accepts --depth=0?!");
assert!(
git_said.contains("depth 0 is not a positive number"),
"git 2.53.0 said something else about --depth=0: {git_said}"
);
// 2. --depth with --unshallow.
let err = DepthArgs {
depth: Some(1),
unshallow: true,
..DepthArgs::default()
}
.to_shallow()
.expect_err("--depth and --unshallow are exclusive");
assert!(
err.to_string()
.contains("'--depth' and '--unshallow' cannot be used together"),
"gunnar's wording drifted: {err}"
);
let git_said = git_try(
&shallow_clone,
&["fetch", "--depth=1", "--unshallow", "origin"],
)
.expect_err("git accepts --depth with --unshallow?!");
assert!(
git_said.contains("'--depth' and '--unshallow' cannot be used together"),
"git 2.53.0 said something else: {git_said}"
);
// 3. --deepen with --unshallow. git folds it onto the same message.
let err = DepthArgs {
deepen: Some(1),
unshallow: true,
..DepthArgs::default()
}
.to_shallow()
.expect_err("--deepen and --unshallow are exclusive");
assert!(
err.to_string()
.contains("'--depth' and '--unshallow' cannot be used together"),
"gunnar's wording drifted: {err}"
);
let git_said = git_try(
&shallow_clone,
&["fetch", "--deepen=1", "--unshallow", "origin"],
)
.expect_err("git accepts --deepen with --unshallow?!");
assert!(
git_said.contains("'--depth' and '--unshallow' cannot be used together"),
"git 2.53.0 said something else: {git_said}"
);
// 4. --depth with --shallow-since. git's CLIENT sends both and lets
// upload-pack die on them; gunnar refuses before sending, in git's own
// words, because `Shallow` has one variant per mode.
let err = DepthArgs {
depth: Some(1),
since: Some("@1700000000".into()),
..DepthArgs::default()
}
.to_shallow()
.expect_err("deepen and deepen-since are exclusive");
assert!(
err.to_string()
.contains("deepen and deepen-since (or deepen-not) cannot be used together"),
"gunnar's wording drifted: {err}"
);
let git_said = git_try(
&shallow_clone,
&[
"fetch",
"--depth=1",
"--shallow-since=@1700000000",
"origin",
],
)
.expect_err("git accepts --depth with --shallow-since?!");
assert!(
git_said.contains("deepen and deepen-since (or deepen-not) cannot be used together"),
"git 2.53.0 said something else: {git_said}"
);
// 5. --depth with --shallow-exclude: the same refusal.
let err = DepthArgs {
depth: Some(1),
exclude: vec!["side".into()],
..DepthArgs::default()
}
.to_shallow()
.expect_err("deepen and deepen-not are exclusive");
assert!(
err.to_string()
.contains("deepen and deepen-since (or deepen-not) cannot be used together"),
"gunnar's wording drifted: {err}"
);
// 6. --shallow-since WITH --shallow-exclude is legal, and both go out. This
// is the row that stops the two above from being a blanket refusal.
let both = DepthArgs {
since: Some("@1700000000".into()),
exclude: vec!["side".into()],
..DepthArgs::default()
}
.to_shallow()
.expect("git accepts --shallow-since with --shallow-exclude");
match both {
Shallow::Exclude {
remote_refs,
since_cutoff,
} => {
assert_eq!(remote_refs.len(), 1);
assert_eq!(
since_cutoff.map(|t| t.seconds),
Some(1_700_000_000),
"the cutoff must survive alongside the exclusion"
);
}
other => panic!("expected Shallow::Exclude, got {other:?}"),
}
// 7. Nothing asked for is `NoChange`, not a depth of zero.
assert_eq!(
DepthArgs::default().to_shallow().unwrap(),
Shallow::NoChange
);
assert!(!DepthArgs::default().is_active());
assert!(DepthArgs {
depth: Some(1),
..DepthArgs::default()
}
.is_active());
}
/// `--shallow-exclude` naming nothing: the server is what refuses, and the
/// client must surface that rather than clone the whole history quietly.
#[test]
fn an_unresolvable_shallow_exclude_fails_rather_than_excluding_nothing() {
let scene = Scene::new(Kind::Sha1);
// git first, so the expected shape is stock git's and not gunnar's.
let git_said = git_try(
scene.dir.path(),
&[
"clone",
"--bare",
"--shallow-exclude=nosuchref",
&scene.file_url(),
"gitref.git",
],
)
.expect_err("git accepts an unresolvable --shallow-exclude?!");
// **Two spellings, one refusal.** git 2.53 says `deepen-not is not a ref`;
// git 2.43 says `ambiguous deepen-not: deepen-not <name>`. Both are
// `upload-pack` refusing a `deepen-not` it cannot resolve, which is the
// behaviour this arm exists to copy — the wording is git's and has moved
// once already. Pinning one version's sentence made this test report on
// which git is installed.
assert!(
git_said.contains("deepen-not is not a ref") || git_said.contains("ambiguous deepen-not"),
"git said something that is not a deepen-not refusal: {git_said}"
);
let shallow = DepthArgs {
exclude: vec!["nosuchref".into()],
..DepthArgs::default()
}
.to_shallow()
.expect("the client cannot know whether a name resolves; the server decides");
let dest = scene.dir.path().join("gunnar.git");
let repo = open_or_init_bare(&dest, Kind::Sha1).unwrap();
let (stdout, stdin, mut proc) = GitChild::upload_pack(&scene.src()).into_halves();
let url = scene.src().to_string_lossy().to_string();
let result = fetch_with_io_opts(
&repo,
stdout,
stdin,
&url,
&url,
BARE_REFS,
&FetchOptions::new().with_shallow(shallow),
);
let _ = proc.wait();
let err = result.expect_err(
"a deepen-not nobody can resolve must fail; excluding nothing instead is the \
silent-wrong-answer shape",
);
let _ = err;
// And the applied side: nothing was left behind that claims to be a clone.
// Two INDEPENDENT assertions, not `a == 0 || b`: `dest` is a fresh empty
// bare repo, so `commit_count(--all)` is 0 by construction and an `||` would
// short-circuit before `shallow_file` was ever consulted — the boundary
// check could never go red. Each property is required on its own.
assert_eq!(
commit_count(&dest, "--all"),
0,
"a failed shallow fetch landed commits — it excluded nothing instead of failing"
);
assert!(
shallow_file(&dest).is_empty(),
"a failed shallow fetch left a boundary behind: {:?}",
shallow_file(&dest)
);
}
/// A fetch with no shallow request must leave a complete repository complete,
/// and its `deepen` line absent.
///
/// The negative control for the whole file: it is what makes `wire.contains(
/// "deepen 1")` above mean something, because it proves the string is not simply
/// always there.
#[test]
fn a_plain_fetch_sends_no_deepen_and_stays_complete() {
for kind in BOTH_HASH_KINDS {
for v2 in BOTH_PROTOCOLS {
let scene = Scene::new(kind);
let at = label(kind, v2);
let (dest, outcome, wire) = scene.gunnar_fetch("gunnar.git", v2, &FetchOptions::new());
assert_version(&wire, v2, kind);
assert!(
!sent_any(&wire, "deepen"),
"{at}: a plain fetch sent a deepen argument. The v0 capability list \
names `deepen-since`, `deepen-not` and `deepen-relative`, so this \
looks only at whole argument lines:\n{}",
readable(&wire)
);
assert_eq!(commit_count(&dest, "refs/heads/main"), 3, "{at}");
assert!(
outcome.shallow_boundary.is_empty(),
"{at}: a plain fetch made the repository shallow"
);
assert!(shallow_file(&dest).is_empty(), "{at}");
}
}
}
/// The one thing `Tee` cannot see: that a fetch which never sent `deepen` also
/// never *wrote* a boundary. Kept as a compile-time reminder that `Write` is the
/// half being observed.
fn _tee_is_a_writer<W: Write>(_: Tee<W>) {}
// ── the clone verb ──────────────────────────────────────────────────────────
/// `gunnar clone --depth 1`: the headline. A real working tree, checked out from
/// a shallow fetch, judged against `git clone --depth=1` of the same repository.
///
/// This is the path `crate::clone::clone_with_io` assembles, and the only test
/// that exercises `init_clone_dir` for a remote that **has** commits — every
/// other caller of it is the empty-remote case.
#[test]
fn clone_at_depth_one_produces_the_same_worktree_git_does() {
for kind in BOTH_HASH_KINDS {
for v2 in BOTH_PROTOCOLS {
let scene = Scene::new(kind);
let at = label(kind, v2);
// git's reference, non-bare this time: a worktree, single-branch,
// exactly what `git clone --depth=1` gives a person.
let reference = scene.dir.path().join("ref");
let version = if v2 {
"protocol.version=2"
} else {
"protocol.version=0"
};
git(
scene.dir.path(),
&[
"-c",
version,
"clone",
"-q",
"--depth=1",
&scene.file_url(),
&reference.to_string_lossy(),
],
);
// gunnar. Two connections, as `clone_ssh_with` makes over SSH: one
// to learn the object format and the symbolic HEAD, one to fetch.
let url = scene.src().to_string_lossy().to_string();
let identity = {
let child = if v2 {
GitChild::upload_pack(&scene.src())
} else {
GitChild::upload_pack_v0(&scene.src())
};
let (stdout, stdin, mut proc) = child.into_halves();
let id = gunnar_client::fetch::remote_identity_with_io(stdout, stdin, &url)
.unwrap_or_else(|e| {
panic!("{at}: remote identity: {}", gunnar_client::chain(&e))
});
let _ = proc.wait();
id
};
assert_eq!(
identity.hash_kind, kind,
"{at}: the object format must be READ, never guessed"
);
assert_eq!(
identity.head_target.as_deref(),
Some("refs/heads/main"),
"{at}: the symbolic HEAD must come off the advertisement"
);
let dest = scene.dir.path().join("gunnar");
let child = if v2 {
GitChild::upload_pack(&scene.src())
} else {
GitChild::upload_pack_v0(&scene.src())
};
let (stdout, stdin, mut proc) = child.into_halves();
let report = gunnar_client::clone_with_io(
&identity,
stdout,
stdin,
&url,
&url,
&dest,
&FetchOptions::new().with_shallow(Shallow::DepthAtRemote(
std::num::NonZeroU32::new(1).unwrap(),
)),
)
.unwrap_or_else(|e| {
panic!("{at}: gunnar clone --depth 1: {}", gunnar_client::chain(&e))
});
let _ = proc.wait();
assert_eq!(report.branch, "refs/heads/main", "{at}");
assert_eq!(
report.head.map(|id| id.to_string()),
Some(scene.main[2].clone()),
"{at}"
);
assert_eq!(
git(&dest, &["rev-parse", "HEAD"]),
git(&reference, &["rev-parse", "HEAD"]),
"{at}: gunnar checked out a different commit than git did"
);
assert_eq!(
commit_count(&dest, "HEAD"),
commit_count(&reference, "HEAD"),
"{at}: different amount of history"
);
assert_eq!(commit_count(&dest, "HEAD"), 1, "{at}");
assert!(
!shallow_file(&dest).is_empty(),
"{at}: the clone is not shallow at all — .git/shallow is empty"
);
assert_eq!(
report
.shallow_boundary
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
shallow_file(&dest),
"{at}: the report disagrees with .git/shallow"
);
// The worktree is real, not just the object database.
assert_eq!(
std::fs::read_to_string(dest.join("c.txt")).unwrap_or_default(),
"three\n",
"{at}: the tip's file is not on disk, so no checkout happened"
);
// And `git` itself is content with what gunnar built.
assert!(
git_try(&dest, &["fsck", "--strict", "--no-progress"]).is_ok(),
"{at}: fsck rejected the shallow clone"
);
assert!(
git_try(&dest, &["status", "--porcelain"])
.map(|s| s.trim().is_empty())
.unwrap_or(false),
"{at}: the checkout is dirty, so the worktree does not match HEAD"
);
// `remote.origin` and the branch's tracking config, which is what
// makes `git pull` work in the clone.
assert_eq!(git(&dest, &["config", "remote.origin.url"]), url, "{at}");
assert_eq!(
git(&dest, &["config", "branch.main.remote"]),
"origin",
"{at}: without this `git pull` says there is no tracking information"
);
}
}
}