use std::net::{TcpListener, TcpStream};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use grit_lib::config::environment::RepositoryOptions;
use grit_lib::objects::odb::Odb;
use grit_lib::objects::ObjectId;
use grit_lib::porcelain::tag::{create_tag, CreateTagRequest};
use grit_lib::progress::NeverCancel;
use grit_lib::refs::resolve_ref;
use grit_lib::remote::fetch::{fetch_remote, NoProgress};
use grit_lib::remote::push::push_remote;
use grit_lib::remote::transfer::{FetchOptions, PushOptions, PushRefSpec, TagMode, UpdateMode};
use grit_lib::remote::transport::{ConnectOptions, GitDaemonTransport, Service, Transport};
use grit_lib::repo::Repository;
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 mut opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
tags: TagMode::All,
..Default::default()
};
let outcome = fetch_remote(
&local_git,
&mut *conn,
&mut opts,
&mut NoProgress,
&NeverCancel,
)
.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)
);
}
#[test]
fn fetch_following_lightweight_tag_at_branch_tip_downloads_branch() {
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", "."]);
std::fs::write(work.join("a"), "a\n").unwrap();
git(&work, &["add", "a"]);
git(&work, &["commit", "-q", "-m", "a"]);
git(&work, &["checkout", "-q", "-b", "rel"]);
std::fs::write(work.join("r"), "r\n").unwrap();
git(&work, &["add", "r"]);
git(&work, &["commit", "-q", "-m", "rel"]);
git(&work, &["tag", "light"]);
git(&work, &["tag", "-a", "-m", "rel1", "rel-1.0"]);
git(&work, &["checkout", "-q", "main"]);
std::fs::write(work.join("b"), "b\n").unwrap();
git(&work, &["add", "b"]);
git(&work, &["commit", "-q", "-m", "b"]);
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 rel_oid = rev_parse(&source, "refs/heads/rel");
let light_oid = rev_parse(&source, "refs/tags/light");
let rel10_oid = rev_parse(&source, "refs/tags/rel-1.0");
assert_eq!(light_oid, rel_oid, "fixture: lightweight tag at rel tip");
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;
}
};
let mut opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
tags: TagMode::Following,
..Default::default()
};
fetch_remote(
&local_git,
&mut *conn,
&mut opts,
&mut NoProgress,
&NeverCancel,
)
.expect("fetch with TagMode::Following");
let got_rel = resolve_ref(&local_git, "refs/remotes/origin/rel").expect("origin/rel");
assert_eq!(got_rel, rel_oid);
assert_eq!(
resolve_ref(&local_git, "refs/tags/light").expect("light tag"),
light_oid
);
assert_eq!(
resolve_ref(&local_git, "refs/tags/rel-1.0").expect("annotated tag"),
rel10_oid
);
let local_odb = open_odb(&local_git);
for oid in [main_oid, rel_oid, light_oid, rel10_oid] {
assert!(
local_odb.exists(&oid),
"object {} missing after fetch",
oid.to_hex()
);
}
let fsck = Command::new("git")
.current_dir(&local)
.args(["fsck", "--no-dangling"])
.output()
.expect("run git fsck");
assert!(
fsck.status.success(),
"git fsck failed (issue #933): {}",
String::from_utf8_lossy(&fsck.stderr)
);
}
#[test]
fn fetch_remote_following_imports_annotated_tag_on_existing_tip() {
let tmp = tempfile::tempdir().expect("tempdir");
let base = tmp.path().join("base");
std::fs::create_dir_all(&base).unwrap();
let source = base.join("repo.git");
std::fs::create_dir_all(&source).unwrap();
git(&source, &["init", "-q", "-b", "main", "."]);
std::fs::write(source.join("README"), b"paired tip\n").unwrap();
git(&source, &["add", "README"]);
git(&source, &["commit", "-q", "-m", "init"]);
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;
}
};
let mut opts = FetchOptions {
refspecs: vec!["+refs/heads/main:refs/remotes/origin/main".to_owned()],
tags: TagMode::Following,
..Default::default()
};
fetch_remote(
&local_git,
&mut *conn,
&mut opts,
&mut NoProgress,
&NeverCancel,
)
.expect("initial fetch");
git(
&source,
&["tag", "-a", "annotated-paired", "-m", "on existing tip"],
);
let mut conn = transport
.connect(&url, Service::UploadPack, &ConnectOptions::default())
.expect("reconnect");
let outcome = fetch_remote(
&local_git,
&mut *conn,
&mut opts,
&mut NoProgress,
&NeverCancel,
)
.expect("incremental fetch with new annotated tag");
let tag_update = outcome
.updates
.iter()
.find(|u| u.remote_ref == "refs/tags/annotated-paired")
.expect("annotated tag update");
assert_eq!(tag_update.mode, UpdateMode::New);
let tag_oid = rev_parse(&source, "refs/tags/annotated-paired");
assert_eq!(
resolve_ref(&local_git, "refs/tags/annotated-paired").expect("tag ref"),
tag_oid
);
assert!(
open_odb(&local_git).exists(&tag_oid),
"annotated tag object must be present locally"
);
}
#[test]
fn push_annotated_tag_on_existing_commit_over_git_daemon() {
let tmp = tempfile::tempdir().expect("tempdir");
let base = tmp.path().join("srv");
std::fs::create_dir_all(&base).unwrap();
let bare = base.join("repo.git");
std::fs::create_dir_all(&bare).unwrap();
git(&bare, &["init", "-q", "--bare", "-b", "main"]);
git(&bare, &["config", "daemon.receivepack", "true"]);
std::fs::write(bare.join("git-daemon-export-ok"), b"").unwrap();
let seed = tmp.path().join("seed");
std::fs::create_dir_all(&seed).unwrap();
git(&seed, &["init", "-q", "-b", "main"]);
std::fs::write(seed.join("f"), "x\n").unwrap();
git(&seed, &["add", "f"]);
git(&seed, &["commit", "-q", "-m", "one"]);
git(
&seed,
&["push", "-q", bare.to_str().expect("utf8 path"), "HEAD:main"],
);
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;
}
};
let mut fetch_opts = FetchOptions {
refspecs: vec!["+refs/heads/main:refs/remotes/origin/main".to_owned()],
tags: TagMode::None,
..Default::default()
};
fetch_remote(
&local_git,
&mut *conn,
&mut fetch_opts,
&mut NoProgress,
&NeverCancel,
)
.expect("fetch main");
let repo = Repository::open(&local_git, &RepositoryOptions::empty()).expect("open local repo");
let head = resolve_ref(&local_git, "refs/remotes/origin/main").expect("origin/main");
let tag_outcome = create_tag(
&repo,
&CreateTagRequest {
name: "v1-ann".to_owned(),
target: head,
message: Some("release".to_owned()),
tagger: Some("T <t@example.com> 946684800 +0000".to_owned()),
sign: None,
signing_key: None,
tree_sha256: None,
force: false,
dry_run: false,
},
)
.expect("annotated tag");
match transport.connect(&url, Service::ReceivePack, &ConnectOptions::default()) {
Ok(_) => {}
Err(e) => {
eprintln!("SKIP: git daemon refuses receive-pack: {e}");
return;
}
}
let mut conn = transport
.connect(&url, Service::ReceivePack, &ConnectOptions::default())
.expect("receive-pack connect");
push_remote(
&local_git,
&mut *conn,
&[PushRefSpec {
src: Some(tag_outcome.oid),
src_ref: None,
dst: "refs/tags/v1-ann".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
}],
&PushOptions::default(),
&mut NoProgress,
&grit_lib::progress::NeverCancel,
)
.expect("push annotated tag");
assert_eq!(
resolve_ref(&bare, "refs/tags/v1-ann").expect("tag ref on remote"),
tag_outcome.oid
);
let cat_type = git(&bare, &["cat-file", "-t", "v1-ann"]);
assert_eq!(
cat_type.trim(),
"tag",
"remote must store annotated tag object"
);
}
#[test]
fn fetch_remote_dry_run_explicit_fast_forward_classifies_without_writing() {
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", "."]);
std::fs::write(work.join("a.txt"), "one\n").unwrap();
git(&work, &["add", "a.txt"]);
git(&work, &["commit", "-q", "-m", "c1"]);
let c1_oid = rev_parse(&work, "HEAD");
std::fs::write(work.join("b.txt"), "two\n").unwrap();
git(&work, &["add", "b.txt"]);
git(&work, &["commit", "-q", "-m", "c2"]);
let base = tmp.path().join("srv");
std::fs::create_dir_all(&base).unwrap();
let bare = base.join("repo.git");
git(
&work,
&[
"clone",
"-q",
"--bare",
".",
bare.to_str().expect("utf8 path"),
],
);
git(&bare, &["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_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;
}
};
let mut seed_opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
tags: TagMode::None,
..Default::default()
};
fetch_remote(
&local_git,
&mut *conn,
&mut seed_opts,
&mut NoProgress,
&NeverCancel,
)
.expect("seed fetch");
git(&local, &["branch", "probe", &c1_oid.to_hex()]);
let probe_before = resolve_ref(&local_git, "refs/heads/probe").expect("probe");
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", bare.to_str().expect("utf8 path"), "HEAD:main"],
);
let mut conn = transport
.connect(&url, Service::UploadPack, &ConnectOptions::default())
.expect("reconnect");
let mut dry_run_opts = FetchOptions {
refspecs: vec!["refs/heads/main:refs/heads/probe".to_owned()],
configured_refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
remote_name: Some("origin".to_owned()),
dry_run: true,
tags: TagMode::None,
..Default::default()
};
let outcome = fetch_remote(
&local_git,
&mut *conn,
&mut dry_run_opts,
&mut NoProgress,
&NeverCancel,
)
.expect("dry-run fetch");
let probe_update = outcome
.updates
.iter()
.find(|u| u.local_ref.as_deref() == Some("refs/heads/probe"))
.expect("probe update");
assert_eq!(
probe_update.mode,
UpdateMode::FastForward,
"probe update: {:?}",
outcome.updates
);
assert_eq!(
resolve_ref(&local_git, "refs/heads/probe").expect("probe after dry-run"),
probe_before
);
}