use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
process::Command,
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
use weavatrix_git::{ChangeKind, HashKind, HistoryOptions, Repository};
struct Fixture {
path: PathBuf,
commits: Vec<String>,
}
static FIXTURE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
impl Fixture {
fn new(format: Option<&str>, commit_count: usize) -> Option<Self> {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!(
"weavatrix-git-{}-{unique}-{}",
std::process::id(),
FIXTURE_SEQUENCE.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&path).unwrap();
let mut init = vec!["init", "-q"];
let format_arg;
if let Some(format) = format {
format_arg = format!("--object-format={format}");
init.push(&format_arg);
}
if !git_status(&path, &init) {
fs::remove_dir_all(path).ok();
return None;
}
git(&path, &["config", "user.name", "Weavatrix Test"]);
git(&path, &["config", "user.email", "test@weavatrix.local"]);
let mut commits = Vec::new();
for index in 0..commit_count {
let content = format!(
"header\n{}\nrevision={index:04}\nfooter\n",
"shared payload ".repeat(1_000)
);
fs::write(path.join("tracked.txt"), content).unwrap();
if index == 1 {
fs::create_dir_all(path.join("nested")).unwrap();
fs::write(path.join("nested").join("added.txt"), "added").unwrap();
}
if index == 2 {
fs::remove_file(path.join("nested").join("added.txt")).unwrap();
fs::write(path.join("replacement.txt"), "replacement").unwrap();
}
git(&path, &["add", "-A"]);
git(&path, &["commit", "-q", "-m", &format!("revision {index}")]);
commits.push(git(&path, &["rev-parse", "HEAD"]).trim().to_owned());
}
git(&path, &["tag", "-a", "v1", "-m", "fixture tag"]);
Some(Self { path, commits })
}
fn compact(&self) {
git(&self.path, &["pack-refs", "--all"]);
git(&self.path, &["gc", "--aggressive", "--prune=now"]);
}
}
impl Drop for Fixture {
fn drop(&mut self) {
fs::remove_dir_all(&self.path).ok();
}
}
#[test]
fn matches_git_for_loose_history_and_tree_diff() {
let fixture = Fixture::new(None, 4).expect("Git must support SHA-1 repositories");
compare_repository(&fixture, HashKind::Sha1);
}
#[test]
fn matches_git_after_pack_and_delta_compression() {
let fixture = Fixture::new(None, 12).expect("Git must support SHA-1 repositories");
fixture.compact();
let repository = Repository::open(&fixture.path).unwrap();
assert!(repository.pack_count() > 0);
compare_repository(&fixture, HashKind::Sha1);
}
#[test]
fn reads_sha256_repository_when_supported_by_git() {
let Some(fixture) = Fixture::new(Some("sha256"), 3) else {
eprintln!("Git build does not support SHA-256 repositories; skipped");
return;
};
compare_repository(&fixture, HashKind::Sha256);
fixture.compact();
compare_repository(&fixture, HashKind::Sha256);
}
fn compare_repository(fixture: &Fixture, hash: HashKind) {
let repository = Repository::open(&fixture.path).unwrap();
assert_eq!(repository.hash_kind(), hash);
assert!(!repository.git_dir().as_os_str().is_empty());
assert!(!repository.common_dir().as_os_str().is_empty());
assert_eq!(
repository.head().unwrap().target.unwrap().to_string(),
fixture.commits.last().unwrap().as_str()
);
let symbolic = repository.head().unwrap().symbolic.unwrap();
let short_branch = symbolic.rsplit('/').next().unwrap();
assert_eq!(
repository.reference(&symbolic).unwrap().target,
repository.resolve("HEAD").unwrap()
);
assert_eq!(
repository.resolve(short_branch).unwrap(),
repository.resolve("HEAD").unwrap()
);
assert!(repository.reference("../bad").is_err());
compare_history(&repository, fixture);
let tag = repository.tag(repository.resolve("v1").unwrap()).unwrap();
assert_eq!(tag.name, "v1");
let head_commit = repository
.commit(repository.resolve("HEAD").unwrap())
.unwrap();
let tree = repository.tree(head_commit.tree).unwrap();
let blob = tree
.entries
.iter()
.find(|entry| entry.kind == weavatrix_git::EntryKind::Blob)
.unwrap()
.id;
assert_eq!(
repository.object(blob).unwrap().kind,
weavatrix_git::ObjectKind::Blob
);
assert!(repository.commit(blob).is_err());
assert!(
repository
.diff_trees(head_commit.tree, head_commit.tree)
.unwrap()
.is_empty()
);
let old = repository.resolve(&fixture.commits[1]).unwrap();
let new = repository.resolve(&fixture.commits[2]).unwrap();
let actual = repository
.diff_commits(old, new)
.unwrap()
.into_iter()
.map(|change| {
(
String::from_utf8(change.path).unwrap(),
match change.kind {
ChangeKind::Added => "A",
ChangeKind::Deleted => "D",
ChangeKind::Modified | ChangeKind::TypeChanged => "M",
},
)
})
.collect::<BTreeMap<_, _>>();
assert_eq!(
actual,
git_diff(&fixture.path, &fixture.commits[1], &fixture.commits[2])
);
}
fn compare_history(repository: &Repository, fixture: &Fixture) {
let history = repository
.history(
repository.resolve("HEAD").unwrap(),
HistoryOptions {
max_commits: fixture.commits.len(),
..HistoryOptions::default()
},
)
.unwrap();
let actual = history
.iter()
.map(|record| record.id.to_string())
.collect::<Vec<_>>();
let fast = repository
.history_ids(
repository.resolve("HEAD").unwrap(),
HistoryOptions {
max_commits: fixture.commits.len(),
..HistoryOptions::default()
},
)
.unwrap()
.into_iter()
.map(|id| id.to_string())
.collect::<Vec<_>>();
let mut expected = fixture.commits.clone();
expected.reverse();
assert_eq!(actual, expected);
assert_eq!(fast, expected);
assert_eq!(history[0].commit.summary_lossy(), expected_summary(fixture));
let outside = repository
.history(
repository.resolve("HEAD").unwrap(),
HistoryOptions {
max_commits: fixture.commits.len(),
until: Some(-1),
..HistoryOptions::default()
},
)
.unwrap();
assert!(outside.is_empty());
}
fn expected_summary(fixture: &Fixture) -> String {
let count = fixture.commits.len() - 1;
format!("revision {count}")
}
fn git_diff(path: &Path, old: &str, new: &str) -> BTreeMap<String, &'static str> {
git(
path,
&[
"diff-tree",
"--no-commit-id",
"--name-status",
"-r",
old,
new,
],
)
.lines()
.map(|line| {
let (kind, path) = line.split_once('\t').unwrap();
let kind = match kind.as_bytes()[0] {
b'A' => "A",
b'D' => "D",
_ => "M",
};
(path.to_owned(), kind)
})
.collect()
}
fn git(path: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(path)
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap()
}
fn git_status(path: &Path, args: &[&str]) -> bool {
Command::new("git")
.args(args)
.current_dir(path)
.status()
.is_ok_and(|status| status.success())
}