use std::{
fs,
path::{Path, PathBuf},
process::Command,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
time::{SystemTime, UNIX_EPOCH},
};
use weavatrix_git::{
GitError, HistoryOptions, Limits, MemoryObjectBackend, Object, ObjectId, ObjectKind,
Repository, StatusKind,
};
struct Fixture {
path: PathBuf,
commits: Vec<ObjectId>,
}
static FIXTURE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
impl Fixture {
fn new() -> Self {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!(
"weavatrix-features-{}-{unique}-{}",
std::process::id(),
FIXTURE_SEQUENCE.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&path).unwrap();
git(&path, &["init", "-q"]);
git(&path, &["config", "user.name", "Weavatrix Test"]);
git(&path, &["config", "user.email", "test@weavatrix.local"]);
fs::write(path.join("a.txt"), "a0").unwrap();
fs::write(path.join("b.txt"), "b0").unwrap();
let mut commits = Vec::new();
for revision in 0..3 {
fs::write(path.join("a.txt"), format!("a{revision}")).unwrap();
git(&path, &["add", "-A"]);
git(
&path,
&["commit", "-q", "-m", &format!("revision {revision}")],
);
commits.push(
git_output(&path, &["rev-parse", "HEAD"])
.trim()
.parse()
.unwrap(),
);
}
Self { path, commits }
}
}
impl Drop for Fixture {
fn drop(&mut self) {
fs::remove_dir_all(&self.path).ok();
}
}
#[test]
fn lazy_revwalk_hides_ancestors_and_resets() {
let fixture = Fixture::new();
let repository = Repository::open(&fixture.path).unwrap();
let mut walk = repository.revwalk();
walk.push_head().unwrap().push(fixture.commits[2]).unwrap();
let actual = walk.collect::<Result<Vec<_>, _>>().unwrap();
let mut expected = fixture.commits.clone();
expected.reverse();
assert_eq!(actual, expected);
let mut walk = repository.revwalk();
walk.push_head().unwrap().hide(fixture.commits[0]).unwrap();
let actual = walk.by_ref().collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(actual, vec![fixture.commits[2], fixture.commits[1]]);
walk.reset();
walk.push_ref("HEAD").unwrap().first_parent(true);
assert_eq!(walk.count(), 3);
git(
&fixture.path,
&["branch", "base", &fixture.commits[0].to_string()],
);
let mut walk = repository.revwalk();
walk.push_head().unwrap().hide_ref("base").unwrap();
assert_eq!(walk.count(), 2);
}
#[test]
fn first_parent_history_is_ordered_and_bounded() {
let fixture = Fixture::new();
let repository = Repository::open(&fixture.path).unwrap();
let options = HistoryOptions {
max_commits: 3,
first_parent: true,
..HistoryOptions::default()
};
let mut expected = fixture.commits.clone();
expected.reverse();
assert_eq!(
repository.history_ids(fixture.commits[2], options).unwrap(),
expected
);
let one = HistoryOptions {
max_commits: 1,
first_parent: true,
..HistoryOptions::default()
};
assert_eq!(
repository.history_ids(fixture.commits[2], one).unwrap(),
[fixture.commits[2]]
);
assert_eq!(
repository.history(fixture.commits[2], one).unwrap()[0].id,
fixture.commits[2]
);
let general = HistoryOptions {
max_commits: 1,
..HistoryOptions::default()
};
assert_eq!(
repository.history_ids(fixture.commits[2], general).unwrap(),
[fixture.commits[2]]
);
let limits = Limits {
max_history_commits: 2,
..Limits::default()
};
let repository = Repository::open_with_limits(&fixture.path, limits).unwrap();
let error = repository
.history_ids(
fixture.commits[2],
HistoryOptions {
max_commits: 2,
first_parent: true,
since: Some(i64::MAX),
until: None,
},
)
.unwrap_err();
assert!(matches!(error, GitError::LimitExceeded { .. }));
}
#[test]
fn reads_reflog_and_index_v2_and_v4() {
let fixture = Fixture::new();
let repository = Repository::open(&fixture.path).unwrap();
assert!(repository.status().unwrap().is_empty());
let object = repository.object_shared(fixture.commits[2]).unwrap();
assert!(Arc::ptr_eq(
&object,
&repository.object_shared(fixture.commits[2]).unwrap()
));
let foreign = "00".repeat(32).parse::<ObjectId>().unwrap();
assert!(repository.object_shared(foreign).is_err());
assert!(!repository.contains(foreign));
let reflog = repository.reflog("HEAD").unwrap();
assert_eq!(reflog[0].new_id, fixture.commits[2]);
assert!(reflog[0].message_lossy().contains("revision 2"));
let index = repository.index_shared().unwrap();
assert_eq!(repository.index().unwrap(), *index);
assert!(Arc::ptr_eq(&index, &repository.index_shared().unwrap()));
assert_eq!(index.version(), 2);
assert_eq!(
index
.entries()
.iter()
.map(|entry| entry.path.as_slice())
.collect::<Vec<_>>(),
[b"a.txt".as_slice(), b"b.txt".as_slice()]
);
git(&fixture.path, &["update-index", "--index-version", "4"]);
let refreshed = repository.index_shared().unwrap();
assert_eq!(refreshed.version(), 4);
assert_eq!(refreshed.entries().len(), 2);
assert!(!Arc::ptr_eq(&index, &refreshed));
}
#[test]
fn tracked_status_matches_git_index_and_worktree_layers() {
let fixture = Fixture::new();
fs::write(fixture.path.join("a.txt"), "unstaged").unwrap();
fs::write(fixture.path.join("c.txt"), "staged").unwrap();
git(&fixture.path, &["add", "c.txt"]);
git(&fixture.path, &["rm", "-q", "b.txt"]);
let repository = Repository::open(&fixture.path).unwrap();
let status = repository.status().unwrap();
assert_eq!(status.len(), 3);
let a = status.iter().find(|entry| entry.path == b"a.txt").unwrap();
assert_eq!(a.index, StatusKind::Unmodified);
assert_eq!(a.worktree, StatusKind::Modified);
let b = status.iter().find(|entry| entry.path == b"b.txt").unwrap();
assert_eq!(b.index, StatusKind::Deleted);
let c = status.iter().find(|entry| entry.path == b"c.txt").unwrap();
assert_eq!(c.index, StatusKind::Added);
assert_eq!(c.worktree, StatusKind::Unmodified);
let porcelain = git_output(&fixture.path, &["status", "--porcelain=v1", "-uno"]);
assert!(porcelain.lines().any(|line| line == " M a.txt"));
assert!(porcelain.lines().any(|line| line == "D b.txt"));
assert!(porcelain.lines().any(|line| line == "A c.txt"));
}
#[test]
fn custom_object_backend_precedes_native_storage() {
let fixture = Fixture::new();
let id: ObjectId = "1111111111111111111111111111111111111111".parse().unwrap();
let backend = Arc::new(MemoryObjectBackend::new([Object {
id,
kind: ObjectKind::Blob,
data: b"external evidence".to_vec(),
}]));
let second: ObjectId = "2222222222222222222222222222222222222222".parse().unwrap();
backend.insert(Object {
id: second,
kind: ObjectKind::Blob,
data: b"second".to_vec(),
});
let repository =
Repository::open_with_backends(&fixture.path, Limits::default(), vec![backend]).unwrap();
assert!(repository.contains_checked(id).unwrap());
assert_eq!(repository.object(id).unwrap().data, b"external evidence");
let objects = repository.objects_parallel(&[id, second]);
assert_eq!(objects[1].as_ref().unwrap().data, b"second");
assert!(
repository
.commit_maybe_changed_path(fixture.commits[2], b"a.txt")
.unwrap()
.is_none()
);
assert!(
repository
.bitmap_reachable(fixture.commits[2])
.unwrap()
.is_none()
);
}
#[test]
fn tracked_status_detects_deleted_and_type_changed_files() {
let fixture = Fixture::new();
fs::remove_file(fixture.path.join("a.txt")).unwrap();
fs::remove_file(fixture.path.join("b.txt")).unwrap();
fs::create_dir(fixture.path.join("b.txt")).unwrap();
let repository = Repository::open(&fixture.path).unwrap();
let status = repository.status().unwrap();
let a = status.iter().find(|entry| entry.path == b"a.txt").unwrap();
assert_eq!(a.worktree, StatusKind::Deleted);
let b = status.iter().find(|entry| entry.path == b"b.txt").unwrap();
assert_eq!(b.worktree, StatusKind::TypeChanged);
assert!(repository.reflog("../bad").is_err());
}
fn git(path: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(path)
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn git_output(path: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(path)
.output()
.unwrap();
assert!(output.status.success());
String::from_utf8(output.stdout).unwrap()
}