#![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::error::Result as GritResult;
use grit_lib::fetch::NoProgress;
use grit_lib::objects::ObjectId;
use grit_lib::odb::Odb;
use grit_lib::refs::resolve_ref;
use grit_lib::push::push_http;
use grit_lib::push_report::PushRefStatus;
use grit_lib::transfer::{
FetchOptions, PushOptions, PushRefSpec, TagMode, UpdateMode,
};
use grit_lib::transport::http::ureq_client::UreqHttpClient;
use grit_lib::transport::http::{http_fetch, HttpClient, SmartHttpTransport};
use grit_lib::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 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, grit_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("GUST_BIN", 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(grit_bin) = find_binary("grit") else {
eprintln!("SKIP: `grit` binary not found in target dir (build grit-cli first)");
return;
};
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, &grit_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 = 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 opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
tags: TagMode::All,
..Default::default()
};
let outcome = http_fetch(client.as_ref(), &local_git, &url, &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_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 text = String::from_utf8_lossy(payload);
let header = text.trim_end();
if matches!(
header,
"acknowledgments" | "packfile" | "wanted-refs" | "shallow-info" | "packfile-uris"
) {
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()
}
}
#[test]
fn fetch_over_smart_http_v2_lands_refs_and_objects() {
let Some(grit_bin) = find_binary("grit") else {
eprintln!("SKIP: `grit` binary not found in target dir (build grit-cli first)");
return;
};
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, &grit_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 opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
tags: TagMode::All,
..Default::default()
};
let outcome = http_fetch(recording.as_ref(), &local_git, &url, &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.as_ref(), &local_git, &url, &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)
);
}
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
}
#[test]
fn push_over_smart_http_lands_ref_and_objects_and_reports_rejection() {
let Some(grit_bin) = find_binary("grit") else {
eprintln!("SKIP: `grit` binary not found in target dir (build grit-cli first)");
return;
};
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, &grit_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 = 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),
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
};
let outcome = push_http(
&client,
&local_git,
&url,
&[spec],
&PushOptions::default(),
&mut NoProgress,
)
.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),
dst: "refs/heads/feature".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
};
let outcome_topic = push_http(
&client,
&local_git,
&url,
&[spec_topic],
&PushOptions::default(),
&mut NoProgress,
)
.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),
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
};
let outcome2 = push_http(
&client,
&local_git,
&url,
&[nonff],
&PushOptions::default(),
&mut NoProgress,
)
.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),
dst: "refs/heads/main".to_owned(),
force: true,
delete: false,
expected_old: None,
expect_absent: false,
};
let outcome3 = push_http(
&client,
&local_git,
&url,
&[forced],
&PushOptions::default(),
&mut NoProgress,
)
.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(grit_bin) = find_binary("grit") else {
eprintln!("SKIP: `grit` binary not found in target dir (build grit-cli first)");
return;
};
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, &grit_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 = 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),
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
},
PushRefSpec {
src: Some(topic_oid),
dst: "refs/heads/topic".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
},
];
let push_outcome = push_http(
&client,
&local_git,
&url,
&specs,
&PushOptions::default(),
&mut NoProgress,
)
.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 fetch_opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
tags: TagMode::None,
..Default::default()
};
let client2 = UreqHttpClient::new();
let fetch_outcome = http_fetch(&client2, &back_git, &url, &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),
dst: "refs/heads/main".to_owned(),
force: true, delete: false,
expected_old: Some(main_oid),
expect_absent: false,
};
let reject_outcome = push_http(
&client,
&local_git,
&url,
&[forced_spec],
&PushOptions::default(),
&mut NoProgress,
)
.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"
);
}