use super::*;
use std::path::Path;
fn git(repo: &Path, args: &[&str]) -> String {
let output = crate::test_git()
.args(args)
.current_dir(repo)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("run git");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_string()
}
fn commit(repo: &Path, name: &str, contents: &str, message: &str) -> String {
std::fs::write(repo.join(name), contents).expect("write file");
git(repo, &["add", "-A"]);
git(repo, &["commit", "-q", "-m", message]);
git(repo, &["rev-parse", "HEAD"])
}
fn open_repo(root: &Path, name: &str) -> anyhow::Result<knot_git::Repo> {
let path = root.join(name);
let output = crate::test_git()
.args([
"init",
"-q",
"-b",
"main",
path.to_str().expect("utf8 path"),
])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git init");
assert!(output.status.success());
Ok(knot_git::Repo::open(path)?)
}
fn ref_named(name: &str, raw: &str, peeled: Option<&str>) -> PublicRef {
PublicRef {
name: name.to_string(),
raw: raw.to_string(),
peeled: peeled.map(|p| p.to_string()),
}
}
#[test]
fn capture_records_symbolic_head_raw_and_peeled_refs() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let repo = open_repo(root.path(), "repo")?;
let path = repo.path().to_path_buf();
let first = commit(&path, "a.txt", "one\n", "first");
git(&path, &["branch", "other"]);
git(&path, &["tag", "light", "HEAD"]);
git(&path, &["tag", "-a", "heavy", "-m", "annotated", "HEAD~0"]);
let captured = capture_repo_state(&repo)?;
let head = &captured.head_capture;
assert_eq!(head.symbolic.as_deref(), Some("refs/heads/main"));
assert!(!head.unborn);
assert_eq!(head.resolved.as_deref(), Some(first.as_str()));
let light = captured
.public_refs
.iter()
.find(|r| r.name == "refs/tags/light")
.expect("lightweight tag captured");
assert_eq!(light.peeled, None, "commit target needs no peeled field");
let heavy = captured
.public_refs
.iter()
.find(|r| r.name == "refs/tags/heavy")
.expect("annotated tag captured");
let heavy_tag_oid = heavy.raw.clone();
assert_ne!(heavy.raw, first, "annotated tag targets the tag object");
assert_eq!(
heavy.peeled.as_deref(),
Some(first.as_str()),
"peeled target resolves to the commit"
);
let snap = RepoSnapshot {
repo_did: RepoDid::new("did:plc:abc").unwrap(),
owner: OwnerDid::new("did:plc:owner").unwrap(),
rkey: RepoRkey::new("abc").unwrap(),
name: None,
head_capture: head.clone(),
public_refs: captured.public_refs.clone(),
cob_refs: Vec::new(),
object_format: captured.object_format,
};
let repointed = RepoSnapshot {
public_refs: snap
.public_refs
.iter()
.map(|r| {
if r.name == "refs/tags/heavy" {
let mut raw = heavy_tag_oid.bytes().collect::<Vec<_>>();
// a fixed char matches the old one 1/16 of the time, which is why this flips it.
raw[0] = if raw[0] == b'f' { b'0' } else { b'f' };
ref_named(
&r.name,
&String::from_utf8(raw).unwrap(),
Some(first.as_str()),
)
} else {
r.clone()
}
})
.collect(),
..snap.clone()
};
assert_ne!(snap.snapshot_identity(), repointed.snapshot_identity());
Ok(())
}
#[test]
fn capture_peels_tags_of_trees_blobs_and_tags() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let repo = open_repo(root.path(), "repo")?;
let path = repo.path().to_path_buf();
commit(&path, "a.txt", "one\n", "first");
let tree = git(&path, &["rev-parse", "HEAD^{tree}"]);
let blob = git(&path, &["rev-parse", "HEAD:a.txt"]);
git(&path, &["tag", "-a", "tree-tag", "-m", "tree", &tree]);
git(&path, &["tag", "blob-light", &blob]);
git(&path, &["tag", "-a", "tag-of-tag", "-m", "tag", "tree-tag"]);
let captured = capture_repo_state(&repo)?;
let peeled = |name: &str| {
captured
.public_refs
.iter()
.find(|r| r.name == format!("refs/tags/{name}"))
.unwrap_or_else(|| panic!("{name} captured"))
.peeled
.clone()
};
assert_eq!(peeled("tree-tag"), Some(tree.clone()));
assert_eq!(
peeled("blob-light"),
None,
"a bare blob has nothing to peel"
);
assert_eq!(peeled("tag-of-tag"), Some(tree));
Ok(())
}
#[test]
fn identity_changes_when_symbolic_head_flips_at_same_commit() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let repo = open_repo(root.path(), "repo")?;
let path = repo.path().to_path_buf();
let first = commit(&path, "a.txt", "one\n", "first");
git(&path, &["branch", "other"]);
let snap = |symbolic: &str| RepoSnapshot {
repo_did: RepoDid::new("did:plc:abc").unwrap(),
owner: OwnerDid::new("did:plc:owner").unwrap(),
rkey: RepoRkey::new("abc").unwrap(),
name: None,
head_capture: HeadCapture {
resolved: Some(first.clone()),
symbolic: Some(symbolic.to_string()),
unborn: false,
},
public_refs: vec![
ref_named("refs/heads/main", &first, None),
ref_named("refs/heads/other", &first, None),
],
cob_refs: Vec::new(),
object_format: knot_types::ObjectFormat::SHA1,
};
let on_main = snap("refs/heads/main");
let on_other = snap("refs/heads/other");
assert_ne!(on_main.snapshot_identity(), on_other.snapshot_identity());
Ok(())
}
#[test]
fn cob_and_keep_refs_are_captured_and_move_the_identity() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let repo = open_repo(root.path(), "repo")?;
let path = repo.path().to_path_buf();
let first = commit(&path, "a.txt", "one\n", "first");
for name in [
"refs/cobs/org.tangled.track.ticket/1",
"refs/tngl/keep/did/patch/1",
"refs/cob-checkpoints/org.tangled.track.ticket/1",
"refs/hidden/x",
"refs/atproto/commit",
] {
git(&path, &["update-ref", name, &first]);
}
let captured = capture_repo_state(&repo)?;
let names: Vec<&str> = captured.cob_refs.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(
names,
[
"refs/cobs/org.tangled.track.ticket/1",
"refs/tngl/keep/did/patch/1"
]
);
let base = RepoSnapshot {
repo_did: RepoDid::new("did:plc:abc").unwrap(),
owner: OwnerDid::new("did:plc:owner").unwrap(),
rkey: RepoRkey::new("abc").unwrap(),
name: None,
head_capture: captured.head_capture,
public_refs: captured.public_refs,
cob_refs: captured.cob_refs,
object_format: captured.object_format,
};
let mut moved = base.clone();
moved.cob_refs[0].1 = "0".repeat(40);
assert_ne!(base.snapshot_identity(), moved.snapshot_identity());
Ok(())
}
#[test]
fn unborn_and_detached_heads_capture_explicitly() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let fresh = open_repo(root.path(), "fresh")?;
let unborn = capture_repo_state(&fresh)?;
assert!(unborn.head_capture.unborn);
assert_eq!(
unborn.head_capture.symbolic.as_deref(),
Some("refs/heads/main")
);
assert_eq!(unborn.head_capture.resolved, None);
let repo = open_repo(root.path(), "repo")?;
let path = repo.path().to_path_buf();
let first = commit(&path, "a.txt", "one\n", "first");
// `repo.path()` is the worktree, and HEAD lives in the git dir.
std::fs::write(repo.git().path().join("HEAD"), format!("{first}\n"))?;
let repo = knot_git::Repo::open(&path)?;
let detached = capture_repo_state(&repo)?;
assert!(!detached.head_capture.unborn);
assert_eq!(detached.head_capture.symbolic, None);
assert_eq!(
detached.head_capture.resolved.as_deref(),
Some(first.as_str())
);
Ok(())
}
#[test]
fn capture_uses_commit_graph_without_reading_commit_objects() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let repo = open_repo(root.path(), "repo")?;
let path = repo.path().to_path_buf();
let first = commit(&path, "a.txt", "one\n", "first");
let second = commit(&path, "b.txt", "two\n", "second");
git(&path, &["branch", "side", &first]);
git(&path, &["tag", "light", &first]);
git(&path, &["commit-graph", "write", "--reachable"]);
// drop `first`'s loose object but keep the commit-graph that still lists it.
let loose = repo
.git()
.path()
.join("objects")
.join(&first[..2])
.join(&first[2..]);
std::fs::remove_file(&loose)?;
let reopened = knot_git::Repo::open(&path)?;
let captured = capture_repo_state(&reopened)?;
assert_eq!(
captured.head_capture.resolved.as_deref(),
Some(second.as_str())
);
let light = captured
.public_refs
.iter()
.find(|r| r.name == "refs/tags/light")
.expect("lightweight tag captured");
assert_eq!(light.raw, first);
assert_eq!(light.peeled, None);
Ok(())
}
/// a foreign commit-graph can list an annotated tag's oid as a commit.
#[test]
fn rewriting_a_planted_commit_graph_keeps_annotated_tags_peeled() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let repo = open_repo(root.path(), "repo")?;
let path = repo.path().to_path_buf();
let first = commit(&path, "a.txt", "one\n", "first");
git(&path, &["tag", "-a", "v1", "-m", "annotated", &first]);
let tag_oid = git(&path, &["rev-parse", "refs/tags/v1"]);
git(&path, &["commit-graph", "write", "--reachable"]);
let graph_path = repo.git().path().join("objects/info/commit-graph");
let mut bytes = std::fs::read(&graph_path)?;
let commit_raw = gix::ObjectId::from_hex(first.as_bytes())?;
let tag_raw = gix::ObjectId::from_hex(tag_oid.as_bytes())?;
let pos = bytes
.windows(commit_raw.as_bytes().len())
.position(|w| w == commit_raw.as_bytes())
.expect("commit oid in graph");
let fanout_start = pos - 256 * 4;
for b in 0..=255usize {
let count = u32::from(b >= usize::from(tag_raw.as_bytes()[0]));
bytes[fanout_start + b * 4..fanout_start + (b + 1) * 4]
.copy_from_slice(&count.to_be_bytes());
}
bytes[pos..pos + tag_raw.as_bytes().len()].copy_from_slice(tag_raw.as_bytes());
std::fs::remove_file(&graph_path)?;
std::fs::write(&graph_path, &bytes)?;
// with the planted graph, `lookup(tag_oid)` hits and skips peeling.
let poisoned = knot_git::Repo::open(&path)?;
let bad = capture_repo_state(&poisoned)?;
let bad_v1 = bad
.public_refs
.iter()
.find(|r| r.name == "refs/tags/v1")
.unwrap();
assert_eq!(bad_v1.peeled, None, "planted graph hid the peel");
crate::commit_graph::rewrite(&path);
let cleaned = knot_git::Repo::open(&path)?;
let good = capture_repo_state(&cleaned)?;
let good_v1 = good
.public_refs
.iter()
.find(|r| r.name == "refs/tags/v1")
.unwrap();
assert_eq!(good_v1.peeled.as_deref(), Some(first.as_str()));
Ok(())
}
#[test]
fn a_later_capture_sees_refs_another_process_moved() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let repo = open_repo(root.path(), "moved")?;
let path = repo.git().workdir().expect("a worktree").to_path_buf();
let first = commit(&path, "a.txt", "one\n", "first");
git(&path, &["branch", "old", &first]);
let names = |captured: &CapturedRepoState| -> Vec<String> {
captured
.public_refs
.iter()
.map(|r| r.name.clone())
.collect()
};
let before = capture_repo_state(&repo)?;
assert_eq!(names(&before), ["refs/heads/main", "refs/heads/old"]);
git(&path, &["branch", "-D", "old"]);
git(&path, &["branch", "new", &first]);
let after = capture_repo_state(&knot_git::Repo::open(&path)?)?;
assert_eq!(names(&after), ["refs/heads/main", "refs/heads/new"]);
Ok(())
}
/// `hideRefs` matches whole path parts, and `secretive` stays public next to `secret`.
#[test]
fn the_public_refs_are_the_ones_upload_pack_advertises() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let repo = open_repo(root.path(), "hidden")?;
let path = repo.git().workdir().expect("a worktree").to_path_buf();
let first = commit(&path, "a.txt", "one\n", "first");
for name in [
"refs/heads/secret/nested",
"refs/heads/secretive",
"refs/heads/quiet",
"refs/tags/v1",
"refs/cobs/org.tangled.repo.issue/x",
"refs/tngl/keep/x",
"refs/hidden/x",
] {
git(&path, &["update-ref", name, &first]);
}
git(
&path,
&["config", "uploadpack.hideRefs", "refs/heads/secret"],
);
git(&path, &["config", "transfer.hideRefs", "refs/heads/quiet"]);
let repo = knot_git::Repo::open(&path)?;
let captured = capture_repo_state(&repo)?;
let names: Vec<&str> = captured
.public_refs
.iter()
.map(|r| r.name.as_str())
.collect();
assert_eq!(
names,
["refs/heads/main", "refs/heads/secretive", "refs/tags/v1"]
);
let advertised: Vec<String> = (repo.advertised_refs_for(knot_git::AdvertScope::Upload)?)
.into_iter()
.map(|record| record.name.as_str().to_string())
.collect();
assert_eq!(names, advertised);
Ok(())
}