use std::net::{TcpListener, TcpStream};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use grit_lib::fetch::{fetch_remote, NoProgress};
use grit_lib::objects::ObjectId;
use grit_lib::odb::Odb;
use grit_lib::refs::resolve_ref;
use grit_lib::transfer::{FetchOptions, TagMode, UpdateMode};
use grit_lib::transport::{ConnectOptions, GitDaemonTransport, 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 spawn_daemon(base_path: &Path, port: u16) -> Option<Child> {
Command::new("git")
.arg("daemon")
.arg("--listen=127.0.0.1")
.arg(format!("--port={port}"))
.arg("--reuseaddr")
.arg("--export-all")
.arg(format!("--base-path={}", base_path.display()))
.arg(base_path)
.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(5);
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 DaemonGuard(Child);
impl Drop for DaemonGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[test]
fn fetch_over_git_daemon_lands_refs_and_objects() {
let tmp = tempfile::tempdir().expect("tempdir");
let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
build_source(&work);
let base = tmp.path().join("srv");
std::fs::create_dir_all(&base).unwrap();
let source = base.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 Some(port) = free_port() else {
eprintln!("SKIP: could not allocate a free port");
return;
};
let Some(child) = spawn_daemon(&base, port) else {
eprintln!("SKIP: `git daemon` is unavailable");
return;
};
let _guard = DaemonGuard(child);
if !wait_ready(port) {
eprintln!("SKIP: git daemon did not become ready on port {port}");
return;
}
let url = format!("git://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 transport = GitDaemonTransport::new();
let mut conn = match transport.connect(&url, Service::UploadPack, &ConnectOptions::default()) {
Ok(c) => c,
Err(e) => {
eprintln!("SKIP: could not connect to git daemon: {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()
);
let opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
tags: TagMode::All,
..Default::default()
};
let outcome = fetch_remote(&local_git, &mut *conn, &opts, &mut NoProgress)
.expect("fetch_remote over git daemon");
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 tag_oid = rev_parse(&source, "refs/tags/v1");
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 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 fetch: {}",
String::from_utf8_lossy(&fsck.stderr)
);
}