use std::path::Path;
use std::process::Command;
use grit_lib::objects::odb::Odb;
use grit_lib::objects::ObjectId;
use grit_lib::refs::gc::{
prune_loose_unreachable, remote_default_branch_local, update_refs, RefTransactionItem,
};
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 {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).expect("utf8 git output")
}
fn git_try(dir: &Path, args: &[&str]) -> (bool, String) {
let out = Command::new("git")
.current_dir(dir)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("run git");
let mut combined = String::from_utf8_lossy(&out.stdout).into_owned();
combined.push_str(&String::from_utf8_lossy(&out.stderr));
(out.status.success(), combined)
}
fn rev_parse(dir: &Path, rev: &str) -> ObjectId {
ObjectId::from_hex(git(dir, &["rev-parse", rev]).trim()).expect("valid oid")
}
fn odb_for(git_dir: &Path) -> Odb {
Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.to_path_buf())
}
#[test]
fn prune_removes_unreachable_keeps_reachable() {
let tmp = tempfile::tempdir().expect("tempdir");
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main", "."]);
let git_dir = repo.join(".git");
std::fs::write(repo.join("a.txt"), "hello\n").unwrap();
git(&repo, &["add", "a.txt"]);
git(&repo, &["commit", "-q", "-m", "c1"]);
let tip = rev_parse(&repo, "refs/heads/main");
let tree = rev_parse(&repo, "refs/heads/main^{tree}");
let blob = rev_parse(&repo, "HEAD:a.txt");
let dangling = write_dangling(&repo, b"unreferenced junk\n");
let odb = odb_for(&git_dir);
for oid in [&tip, &tree, &blob, &dangling] {
assert!(odb.exists(oid), "object {oid} should exist before prune");
}
let stats = prune_loose_unreachable(&odb, &[tip], None).expect("prune");
assert!(
!git_dir
.join("objects")
.join(dangling.loose_prefix())
.join(dangling.loose_suffix())
.exists(),
"dangling object {dangling} should have been pruned"
);
assert!(odb.exists(&tip), "tip kept");
assert!(odb.exists(&tree), "tree kept");
assert!(odb.exists(&blob), "blob kept");
assert_eq!(stats.pruned, 1, "exactly one object pruned");
assert!(stats.kept >= 3, "at least commit+tree+blob kept");
let (ok, out) = git_try(&git_dir, &["fsck", "--strict"]);
assert!(ok, "git fsck not clean after prune: {out}");
}
#[test]
fn prune_respects_grace_window() {
let tmp = tempfile::tempdir().expect("tempdir");
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main", "."]);
let git_dir = repo.join(".git");
std::fs::write(repo.join("a.txt"), "hello\n").unwrap();
git(&repo, &["add", "a.txt"]);
git(&repo, &["commit", "-q", "-m", "c1"]);
let tip = rev_parse(&repo, "refs/heads/main");
let dangling = write_dangling(&repo, b"recent junk\n");
let odb = odb_for(&git_dir);
assert!(odb.exists(&dangling));
let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(3600);
let stats = prune_loose_unreachable(&odb, &[tip], Some(cutoff)).expect("prune");
assert!(
odb.exists(&dangling),
"recent unreachable object must be kept by the grace window"
);
assert_eq!(stats.pruned, 0, "nothing pruned within the grace window");
let cutoff = std::time::SystemTime::now() + std::time::Duration::from_secs(3600);
let stats = prune_loose_unreachable(&odb, &[tip], Some(cutoff)).expect("prune");
assert!(
!odb.exists(&dangling),
"object older than the (future) cutoff must be pruned"
);
assert_eq!(stats.pruned, 1);
}
#[test]
fn default_branch_is_main_for_normal_repo() {
let tmp = tempfile::tempdir().expect("tempdir");
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main", "."]);
let git_dir = repo.join(".git");
std::fs::write(repo.join("a.txt"), "hi\n").unwrap();
git(&repo, &["add", "a.txt"]);
git(&repo, &["commit", "-q", "-m", "c1"]);
let branch = remote_default_branch_local(&git_dir).expect("default branch");
assert_eq!(branch.as_deref(), Some("main"));
let bare = tmp.path().join("bare.git");
git(
tmp.path(),
&[
"clone",
"-q",
"--bare",
repo.to_str().unwrap(),
bare.to_str().unwrap(),
],
);
let branch = remote_default_branch_local(&bare).expect("default branch bare");
assert_eq!(branch.as_deref(), Some("main"));
}
#[test]
fn default_branch_for_empty_bare_repo() {
let tmp = tempfile::tempdir().expect("tempdir");
let bare = tmp.path().join("empty.git");
git(
tmp.path(),
&["init", "-q", "--bare", "-b", "main", bare.to_str().unwrap()],
);
let branch = remote_default_branch_local(&bare).expect("default branch empty bare");
assert_eq!(branch.as_deref(), Some("main"));
}
#[test]
fn update_refs_cas_and_atomicity() {
let tmp = tempfile::tempdir().expect("tempdir");
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main", "."]);
let git_dir = repo.join(".git");
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
git(&repo, &["add", "a.txt"]);
git(&repo, &["commit", "-q", "-m", "c1"]);
let c1 = rev_parse(&repo, "refs/heads/main");
std::fs::write(repo.join("a.txt"), "two\n").unwrap();
git(&repo, &["add", "a.txt"]);
git(&repo, &["commit", "-q", "-m", "c2"]);
let c2 = rev_parse(&repo, "refs/heads/main");
git(&repo, &["update-ref", "refs/heads/to-update", &c1.to_hex()]);
git(&repo, &["update-ref", "refs/heads/to-delete", &c1.to_hex()]);
let bad = vec![
RefTransactionItem {
name: "refs/heads/created".to_owned(),
new_oid: Some(c2),
expected_old: None,
},
RefTransactionItem {
name: "refs/heads/to-update".to_owned(),
new_oid: Some(c2),
expected_old: Some(c1),
},
RefTransactionItem {
name: "refs/heads/to-delete".to_owned(),
new_oid: None,
expected_old: Some(c2),
},
];
let err = update_refs(&git_dir, &bad).expect_err("batch with bad CAS must fail");
let _ = err;
assert!(
remote_ref(&git_dir, "refs/heads/created").is_none(),
"failed batch must not create refs"
);
assert_eq!(
remote_ref(&git_dir, "refs/heads/to-update"),
Some(c1),
"failed batch must not update refs"
);
assert_eq!(
remote_ref(&git_dir, "refs/heads/to-delete"),
Some(c1),
"failed batch must not delete refs"
);
let good = vec![
RefTransactionItem {
name: "refs/heads/created".to_owned(),
new_oid: Some(c2),
expected_old: None,
},
RefTransactionItem {
name: "refs/heads/to-update".to_owned(),
new_oid: Some(c2),
expected_old: Some(c1),
},
RefTransactionItem {
name: "refs/heads/to-delete".to_owned(),
new_oid: None,
expected_old: Some(c1),
},
];
update_refs(&git_dir, &good).expect("good batch applies");
assert_eq!(remote_ref(&git_dir, "refs/heads/created"), Some(c2));
assert_eq!(remote_ref(&git_dir, "refs/heads/to-update"), Some(c2));
assert!(
remote_ref(&git_dir, "refs/heads/to-delete").is_none(),
"to-delete must be gone"
);
let absent_cas = vec![RefTransactionItem {
name: "refs/heads/never".to_owned(),
new_oid: Some(c2),
expected_old: Some(c1),
}];
update_refs(&git_dir, &absent_cas).expect_err("CAS on an absent ref must fail");
assert!(remote_ref(&git_dir, "refs/heads/never").is_none());
let (ok, out) = git_try(&git_dir, &["fsck", "--strict"]);
assert!(ok, "git fsck not clean after ref transaction: {out}");
}
fn remote_ref(git_dir: &Path, refname: &str) -> Option<ObjectId> {
let (ok, out) = git_try(git_dir, &["rev-parse", "--verify", "-q", refname]);
if ok {
ObjectId::from_hex(out.trim()).ok()
} else {
None
}
}
fn write_dangling(repo: &Path, content: &[u8]) -> ObjectId {
let child = Command::new("git")
.current_dir(repo)
.args(["hash-object", "-w", "--stdin"])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.expect("spawn hash-object");
dangling_write(&child, content);
let res = child.wait_with_output().expect("hash-object output");
ObjectId::from_hex(String::from_utf8_lossy(&res.stdout).trim()).expect("dangling oid")
}
fn dangling_write(child: &std::process::Child, content: &[u8]) {
use std::io::Write as _;
child
.stdin
.as_ref()
.expect("stdin")
.write_all(content)
.expect("write stdin");
}