#![cfg(feature = "http-ureq")]
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use grit_lib::config::ConfigSet;
use grit_lib::error::{Error, Result as GritResult};
use grit_lib::objects::odb::Odb;
use grit_lib::objects::ObjectId;
use grit_lib::refs::resolve_ref;
use grit_lib::remote::fetch::NoProgress;
use grit_lib::remote::push::push_http;
use grit_lib::remote::push_report::PushRefStatus;
use grit_lib::remote::transfer::{FetchOptions, PushOptions, PushRefSpec, TagMode, UpdateMode};
use grit_lib::remote::transport::http::{http_client_arc, ureq_client::UreqHttpClient};
use grit_lib::remote::transport::http::{http_fetch, HttpClient, SmartHttpTransport};
use grit_lib::remote::transport::{ConnectOptions, Service, Transport};
fn git(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.args(args)
.env("GIT_AUTHOR_NAME", "T")
.env("GIT_AUTHOR_EMAIL", "t@example.com")
.env("GIT_AUTHOR_DATE", "2005-04-07T22:13:13 +0200")
.env("GIT_COMMITTER_NAME", "T")
.env("GIT_COMMITTER_EMAIL", "t@example.com")
.env("GIT_COMMITTER_DATE", "2005-04-07T22:13:13 +0200")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("run git");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).expect("utf8 git output")
}
fn rev_parse(dir: &Path, rev: &str) -> ObjectId {
ObjectId::from_hex(git(dir, &["rev-parse", rev]).trim()).expect("valid oid")
}
fn open_odb(git_dir: &Path) -> Odb {
Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.to_path_buf())
}
fn build_source(dir: &Path) {
git(dir, &["init", "-q", "-b", "main", "."]);
std::fs::write(dir.join("a.txt"), "one\n").unwrap();
git(dir, &["add", "a.txt"]);
git(dir, &["commit", "-q", "-m", "c1"]);
std::fs::write(dir.join("b.txt"), "two\n").unwrap();
git(dir, &["add", "b.txt"]);
git(dir, &["commit", "-q", "-m", "c2"]);
git(dir, &["tag", "-a", "v1", "-m", "release one"]);
git(dir, &["branch", "topic"]);
}
fn build_heavy_source(dir: &Path, extra_refs: usize) {
build_source(dir);
let tip = git(dir, &["rev-parse", "HEAD"]).trim().to_owned();
for i in 0..extra_refs {
git(
dir,
&[
"update-ref",
&format!("refs/heads/branch-{i}"),
tip.as_str(),
],
);
}
std::fs::write(dir.join("large.bin"), vec![b'L'; 512 * 1024]).unwrap();
git(dir, &["add", "large.bin"]);
git(dir, &["commit", "-q", "-m", "large blob"]);
}
fn free_port() -> Option<u16> {
static USED: std::sync::Mutex<Vec<u16>> = std::sync::Mutex::new(Vec::new());
let mut used = USED.lock().unwrap_or_else(|e| e.into_inner());
for _ in 0..200 {
let l = TcpListener::bind(("127.0.0.1", 0)).ok()?;
let p = l.local_addr().ok()?.port();
drop(l);
if !used.contains(&p) {
used.push(p);
return Some(p);
}
}
None
}
fn find_binary(name: &str) -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
let deps = exe.parent()?; let profile = deps.parent()?; for cand in [profile.join(name), deps.join(name)] {
if cand.is_file() {
return Some(cand);
}
}
None
}
fn spawn_server(server_bin: &Path, root: &Path, port: u16) -> Option<Child> {
Command::new(server_bin)
.arg("--root")
.arg(root)
.arg("--bind")
.arg(format!("127.0.0.1:{port}"))
.env_remove("GRIT_BIN")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()
}
fn wait_ready(port: u16) -> bool {
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() {
return true;
}
std::thread::sleep(Duration::from_millis(50));
}
false
}
struct ServerGuard(Child);
impl Drop for ServerGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[test]
fn fetch_over_smart_http_lands_refs_and_objects() {
let Some(server_bin) = find_binary("grit-http-server") else {
eprintln!("SKIP: `grit-http-server` binary not found (build grit-http-server first)");
return;
};
let tmp = tempfile::tempdir().expect("tempdir");
let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
build_source(&work);
let root = tmp.path().join("srv");
std::fs::create_dir_all(&root).unwrap();
let source = root.join("repo.git");
git(
&work,
&[
"clone",
"-q",
"--bare",
".",
source.to_str().expect("utf8 path"),
],
);
git(&source, &["symbolic-ref", "HEAD", "refs/heads/main"]);
let main_oid = rev_parse(&source, "refs/heads/main");
let topic_oid = rev_parse(&source, "refs/heads/topic");
let c1_oid = rev_parse(&work, "HEAD~1");
let tag_oid = rev_parse(&source, "refs/tags/v1");
let Some(port) = free_port() else {
eprintln!("SKIP: could not allocate a free port");
return;
};
let Some(child) = spawn_server(&server_bin, &root, port) else {
eprintln!("SKIP: could not spawn grit-http-server");
return;
};
let _guard = ServerGuard(child);
if !wait_ready(port) {
eprintln!("SKIP: grit-http-server did not become ready on port {port}");
return;
}
let url = format!("http://127.0.0.1:{port}/repo.git");
let local = tmp.path().join("local");
std::fs::create_dir_all(&local).unwrap();
git(&local, &["init", "-q", "-b", "main", "."]);
let local_git = local.join(".git");
let client = http_client_arc(UreqHttpClient::new());
let transport = SmartHttpTransport::new(client);
let conn = match transport.connect(&url, Service::UploadPack, &ConnectOptions::default()) {
Ok(c) => c,
Err(e) => {
eprintln!("SKIP: could not connect to grit-http-server: {e}");
return;
}
};
assert!(
conn.advertised_refs()
.iter()
.any(|(n, o)| n == "refs/heads/main" && *o == main_oid),
"advertisement missing refs/heads/main = {}",
main_oid.to_hex()
);
assert_eq!(conn.head_symref(), Some("refs/heads/main"));
assert_eq!(conn.protocol_version(), 0);
drop(conn);
let client = Arc::new(RecordingClient::new_default());
let mut opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
tags: TagMode::All,
..Default::default()
};
let outcome = http_fetch(client.clone(), &local_git, &url, &mut opts, &mut NoProgress)
.expect("http_fetch over grit-http-server");
let v1_commands = client.post_commands.lock().unwrap().clone();
assert!(
v1_commands.iter().any(|c| c.starts_with("want ")),
"v0/v1 fetch must POST a bare `want` body; saw {v1_commands:?}"
);
assert!(
!v1_commands
.iter()
.any(|c| c == "command=ls-refs" || c == "command=fetch"),
"v0/v1 fetch must NOT POST any v2 command body; saw {v1_commands:?}"
);
let v1_protocols = client.git_protocols.lock().unwrap().clone();
assert!(
v1_protocols.iter().all(|p| p.is_none()),
"v0/v1 fetch must not send a Git-Protocol header; saw {v1_protocols:?}"
);
let got_main = resolve_ref(&local_git, "refs/remotes/origin/main").expect("origin/main");
let got_topic = resolve_ref(&local_git, "refs/remotes/origin/topic").expect("origin/topic");
assert_eq!(got_main, main_oid, "origin/main oid mismatch vs source");
assert_eq!(got_topic, topic_oid, "origin/topic oid mismatch vs source");
let got_tag = resolve_ref(&local_git, "refs/tags/v1").expect("tag v1 written");
assert_eq!(got_tag, tag_oid, "tag v1 oid mismatch vs source");
let local_odb = open_odb(&local_git);
for oid in [main_oid, topic_oid, c1_oid, tag_oid] {
assert!(
local_odb.exists(&oid),
"object {} missing from local odb after http fetch",
oid.to_hex()
);
local_odb
.read(&oid)
.unwrap_or_else(|e| panic!("read {}: {e}", oid.to_hex()));
}
let main_update = outcome
.updates
.iter()
.find(|u| u.remote_ref == "refs/heads/main")
.expect("update for main");
assert_eq!(main_update.mode, UpdateMode::New);
assert_eq!(main_update.new_oid, Some(main_oid));
assert_eq!(outcome.default_branch.as_deref(), Some("main"));
assert_eq!(
got_main.to_hex(),
git(&source, &["rev-parse", "refs/heads/main"]).trim()
);
let fsck = Command::new("git")
.current_dir(&local)
.args(["fsck", "--no-dangling"])
.output()
.expect("run git fsck");
assert!(
fsck.status.success(),
"git fsck failed after http fetch: {}",
String::from_utf8_lossy(&fsck.stderr)
);
}
struct RecordingClient {
inner: UreqHttpClient,
git_protocols: Mutex<Vec<Option<String>>>,
post_commands: Mutex<Vec<String>>,
fetch_pack_object_counts: Mutex<Vec<Option<u32>>>,
fetch_sent_haves: Mutex<bool>,
}
impl RecordingClient {
fn new(git_protocol: &str) -> Self {
Self::from_inner(UreqHttpClient::new().with_git_protocol(git_protocol.to_owned()))
}
fn new_default() -> Self {
Self::from_inner(UreqHttpClient::new())
}
fn from_default_config() -> GritResult<Self> {
let inner = UreqHttpClient::from_config(&ConfigSet::new())?;
Ok(Self::from_inner(inner))
}
fn from_inner(inner: UreqHttpClient) -> Self {
Self {
inner,
git_protocols: Mutex::new(Vec::new()),
post_commands: Mutex::new(Vec::new()),
fetch_pack_object_counts: Mutex::new(Vec::new()),
fetch_sent_haves: Mutex::new(false),
}
}
fn body_has_have_line(body: &[u8]) -> bool {
let mut i = 0usize;
while i + 4 <= body.len() {
let Ok(len_str) = std::str::from_utf8(&body[i..i + 4]) else {
return false;
};
let Ok(len) = usize::from_str_radix(len_str, 16) else {
return false;
};
if len < 4 {
i += 4;
continue;
}
if i + len > body.len() {
return false;
}
let payload = &body[i + 4..i + len];
if payload.starts_with(b"have ") {
return true;
}
i += len;
}
false
}
fn first_pkt_line(body: &[u8]) -> String {
if body.len() < 4 {
return String::new();
}
let Ok(len_str) = std::str::from_utf8(&body[..4]) else {
return String::new();
};
let Ok(len) = usize::from_str_radix(len_str, 16) else {
return String::new();
};
if len <= 4 || len > body.len() {
return String::new();
}
String::from_utf8_lossy(&body[4..len]).trim_end().to_owned()
}
fn pack_object_count(resp: &[u8]) -> Option<u32> {
let mut i = 0usize;
let mut in_packfile = false;
let mut pack: Vec<u8> = Vec::new();
while i + 4 <= resp.len() {
let Ok(len_str) = std::str::from_utf8(&resp[i..i + 4]) else {
break;
};
let Ok(len) = usize::from_str_radix(len_str, 16) else {
break;
};
if len < 4 {
i += 4;
if in_packfile && len == 0 {
break;
}
continue;
}
if i + len > resp.len() {
break;
}
let payload = &resp[i + 4..i + len];
i += len;
let header = if !payload.is_empty() && payload[0] == 1 {
String::from_utf8_lossy(&payload[1..]).trim_end().to_owned()
} else {
String::from_utf8_lossy(payload).trim_end().to_owned()
};
if matches!(
header.as_str(),
"acknowledgments" | "shallow-info" | "wanted-refs" | "packfile-uris" | "packfile"
) {
in_packfile = header == "packfile";
continue;
}
if in_packfile && !payload.is_empty() && payload[0] == 1 {
pack.extend_from_slice(&payload[1..]);
}
}
if pack.len() >= 12 && &pack[0..4] == b"PACK" {
Some(u32::from_be_bytes([pack[8], pack[9], pack[10], pack[11]]))
} else {
None
}
}
fn fetch_object_counts(&self) -> Vec<Option<u32>> {
self.fetch_pack_object_counts.lock().unwrap().clone()
}
}
impl HttpClient for RecordingClient {
fn get(&self, url: &str, git_protocol: Option<&str>) -> GritResult<Vec<u8>> {
let gp = git_protocol.or_else(|| self.inner.git_protocol_header());
self.git_protocols
.lock()
.unwrap()
.push(gp.map(str::to_owned));
self.inner.get(url, git_protocol)
}
fn post(
&self,
url: &str,
content_type: &str,
accept: &str,
body: &[u8],
git_protocol: Option<&str>,
) -> GritResult<Vec<u8>> {
let gp = git_protocol.or_else(|| self.inner.git_protocol_header());
self.git_protocols
.lock()
.unwrap()
.push(gp.map(str::to_owned));
let command = Self::first_pkt_line(body);
self.post_commands.lock().unwrap().push(command.clone());
if command == "command=fetch" && Self::body_has_have_line(body) {
*self.fetch_sent_haves.lock().unwrap() = true;
}
let resp = self
.inner
.post(url, content_type, accept, body, git_protocol)?;
if command == "command=fetch" {
self.fetch_pack_object_counts
.lock()
.unwrap()
.push(Self::pack_object_count(&resp));
}
Ok(resp)
}
fn git_protocol_header(&self) -> Option<&str> {
self.inner.git_protocol_header()
}
}
struct IoFailUploadPackClient<C> {
inner: C,
}
impl<C: HttpClient> HttpClient for IoFailUploadPackClient<C> {
fn get(&self, url: &str, git_protocol: Option<&str>) -> GritResult<Vec<u8>> {
self.inner.get(url, git_protocol)
}
fn post(
&self,
url: &str,
content_type: &str,
accept: &str,
body: &[u8],
git_protocol: Option<&str>,
) -> GritResult<Vec<u8>> {
if url.contains("git-upload-pack") {
return Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"failed to fill whole buffer",
)));
}
self.inner
.post(url, content_type, accept, body, git_protocol)
}
fn git_protocol_header(&self) -> Option<&str> {
self.inner.git_protocol_header()
}
}
#[test]
fn fetch_over_smart_http_v2_lands_refs_and_objects() {
let Some(server_bin) = find_binary("grit-http-server") else {
eprintln!("SKIP: `grit-http-server` binary not found (build grit-http-server first)");
return;
};
let tmp = tempfile::tempdir().expect("tempdir");
let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
build_source(&work);
let root = tmp.path().join("srv");
std::fs::create_dir_all(&root).unwrap();
let source = root.join("repo.git");
git(
&work,
&[
"clone",
"-q",
"--bare",
".",
source.to_str().expect("utf8 path"),
],
);
git(&source, &["symbolic-ref", "HEAD", "refs/heads/main"]);
let main_oid = rev_parse(&source, "refs/heads/main");
let topic_oid = rev_parse(&source, "refs/heads/topic");
let c1_oid = rev_parse(&work, "HEAD~1");
let tag_oid = rev_parse(&source, "refs/tags/v1");
let Some(port) = free_port() else {
eprintln!("SKIP: could not allocate a free port");
return;
};
let Some(child) = spawn_server(&server_bin, &root, port) else {
eprintln!("SKIP: could not spawn grit-http-server");
return;
};
let _guard = ServerGuard(child);
if !wait_ready(port) {
eprintln!("SKIP: grit-http-server did not become ready on port {port}");
return;
}
let url = format!("http://127.0.0.1:{port}/repo.git");
let xcheck = tmp.path().join("v2xcheck");
let xclone = Command::new("git")
.args([
"-c",
"protocol.version=2",
"clone",
"-q",
&url,
xcheck.to_str().expect("utf8 path"),
])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("run git v2 clone");
if !xclone.status.success() {
eprintln!(
"SKIP: server does not speak protocol v2 (git v2 clone failed: {})",
String::from_utf8_lossy(&xclone.stderr)
);
return;
}
let local = tmp.path().join("local");
std::fs::create_dir_all(&local).unwrap();
git(&local, &["init", "-q", "-b", "main", "."]);
let local_git = local.join(".git");
let recording = Arc::new(RecordingClient::new("version=2"));
let transport = SmartHttpTransport::new(Arc::clone(&recording));
let opts_v2 = ConnectOptions {
protocol_version: 2,
..Default::default()
};
let conn = match transport.connect(&url, Service::UploadPack, &opts_v2) {
Ok(c) => c,
Err(e) => {
eprintln!("SKIP: could not connect to grit-http-server over v2: {e}");
return;
}
};
assert_eq!(
conn.protocol_version(),
2,
"expected a v2 advertisement from the server"
);
assert!(
conn.advertised_refs().is_empty(),
"v2 connect carries no refs (they come from ls-refs)"
);
assert!(
conn.capabilities()
.iter()
.any(|c| c.starts_with("fetch=") || c.starts_with("ls-refs")),
"v2 capability block missing: {:?}",
conn.capabilities()
);
drop(conn);
let mut opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
tags: TagMode::All,
..Default::default()
};
let outcome = http_fetch(
recording.clone(),
&local_git,
&url,
&mut opts,
&mut NoProgress,
)
.expect("v2 http_fetch over grit-http-server");
let commands = recording.post_commands.lock().unwrap().clone();
assert!(
commands.iter().any(|c| c == "command=ls-refs"),
"expected a `command=ls-refs` POST (v2 path); saw {commands:?}"
);
assert!(
commands.iter().any(|c| c == "command=fetch"),
"expected a `command=fetch` POST (v2 path); saw {commands:?}"
);
assert!(
!commands.iter().any(|c| c.starts_with("want ")),
"v2 path must not POST a bare v0/v1 want body; saw {commands:?}"
);
let protocols = recording.git_protocols.lock().unwrap().clone();
assert!(
protocols.iter().all(|p| p.as_deref() == Some("version=2")),
"expected version=2 on every request; saw {protocols:?}"
);
let scratch_counts: Vec<u32> = recording
.fetch_object_counts()
.into_iter()
.flatten()
.collect();
assert_eq!(
scratch_counts.len(),
1,
"expected exactly one packfile-bearing command=fetch response on a from-scratch v2 fetch; saw {scratch_counts:?}"
);
let scratch_objs = scratch_counts[0];
assert!(
scratch_objs >= 6,
"from-scratch v2 fetch should pack the full closure (>= 6 objects), packed {scratch_objs}"
);
let got_main = resolve_ref(&local_git, "refs/remotes/origin/main").expect("origin/main");
let got_topic = resolve_ref(&local_git, "refs/remotes/origin/topic").expect("origin/topic");
assert_eq!(got_main, main_oid, "origin/main oid mismatch vs source");
assert_eq!(got_topic, topic_oid, "origin/topic oid mismatch vs source");
let got_tag = resolve_ref(&local_git, "refs/tags/v1").expect("tag v1 written");
assert_eq!(got_tag, tag_oid, "tag v1 oid mismatch vs source");
let local_odb = open_odb(&local_git);
for oid in [main_oid, topic_oid, c1_oid, tag_oid] {
assert!(
local_odb.exists(&oid),
"object {} missing from local odb after v2 http fetch",
oid.to_hex()
);
local_odb
.read(&oid)
.unwrap_or_else(|e| panic!("read {}: {e}", oid.to_hex()));
}
let main_update = outcome
.updates
.iter()
.find(|u| u.remote_ref == "refs/heads/main")
.expect("update for main");
assert_eq!(main_update.mode, UpdateMode::New);
assert_eq!(main_update.new_oid, Some(main_oid));
assert_eq!(outcome.default_branch.as_deref(), Some("main"));
assert_eq!(
got_main.to_hex(),
git(&source, &["rev-parse", "refs/heads/main"]).trim()
);
let fsck = Command::new("git")
.current_dir(&local)
.args(["fsck", "--no-dangling"])
.output()
.expect("run git fsck");
assert!(
fsck.status.success(),
"git fsck failed after v2 http fetch: {}",
String::from_utf8_lossy(&fsck.stderr)
);
std::fs::write(work.join("c.txt"), "three\n").unwrap();
git(&work, &["add", "c.txt"]);
git(&work, &["commit", "-q", "-m", "c3"]);
git(
&work,
&["push", "-q", source.to_str().expect("utf8 path"), "main"],
);
let new_main_oid = rev_parse(&source, "refs/heads/main");
assert_ne!(new_main_oid, main_oid, "source main should have advanced");
let recording2 = Arc::new(RecordingClient::new("version=2"));
let outcome2 = http_fetch(
recording2.clone(),
&local_git,
&url,
&mut opts,
&mut NoProgress,
)
.expect("incremental v2 http_fetch");
let commands2 = recording2.post_commands.lock().unwrap().clone();
assert!(
commands2.iter().any(|c| c == "command=ls-refs"),
"incremental fetch missing ls-refs; saw {commands2:?}"
);
assert!(
commands2.iter().any(|c| c == "command=fetch"),
"incremental fetch missing command=fetch; saw {commands2:?}"
);
let got_main2 = resolve_ref(&local_git, "refs/remotes/origin/main").expect("origin/main");
assert_eq!(
got_main2, new_main_oid,
"incremental v2 fetch did not advance origin/main"
);
assert!(
local_odb.exists(&new_main_oid),
"new commit object missing after incremental v2 fetch"
);
let main_update2 = outcome2
.updates
.iter()
.find(|u| u.remote_ref == "refs/heads/main")
.expect("incremental update for main");
assert_eq!(main_update2.mode, UpdateMode::FastForward);
assert!(
*recording2.fetch_sent_haves.lock().unwrap(),
"incremental v2 fetch must POST `have` lines so the server can trim the pack"
);
let inc_counts: Vec<u32> = recording2
.fetch_object_counts()
.into_iter()
.flatten()
.collect();
assert_eq!(
inc_counts.len(),
1,
"expected exactly one packfile-bearing command=fetch response on the incremental v2 fetch; saw {inc_counts:?}"
);
let inc_objs = inc_counts[0];
assert!(
inc_objs <= 4,
"incremental v2 fetch (one new commit) must send a minimal pack (<= 4 objects), sent {inc_objs}"
);
assert!(
inc_objs < scratch_objs,
"incremental pack ({inc_objs} objects) must be smaller than the from-scratch closure ({scratch_objs})"
);
let fsck2 = Command::new("git")
.current_dir(&local)
.args(["fsck", "--no-dangling"])
.output()
.expect("run git fsck");
assert!(
fsck2.status.success(),
"git fsck failed after incremental v2 http fetch: {}",
String::from_utf8_lossy(&fsck2.stderr)
);
}
#[test]
fn fetch_from_config_client_v2_many_refs_and_large_pack() {
let Some(server_bin) = find_binary("grit-http-server") else {
eprintln!("SKIP: `grit-http-server` binary not found (build grit-http-server first)");
return;
};
let tmp = tempfile::tempdir().expect("tempdir");
let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
build_heavy_source(&work, 1500);
let root = tmp.path().join("srv");
std::fs::create_dir_all(&root).unwrap();
let source = root.join("repo.git");
git(
&work,
&[
"clone",
"-q",
"--bare",
".",
source.to_str().expect("utf8 path"),
],
);
git(&source, &["symbolic-ref", "HEAD", "refs/heads/main"]);
let Some(port) = free_port() else {
eprintln!("SKIP: could not allocate a free port");
return;
};
let Some(child) = spawn_server(&server_bin, &root, port) else {
eprintln!("SKIP: could not spawn grit-http-server");
return;
};
let _guard = ServerGuard(child);
if !wait_ready(port) {
eprintln!("SKIP: grit-http-server did not become ready on port {port}");
return;
}
let url = format!("http://127.0.0.1:{port}/repo.git");
let local = tmp.path().join("local");
std::fs::create_dir_all(&local).unwrap();
git(&local, &["init", "-q", "-b", "main", "."]);
let local_git = local.join(".git");
let recording = Arc::new(
RecordingClient::from_default_config().expect("from_config client for #911 regression"),
);
assert_eq!(
recording.git_protocol_header(),
Some("version=2"),
"from_config must default to Git-Protocol version=2 (grit-cli path)"
);
assert!(
UreqHttpClient::new().git_protocol_header().is_none(),
"sanity: bare new() must not imply v2 — regression targets from_config"
);
let mut opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
tags: TagMode::All,
..Default::default()
};
let outcome = http_fetch(
recording.clone(),
&local_git,
&url,
&mut opts,
&mut NoProgress,
)
.expect("from_config v2 http_fetch over many-ref repo");
let commands = recording.post_commands.lock().unwrap().clone();
assert!(
commands.iter().any(|c| c == "command=ls-refs"),
"from_config fetch must use v2 ls-refs; saw {commands:?}"
);
assert!(
commands.iter().any(|c| c == "command=fetch"),
"from_config fetch must use v2 command=fetch; saw {commands:?}"
);
let branch_updates = outcome
.updates
.iter()
.filter(|u| u.remote_ref.starts_with("refs/heads/branch-"))
.count();
assert!(
branch_updates >= 1500,
"expected >=1500 branch ref updates, got {branch_updates}"
);
let main_oid = rev_parse(&source, "refs/heads/main");
assert_eq!(
resolve_ref(&local_git, "refs/remotes/origin/main").expect("origin/main"),
main_oid
);
let fsck = Command::new("git")
.current_dir(&local)
.args(["fsck", "--no-dangling"])
.output()
.expect("run git fsck");
assert!(
fsck.status.success(),
"git fsck failed after from_config v2 fetch: {}",
String::from_utf8_lossy(&fsck.stderr)
);
}
#[test]
fn http_fetch_surfaces_io_error_from_upload_pack_read_failure() {
let Some(server_bin) = find_binary("grit-http-server") else {
eprintln!("SKIP: `grit-http-server` binary not found (build grit-http-server first)");
return;
};
let tmp = tempfile::tempdir().expect("tempdir");
let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
build_source(&work);
let root = tmp.path().join("srv");
std::fs::create_dir_all(&root).unwrap();
let source = root.join("repo.git");
git(
&work,
&[
"clone",
"-q",
"--bare",
".",
source.to_str().expect("utf8 path"),
],
);
git(&source, &["symbolic-ref", "HEAD", "refs/heads/main"]);
let Some(port) = free_port() else {
eprintln!("SKIP: could not allocate a free port");
return;
};
let Some(child) = spawn_server(&server_bin, &root, port) else {
eprintln!("SKIP: could not spawn grit-http-server");
return;
};
let _guard = ServerGuard(child);
if !wait_ready(port) {
eprintln!("SKIP: grit-http-server did not become ready on port {port}");
return;
}
let url = format!("http://127.0.0.1:{port}/repo.git");
let local = tmp.path().join("local");
std::fs::create_dir_all(&local).unwrap();
git(&local, &["init", "-q", "-b", "main", "."]);
let local_git = local.join(".git");
let inner = UreqHttpClient::from_config(&ConfigSet::new()).expect("from_config");
let client = http_client_arc(IoFailUploadPackClient { inner });
let mut opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
tags: TagMode::None,
..Default::default()
};
let err = http_fetch(client, &local_git, &url, &mut opts, &mut NoProgress).expect_err("I/O");
assert!(
matches!(err, Error::Io(_)),
"truncated upload-pack read must surface as Io error, got {err:?}"
);
}
fn make_bare_target(root: &Path, name: &str) -> PathBuf {
let bare = root.join(name);
std::fs::create_dir_all(&bare).unwrap();
git(&bare, &["init", "-q", "--bare", "."]);
git(&bare, &["symbolic-ref", "HEAD", "refs/heads/main"]);
bare
}
fn build_deep_history_repo(dir: &Path, commits: usize) {
git(dir, &["init", "-q", "-b", "main", "."]);
for i in 0..commits {
std::fs::write(dir.join(format!("f{i:03}.txt")), format!("content {i}\n")).unwrap();
std::fs::write(dir.join("shared.txt"), format!("shared rev {i}\n")).unwrap();
git(dir, &["add", "-A"]);
git(dir, &["commit", "-q", "-m", &format!("commit {i}")]);
}
}
fn verify_pack_delta_count(pack_idx: &Path) -> usize {
let out = Command::new("git")
.args(["verify-pack", "-v"])
.arg(pack_idx)
.output()
.expect("verify-pack");
assert!(
out.status.success(),
"verify-pack failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|line| line.split_whitespace().count() == 7)
.count()
}
fn first_pack_idx(bare: &Path) -> Option<PathBuf> {
let pack_dir = bare.join("objects/pack");
let mut idxs: Vec<_> = std::fs::read_dir(&pack_dir)
.ok()?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "idx"))
.collect();
idxs.sort();
idxs.into_iter().next()
}
#[test]
fn push_over_smart_http_initial_pack_is_delta_compressed() {
let Some(server_bin) = find_binary("grit-http-server") else {
eprintln!("SKIP: `grit-http-server` binary not found (build grit-http-server first)");
return;
};
let tmp = tempfile::tempdir().expect("tempdir");
let local = tmp.path().join("local");
std::fs::create_dir_all(&local).unwrap();
build_deep_history_repo(&local, 50);
git(&local, &["repack", "-adf"]);
let local_git = local.join(".git");
let main_oid = rev_parse(&local, "refs/heads/main");
let root = tmp.path().join("srv");
std::fs::create_dir_all(&root).unwrap();
let bare = make_bare_target(&root, "delta.git");
git(&bare, &["config", "receive.unpacklimit", "1"]);
let Some(port) = free_port() else {
eprintln!("SKIP: could not allocate a free port");
return;
};
let Some(child) = spawn_server(&server_bin, &root, port) else {
eprintln!("SKIP: could not spawn grit-http-server");
return;
};
let _guard = ServerGuard(child);
if !wait_ready(port) {
eprintln!("SKIP: grit-http-server did not become ready on port {port}");
return;
}
let url = format!("http://127.0.0.1:{port}/delta.git");
let client = http_client_arc(UreqHttpClient::new());
let spec = PushRefSpec {
src: Some(main_oid),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
};
let outcome = push_http(
client,
&local_git,
&url,
&[spec],
&PushOptions::default(),
&mut NoProgress,
&grit_lib::progress::NeverCancel,
)
.expect("push_http initial deep history");
assert_eq!(outcome.results[0].status, PushRefStatus::Ok);
let Some(idx) = first_pack_idx(&bare) else {
panic!("remote bare repo has no pack after push");
};
let deltas = verify_pack_delta_count(&idx);
assert!(
deltas > 0,
"http push pack must contain delta objects (got {deltas})"
);
}
#[test]
fn push_over_smart_http_lands_ref_and_objects_and_reports_rejection() {
let Some(server_bin) = find_binary("grit-http-server") else {
eprintln!("SKIP: `grit-http-server` binary not found (build grit-http-server first)");
return;
};
let tmp = tempfile::tempdir().expect("tempdir");
let local = tmp.path().join("local");
std::fs::create_dir_all(&local).unwrap();
build_source(&local);
let local_git = local.join(".git");
let main_oid = rev_parse(&local, "refs/heads/main");
let c1_oid = rev_parse(&local, "HEAD~1");
let root = tmp.path().join("srv");
std::fs::create_dir_all(&root).unwrap();
let bare = make_bare_target(&root, "push.git");
let Some(port) = free_port() else {
eprintln!("SKIP: could not allocate a free port");
return;
};
let Some(child) = spawn_server(&server_bin, &root, port) else {
eprintln!("SKIP: could not spawn grit-http-server");
return;
};
let _guard = ServerGuard(child);
if !wait_ready(port) {
eprintln!("SKIP: grit-http-server did not become ready on port {port}");
return;
}
let url = format!("http://127.0.0.1:{port}/push.git");
let client = http_client_arc(UreqHttpClient::new());
let probe_url = format!("{url}/info/refs?service=git-receive-pack");
match client.get(&probe_url, None) {
Ok(body) if body.windows(20).any(|w| w == b"# service=git-receiv") => {}
Ok(_) => {
eprintln!("SKIP: server returned a non-smart receive-pack advertisement");
return;
}
Err(e) => {
eprintln!("SKIP: server does not offer receive-pack: {e}");
return;
}
}
let spec = PushRefSpec {
src: Some(main_oid),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
};
let outcome = push_http(
client.clone(),
&local_git,
&url,
&[spec],
&PushOptions::default(),
&mut NoProgress,
&grit_lib::progress::NeverCancel,
)
.expect("push_http over grit-http-server");
assert_eq!(outcome.results.len(), 1);
let r = &outcome.results[0];
assert_eq!(
r.status,
PushRefStatus::Ok,
"push of new ref should be accepted, got {:?} ({:?})",
r.status,
r.message
);
assert_eq!(r.new_oid, Some(main_oid));
assert!(r.old_oid.is_none(), "new ref has no old value");
let remote_main = resolve_ref(&bare, "refs/heads/main").expect("remote main written");
assert_eq!(
remote_main, main_oid,
"remote main oid mismatch after http push"
);
let remote_odb = open_odb(&bare);
for oid in [main_oid, c1_oid] {
assert!(
remote_odb.exists(&oid),
"object {} missing from remote odb after http push",
oid.to_hex()
);
remote_odb
.read(&oid)
.unwrap_or_else(|e| panic!("read {}: {e}", oid.to_hex()));
}
let fsck = Command::new("git")
.current_dir(&bare)
.args(["fsck", "--no-dangling"])
.output()
.expect("run git fsck");
assert!(
fsck.status.success(),
"git fsck failed after http push: {}\n{}",
String::from_utf8_lossy(&fsck.stdout),
String::from_utf8_lossy(&fsck.stderr)
);
assert_eq!(
remote_main.to_hex(),
git(&bare, &["rev-parse", "refs/heads/main"]).trim()
);
git(&local, &["checkout", "-q", "-b", "feature"]);
std::fs::write(local.join("t.txt"), "feature\n").unwrap();
git(&local, &["add", "t.txt"]);
git(&local, &["commit", "-q", "-m", "feature1"]);
let topic_oid = rev_parse(&local, "refs/heads/feature");
let spec_topic = PushRefSpec {
src: Some(topic_oid),
src_ref: None,
dst: "refs/heads/feature".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
};
let outcome_topic = push_http(
client.clone(),
&local_git,
&url,
&[spec_topic],
&PushOptions::default(),
&mut NoProgress,
&grit_lib::progress::NeverCancel,
)
.expect("push_http topic over grit-http-server");
assert_eq!(outcome_topic.results[0].status, PushRefStatus::Ok);
let remote_topic = resolve_ref(&bare, "refs/heads/feature").expect("remote feature written");
assert_eq!(remote_topic, topic_oid);
let fsck_topic = Command::new("git")
.current_dir(&bare)
.args(["fsck", "--no-dangling"])
.output()
.expect("run git fsck");
assert!(
fsck_topic.status.success(),
"git fsck failed after second http push: {}",
String::from_utf8_lossy(&fsck_topic.stderr)
);
git(&local, &["checkout", "-q", "-b", "diverge", "HEAD~2"]);
std::fs::write(local.join("d.txt"), "diverge\n").unwrap();
git(&local, &["add", "d.txt"]);
git(&local, &["commit", "-q", "-m", "divergent"]);
let diverged = rev_parse(&local, "HEAD");
assert_ne!(diverged, main_oid);
let nonff = PushRefSpec {
src: Some(diverged),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
};
let outcome2 = push_http(
client.clone(),
&local_git,
&url,
&[nonff],
&PushOptions::default(),
&mut NoProgress,
&grit_lib::progress::NeverCancel,
)
.expect("non-ff push_http completes");
assert_eq!(outcome2.results.len(), 1);
let r2 = &outcome2.results[0];
assert!(
r2.status.is_error(),
"non-fast-forward push must be rejected, got {:?}",
r2.status
);
assert!(
matches!(
r2.status,
PushRefStatus::RejectNonFastForward | PushRefStatus::RemoteRejected
),
"expected non-ff/remote rejection, got {:?} ({:?})",
r2.status,
r2.message
);
let remote_main_after = resolve_ref(&bare, "refs/heads/main").expect("remote main still set");
assert_eq!(
remote_main_after, main_oid,
"rejected non-ff push must not move the remote ref"
);
let forced = PushRefSpec {
src: Some(diverged),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: true,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
};
let outcome3 = push_http(
client.clone(),
&local_git,
&url,
&[forced],
&PushOptions::default(),
&mut NoProgress,
&grit_lib::progress::NeverCancel,
)
.expect("forced push_http completes");
assert_eq!(
outcome3.results[0].status,
PushRefStatus::Ok,
"forced non-ff push should be accepted: {:?}",
outcome3.results[0].message
);
let remote_main_forced = resolve_ref(&bare, "refs/heads/main").expect("remote main moved");
assert_eq!(
remote_main_forced, diverged,
"forced push should move the remote ref to the divergent tip"
);
assert_eq!(
remote_main_forced.to_hex(),
git(&bare, &["rev-parse", "refs/heads/main"]).trim()
);
let fsck_forced = Command::new("git")
.current_dir(&bare)
.args(["fsck", "--no-dangling"])
.output()
.expect("run git fsck");
assert!(
fsck_forced.status.success(),
"git fsck failed after forced http push: {}",
String::from_utf8_lossy(&fsck_forced.stderr)
);
}
#[test]
fn push_then_fetch_roundtrip_and_server_side_rejection_over_http() {
let Some(server_bin) = find_binary("grit-http-server") else {
eprintln!("SKIP: `grit-http-server` binary not found (build grit-http-server first)");
return;
};
let tmp = tempfile::tempdir().expect("tempdir");
let local = tmp.path().join("local");
std::fs::create_dir_all(&local).unwrap();
build_source(&local);
let local_git = local.join(".git");
let main_oid = rev_parse(&local, "refs/heads/main");
let topic_oid = rev_parse(&local, "refs/heads/topic");
let c1_oid = rev_parse(&local, "HEAD~1");
let root = tmp.path().join("srv");
std::fs::create_dir_all(&root).unwrap();
let bare = make_bare_target(&root, "rt.git");
let Some(port) = free_port() else {
eprintln!("SKIP: could not allocate a free port");
return;
};
let Some(child) = spawn_server(&server_bin, &root, port) else {
eprintln!("SKIP: could not spawn grit-http-server");
return;
};
let _guard = ServerGuard(child);
if !wait_ready(port) {
eprintln!("SKIP: grit-http-server did not become ready on port {port}");
return;
}
let url = format!("http://127.0.0.1:{port}/rt.git");
let client = http_client_arc(UreqHttpClient::new());
let probe_url = format!("{url}/info/refs?service=git-receive-pack");
match client.get(&probe_url, None) {
Ok(body) if body.windows(20).any(|w| w == b"# service=git-receiv") => {}
Ok(_) => {
eprintln!("SKIP: server returned a non-smart receive-pack advertisement");
return;
}
Err(e) => {
eprintln!("SKIP: server does not offer receive-pack: {e}");
return;
}
}
let specs = [
PushRefSpec {
src: Some(main_oid),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
},
PushRefSpec {
src: Some(topic_oid),
src_ref: None,
dst: "refs/heads/topic".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
},
];
let push_outcome = push_http(
client.clone(),
&local_git,
&url,
&specs,
&PushOptions::default(),
&mut NoProgress,
&grit_lib::progress::NeverCancel,
)
.expect("push_http main+topic over grit-http-server");
for r in &push_outcome.results {
assert_eq!(
r.status,
PushRefStatus::Ok,
"push of {} should be accepted, got {:?} ({:?})",
r.remote_ref,
r.status,
r.message
);
}
assert_eq!(
resolve_ref(&bare, "refs/heads/main").unwrap(),
main_oid,
"remote main mismatch after push"
);
assert_eq!(
resolve_ref(&bare, "refs/heads/topic").unwrap(),
topic_oid,
"remote topic mismatch after push"
);
let back = tmp.path().join("back");
std::fs::create_dir_all(&back).unwrap();
git(&back, &["init", "-q", "-b", "main", "."]);
let back_git = back.join(".git");
let mut fetch_opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
tags: TagMode::None,
..Default::default()
};
let client2 = http_client_arc(UreqHttpClient::new());
let fetch_outcome = http_fetch(
client2.clone(),
&back_git,
&url,
&mut fetch_opts,
&mut NoProgress,
)
.expect("http_fetch back the just-pushed repo");
assert_eq!(
resolve_ref(&back_git, "refs/remotes/origin/main").unwrap(),
main_oid,
"round-trip: fetched origin/main != pushed main"
);
assert_eq!(
resolve_ref(&back_git, "refs/remotes/origin/topic").unwrap(),
topic_oid,
"round-trip: fetched origin/topic != pushed topic"
);
let back_odb = open_odb(&back_git);
for oid in [main_oid, topic_oid, c1_oid] {
assert!(
back_odb.exists(&oid),
"round-trip: object {} missing after fetch-back",
oid.to_hex()
);
}
assert!(
fetch_outcome
.updates
.iter()
.any(|u| u.remote_ref == "refs/heads/main" && u.new_oid == Some(main_oid)),
"round-trip fetch did not report the main update"
);
let fsck = Command::new("git")
.current_dir(&back)
.args(["fsck", "--no-dangling"])
.output()
.expect("run git fsck");
assert!(
fsck.status.success(),
"git fsck failed after fetch-back: {}",
String::from_utf8_lossy(&fsck.stderr)
);
let cfg = Command::new("git")
.current_dir(&bare)
.args(["config", "receive.denyNonFastForwards", "true"])
.output()
.expect("set denyNonFastForwards");
assert!(cfg.status.success(), "failed to set denyNonFastForwards");
git(
&local,
&["checkout", "-q", "-b", "rt-diverge", "refs/heads/main~1"],
);
std::fs::write(local.join("rt.txt"), "rt-diverge\n").unwrap();
git(&local, &["add", "rt.txt"]);
git(&local, &["commit", "-q", "-m", "rt-divergent"]);
let diverged = rev_parse(&local, "HEAD");
assert_ne!(diverged, main_oid);
let forced_spec = PushRefSpec {
src: Some(diverged),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: true, delete: false,
expected_old: Some(main_oid),
expect_absent: false,
..Default::default()
};
let reject_outcome = push_http(
client.clone(),
&local_git,
&url,
&[forced_spec],
&PushOptions::default(),
&mut NoProgress,
&grit_lib::progress::NeverCancel,
)
.expect("forced push against denyNonFastForwards completes");
assert_eq!(reject_outcome.results.len(), 1);
let rr = &reject_outcome.results[0];
assert_eq!(
rr.status,
PushRefStatus::RemoteRejected,
"server-side denyNonFastForwards must surface as RemoteRejected, got {:?} ({:?})",
rr.status,
rr.message
);
assert_eq!(
resolve_ref(&bare, "refs/heads/main").unwrap(),
main_oid,
"server-rejected push must not move the remote ref"
);
}
#[test]
#[ignore = "spawned by http_clone_peak_rss_bounded_for_large_pack via grit-bench measure-rss"]
fn http_fetch_rss_child_process() {
let url = std::env::var("GRIT_HTTP_RSS_URL").expect("GRIT_HTTP_RSS_URL");
let work = PathBuf::from(std::env::var("GRIT_HTTP_RSS_WORK").expect("GRIT_HTTP_RSS_WORK"));
let index_threads = std::env::var("GRIT_HTTP_RSS_INDEX_THREADS")
.ok()
.and_then(|s| s.parse().ok());
std::fs::create_dir_all(&work).expect("work dir");
git(&work, &["init", "-q", "-b", "main", "."]);
let git_dir = work.join(".git");
let mut opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
initial_remote_fetch: true,
remote_name: Some("origin".to_owned()),
index_pack_threads: index_threads.or(Some(1)),
pack_spool_only: true,
..Default::default()
};
let client = http_client_arc(UreqHttpClient::new());
http_fetch(client, &git_dir, &url, &mut opts, &mut NoProgress).expect("http_fetch");
}
#[test]
fn http_clone_peak_rss_bounded_for_large_pack() {
const MEASURE_CMD_ENV: &str = "GRIT_BENCH_MEASURE_CMD";
let Some(server_bin) = find_binary("grit-http-server") else {
eprintln!("SKIP: grit-http-server binary not found");
return;
};
let Some(bench_bin) = find_binary("grit-bench") else {
eprintln!("SKIP: grit-bench binary not found (build grit-utils)");
return;
};
let test_exe = std::env::current_exe().expect("current test exe");
let tmp = tempfile::tempdir().expect("tempdir");
let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
git(&work, &["init", "-q", "-b", "main", "."]);
for i in 0..100 {
let blob = work.join(format!("blob-{i}.bin"));
let status = Command::new("dd")
.args([
"if=/dev/urandom",
&format!("of={}", blob.display()),
"bs=1M",
"count=1",
])
.status()
.expect("dd");
assert!(status.success(), "dd must create blob {i}");
git(&work, &["add", blob.file_name().unwrap().to_str().unwrap()]);
git(&work, &["commit", "-q", "-m", &format!("c{i}")]);
}
let root = tmp.path().join("srv");
std::fs::create_dir_all(&root).unwrap();
let source = root.join("repo.git");
git(
&work,
&[
"clone",
"-q",
"--bare",
".",
source.to_str().expect("utf8 path"),
],
);
git(&source, &["repack", "-adf"]);
git(&source, &["prune-packed"]);
let pack_size = std::fs::read_dir(source.join("objects/pack"))
.expect("pack dir")
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "pack"))
.map(|e| e.metadata().map(|m| m.len()).unwrap_or(0))
.max()
.unwrap_or(0);
assert!(
pack_size >= 90 * 1024 * 1024,
"expected ~100 MiB pack, got {pack_size} bytes"
);
let Some(port) = free_port() else {
eprintln!("SKIP: no free port");
return;
};
let Some(child) = spawn_server(&server_bin, &root, port) else {
eprintln!("SKIP: could not spawn grit-http-server");
return;
};
let _guard = ServerGuard(child);
if !wait_ready(port) {
eprintln!("SKIP: server not ready");
return;
}
let url = format!("http://127.0.0.1:{port}/repo.git");
let work = tmp.path().join("fetch-work");
let measure_cmd = format!(
"{} http_fetch_rss_child_process --ignored --exact --nocapture",
test_exe.display()
);
let tmp_cwd = tmp.path().to_path_buf();
let mut cmd = Command::new(&bench_bin);
cmd.arg("measure-rss")
.arg("--cwd")
.arg(&tmp_cwd)
.env(MEASURE_CMD_ENV, &measure_cmd)
.env("GRIT_HTTP_RSS_URL", &url)
.env("GRIT_HTTP_RSS_WORK", "fetch-work")
.env("GRIT_HTTP_RSS_INDEX_THREADS", "1");
let out = cmd.output().expect("measure-rss");
assert!(
out.status.success(),
"measure-rss failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let peak_rss: u64 = String::from_utf8_lossy(&out.stdout)
.trim()
.parse()
.expect("peak RSS bytes on stdout");
assert!(
peak_rss <= pack_size / 2,
"peak RSS {peak_rss} must not scale with pack size {pack_size} (streaming ingest)"
);
let git_dir = work.join(".git");
assert!(git_dir.is_dir(), "fetch must create a git dir");
let incoming_dir = git_dir.join("objects/incoming");
let spooled: Vec<_> = std::fs::read_dir(&incoming_dir)
.expect("objects/incoming spool dir")
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("pack-receive-") && n.ends_with(".tmp"))
})
.collect();
assert_eq!(
spooled.len(),
1,
"pack_spool_only fetch must leave exactly one pack-receive temp file under the repo"
);
let _ = std::fs::remove_file(spooled[0].path());
}
#[test]
fn fetch_over_git_http_backend_cgi() {
let Some(bench_bin) = find_binary("grit-bench") else {
eprintln!("SKIP: `grit-bench` binary not found (build grit-utils first)");
return;
};
let tmp = tempfile::tempdir().expect("tempdir");
let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
build_source(&work);
let root = tmp.path().join("http-root");
std::fs::create_dir_all(&root).unwrap();
let bare = root.join("repo.git");
git(
&work,
&[
"clone",
"-q",
"--bare",
".",
bare.to_str().expect("utf8 path"),
],
);
git(&bare, &["symbolic-ref", "HEAD", "refs/heads/main"]);
let main_oid = rev_parse(&bare, "refs/heads/main");
let Some(port) = free_port() else {
eprintln!("SKIP: could not allocate a free port");
return;
};
let Ok(child) = Command::new(&bench_bin)
.args([
"serve-git-http",
"--root",
root.to_str().expect("utf8"),
"--bind",
&format!("127.0.0.1:{port}"),
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
else {
eprintln!("SKIP: could not spawn grit-bench serve-git-http");
return;
};
let _guard = ServerGuard(child);
if !wait_ready(port) {
eprintln!("SKIP: git http-backend wrapper did not become ready on port {port}");
return;
}
let local = tmp.path().join("local.git");
git(
tmp.path(),
&["init", "-q", "--bare", local.to_str().expect("utf8")],
);
let url = format!("http://127.0.0.1:{port}/repo.git");
let client = http_client_arc(UreqHttpClient::new());
let upload_url = format!("{url}/git-upload-pack");
let want = format!("want {main_oid} multi_ack thin-pack side-band side-band-64k");
let mut req_body = Vec::new();
grit_lib::remote::pkt_line::write_line_to_vec(&mut req_body, &want).unwrap();
grit_lib::remote::pkt_line::write_flush(&mut req_body).unwrap();
grit_lib::remote::pkt_line::write_line_to_vec(&mut req_body, "done").unwrap();
grit_lib::remote::pkt_line::write_flush(&mut req_body).unwrap();
let post_body = client
.post(
&upload_url,
"application/x-git-upload-pack-request",
"application/x-git-upload-pack-result",
&req_body,
None,
)
.expect("POST git-upload-pack");
assert!(
post_body.starts_with(b"0008"),
"upload-pack body should start with a pkt-line, got {:?}",
&post_body[..post_body.len().min(24)]
);
let mut progress = NoProgress;
let mut fetch_opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".into()],
tags: TagMode::Following,
..Default::default()
};
http_fetch(client.clone(), &local, &url, &mut fetch_opts, &mut progress)
.expect("http_fetch over git http-backend CGI wrapper");
assert_eq!(
resolve_ref(&local, "refs/remotes/origin/main").unwrap(),
main_oid
);
}