makepad/libs/git/tests/integration_test.rs
Admin f08ef0a5e3 libs: git
Squash of 1 work commits (Sep 9–9):
  842ea96  git: topological log, worktree lifecycle and bounded line diff in libs/git

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:31 +02:00

1485 lines
54 KiB
Rust

//! End-to-end behaviour of the repository API on repositories built with the
//! API itself, with a separately ignored read-only Git diff oracle. Where the old suite asked git to
//! confirm an object, the assertion is now the object id git assigns to the
//! same content (computed independently of this crate's serialisers).
use makepad_git::test_support::{
self, delta_copy, delta_header, delta_insert, pack_all_loose, write_pack_entries, PackEntry,
};
use makepad_git::*;
use std::fs;
use std::path::Path;
fn test_sig() -> Signature {
Signature {
name: "Test".into(),
email: "test@test.com".into(),
timestamp: 1700000000,
tz_offset: "+0000".into(),
}
}
/// `git init`: the layout a fresh repository has before its first commit.
fn init_layout(dir: &Path) {
let git = dir.join(".git");
fs::create_dir_all(git.join("objects")).unwrap();
fs::create_dir_all(git.join("refs/heads")).unwrap();
fs::create_dir_all(git.join("refs/tags")).unwrap();
fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
}
/// Every file under `dir` (except `.git`) as a repository-relative path.
fn worktree_files(dir: &Path, prefix: &str, out: &mut Vec<String>) {
let mut entries: Vec<_> = fs::read_dir(dir).unwrap().map(|e| e.unwrap()).collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let name = entry.file_name().to_string_lossy().to_string();
if name == ".git" {
continue;
}
let rel = if prefix.is_empty() {
name
} else {
format!("{}/{}", prefix, name)
};
if entry.path().is_dir() {
worktree_files(&entry.path(), &rel, out);
} else {
out.push(rel);
}
}
}
/// `git add -A`: stage every file, drop index entries whose file is gone.
fn add_all(repo: &mut Repository, dir: &Path) {
let mut files = Vec::new();
worktree_files(dir, "", &mut files);
for file in &files {
repo.stage_file(file).unwrap();
}
let index = repo.read_index().unwrap();
for entry in index.entries {
if !files.contains(&entry.path) {
repo.unstage_file(&entry.path).unwrap();
}
}
}
/// `git add -A && git commit -m <message>`.
fn commit_all(repo: &mut Repository, dir: &Path, message: &str) -> ObjectId {
add_all(repo, dir);
repo.commit(&format!("{}\n", message), test_sig()).unwrap()
}
/// `git checkout -b <name>`.
fn checkout_new_branch(repo: &mut Repository, name: &str) {
let head = repo.head_oid().unwrap();
repo.create_branch(name, &head).unwrap();
repo.checkout_branch(name).unwrap();
}
/// `git merge <branch> --no-ff -m <message>` for a branch the current branch
/// has not diverged from: a merge commit with both parents on top of the
/// branch's tree.
fn merge_no_ff(repo: &mut Repository, branch: &str, message: &str) -> ObjectId {
let ours = repo.head_oid().unwrap();
let theirs = repo.resolve_ref(&format!("refs/heads/{}", branch)).unwrap();
let theirs_commit = repo.read_commit(&theirs).unwrap();
let merge = repo
.write_commit(&Commit {
tree: theirs_commit.tree,
parents: vec![ours, theirs],
author: test_sig(),
committer: test_sig(),
message: format!("{}\n", message),
})
.unwrap();
let current = repo.current_branch().unwrap().unwrap();
repo.create_branch(&current, &merge).unwrap();
repo.checkout_branch(&current).unwrap();
merge
}
/// Our `git fsck`: every object reachable from HEAD re-hashes to its id.
fn fsck(repo: &mut Repository) {
fn check(repo: &mut Repository, oid: &ObjectId) -> Object {
let object = repo.read_object(oid).unwrap();
assert_eq!(
&makepad_git::oid::hash_object(object.kind.as_str(), &object.data),
oid,
"object {} does not hash to its id",
oid
);
object
}
fn check_tree(repo: &mut Repository, oid: &ObjectId) {
let object = check(repo, oid);
assert_eq!(object.kind, ObjectKind::Tree);
let tree = repo.read_tree(oid).unwrap();
for entry in tree.entries {
if entry.is_tree() {
check_tree(repo, &entry.oid);
} else {
assert_eq!(check(repo, &entry.oid).kind, ObjectKind::Blob);
}
}
}
let head = repo.head_oid().unwrap();
for (oid, commit) in repo.log(&head, 1000).unwrap() {
assert_eq!(check(repo, &oid).kind, ObjectKind::Commit);
check_tree(repo, &commit.tree);
}
}
/// `git init` + file1.txt/file2.txt/subdir/nested.txt + `git add .` +
/// `git commit -m "initial commit"`.
fn make_repo() -> test_support::TempDir {
let dir = test_support::tempdir().unwrap();
init_layout(dir.path());
fs::write(dir.path().join("file1.txt"), "hello\n").unwrap();
fs::write(dir.path().join("file2.txt"), "world\n").unwrap();
fs::create_dir_all(dir.path().join("subdir")).unwrap();
fs::write(dir.path().join("subdir/nested.txt"), "nested\n").unwrap();
let mut repo = Repository::open(dir.path()).unwrap();
commit_all(&mut repo, dir.path(), "initial commit");
dir
}
// ===== Original tests =====
#[test]
fn test_open_and_read_head() {
let dir = make_repo();
let repo = Repository::open(dir.path()).unwrap();
let branch = repo.current_branch().unwrap();
assert!(branch.is_some());
let branch_name = branch.unwrap();
assert!(
branch_name == "main" || branch_name == "master",
"unexpected branch: {}",
branch_name
);
}
#[test]
fn fixture_matches_the_ids_git_assigns() {
// The index-built tree and the commit serialise exactly as git does: the
// ids were computed independently for this content and signature.
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
let head = repo.head_oid().unwrap();
assert_eq!(head.to_hex(), "18be1235587927fcf5b773fd83b97c85507d6b81");
let commit = repo.read_commit(&head).unwrap();
assert_eq!(commit.tree.to_hex(), "2882f69277885874924f338eaf1f6c801e4d7be0");
let tree = repo.read_tree(&commit.tree).unwrap();
let subdir = tree.entries.iter().find(|e| e.name == "subdir").unwrap();
assert_eq!(subdir.oid.to_hex(), "9dfd7d08cef435bccfc5701b5b547c3740a67404");
fsck(&mut repo);
}
#[test]
fn test_read_commit_and_tree() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
let head_oid = repo.head_oid().unwrap();
let commit = repo.read_commit(&head_oid).unwrap();
assert_eq!(commit.message, "initial commit\n");
assert_eq!(commit.parents.len(), 0);
assert_eq!(commit.author.name, "Test");
let tree = repo.read_tree(&commit.tree).unwrap();
assert_eq!(tree.entries.len(), 3);
let file1 = tree.entries.iter().find(|e| e.name == "file1.txt").unwrap();
assert!(file1.is_blob());
let blob_data = repo.read_blob(&file1.oid).unwrap();
assert_eq!(blob_data, b"hello\n");
let subdir = tree.entries.iter().find(|e| e.name == "subdir").unwrap();
assert!(subdir.is_tree());
let sub_tree = repo.read_tree(&subdir.oid).unwrap();
assert_eq!(sub_tree.entries.len(), 1);
assert_eq!(sub_tree.entries[0].name, "nested.txt");
}
#[test]
fn test_write_blob_and_verify() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
let data = b"new content written by makepad-git\n";
let oid = repo.write_blob(data).unwrap();
let read_data = repo.read_blob(&oid).unwrap();
assert_eq!(read_data, data);
// The id git assigns to this content.
assert_eq!(oid.to_hex(), "d9ebf63dec9cbf92245866d90e1f698cec04f916");
}
#[test]
fn test_list_branches() {
let dir = make_repo();
let repo = Repository::open(dir.path()).unwrap();
let head_oid = repo.head_oid().unwrap();
repo.create_branch("feature-a", &head_oid).unwrap();
repo.create_branch("feature-b", &head_oid).unwrap();
let branches = repo.list_branches().unwrap();
let names: Vec<&str> = branches.iter().map(|r| r.name.as_str()).collect();
assert!(names.contains(&"refs/heads/feature-a"));
assert!(names.contains(&"refs/heads/feature-b"));
}
#[test]
fn test_read_index() {
let dir = make_repo();
let repo = Repository::open(dir.path()).unwrap();
let index = repo.read_index().unwrap();
assert_eq!(index.entries.len(), 3);
let paths: Vec<&str> = index.entries.iter().map(|e| e.path.as_str()).collect();
assert!(paths.contains(&"file1.txt"));
assert!(paths.contains(&"file2.txt"));
assert!(paths.contains(&"subdir/nested.txt"));
}
#[test]
fn test_create_commit_programmatically() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
let old_head = repo.head_oid().unwrap();
let new_data = b"modified by makepad-git\n";
let new_oid = repo.write_blob(new_data).unwrap();
let mut index = repo.read_index().unwrap();
for entry in &mut index.entries {
if entry.path == "file1.txt" {
entry.oid = new_oid;
entry.file_size = new_data.len() as u32;
}
}
repo.write_index(&index).unwrap();
let commit_oid = repo
.commit("commit from makepad-git\n", test_sig())
.unwrap();
let log: Vec<String> = repo
.log(&commit_oid, 2)
.unwrap()
.into_iter()
.map(|(_, c)| c.message)
.collect();
assert!(log.contains(&"commit from makepad-git\n".to_string()), "log: {:?}", log);
assert!(log.contains(&"initial commit\n".to_string()), "log: {:?}", log);
let new_commit = repo.read_commit(&commit_oid).unwrap();
assert_eq!(new_commit.parents.len(), 1);
assert_eq!(new_commit.parents[0], old_head);
let tree = repo.read_tree(&new_commit.tree).unwrap();
let file1 = tree.entries.iter().find(|e| e.name == "file1.txt").unwrap();
assert_eq!(file1.oid, new_oid);
}
#[test]
fn test_log_walk() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
fs::write(dir.path().join("file1.txt"), "updated\n").unwrap();
commit_all(&mut repo, dir.path(), "second commit");
let head = repo.head_oid().unwrap();
let log = repo.log(&head, 10).unwrap();
assert_eq!(log.len(), 2);
assert_eq!(log[0].1.message, "second commit\n");
assert_eq!(log[1].1.message, "initial commit\n");
}
#[test]
fn test_read_from_packed_repo() {
let dir = make_repo();
// What `git gc` does to a small repository: every object into one pack.
pack_all_loose(&dir.path().join(".git")).unwrap();
let mut repo = Repository::open(dir.path()).unwrap();
let head_oid = repo.head_oid().unwrap();
let commit = repo.read_commit(&head_oid).unwrap();
assert_eq!(commit.message, "initial commit\n");
let tree = repo.read_tree(&commit.tree).unwrap();
assert_eq!(tree.entries.len(), 3);
let file1 = tree.entries.iter().find(|e| e.name == "file1.txt").unwrap();
let blob = repo.read_blob(&file1.oid).unwrap();
assert_eq!(blob, b"hello\n");
}
// ===== Status Tests =====
#[test]
fn test_status_clean() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
let status = repo.status().unwrap();
// No modified/untracked files — but stat cache may differ
// Filter to only meaningful statuses
let non_modified: Vec<_> = status
.entries
.iter()
.filter(|e| e.status != FileStatus::Modified)
.collect();
// Should have no staged, deleted, or untracked
for entry in &non_modified {
assert!(
entry.status != FileStatus::Untracked,
"unexpected untracked: {}",
entry.path
);
}
}
#[test]
fn test_status_untracked_file() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
fs::write(dir.path().join("new_file.txt"), "I'm new!\n").unwrap();
let status = repo.status().unwrap();
let untracked: Vec<_> = status
.entries
.iter()
.filter(|e| e.status == FileStatus::Untracked)
.collect();
assert!(
untracked.iter().any(|e| e.path == "new_file.txt"),
"new_file.txt should be untracked, got: {:?}",
untracked
);
}
#[test]
fn test_status_deleted_file() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
fs::remove_file(dir.path().join("file1.txt")).unwrap();
let status = repo.status().unwrap();
let deleted: Vec<_> = status
.entries
.iter()
.filter(|e| e.status == FileStatus::Deleted)
.collect();
assert!(
deleted.iter().any(|e| e.path == "file1.txt"),
"file1.txt should be deleted, got: {:?}",
deleted
);
}
#[test]
fn test_status_modified_file() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
fs::write(dir.path().join("file1.txt"), "modified content\n").unwrap();
let status = repo.status().unwrap();
let modified: Vec<_> = status
.entries
.iter()
.filter(|e| e.status == FileStatus::Modified && e.path == "file1.txt")
.collect();
assert!(!modified.is_empty(), "file1.txt should be modified");
}
// ===== Stage/Unstage Tests =====
fn status_of(repo: &mut Repository, path: &str) -> Option<FileStatus> {
repo.status()
.unwrap()
.entries
.into_iter()
.find(|e| e.path == path)
.map(|e| e.status)
}
#[test]
fn test_stage_new_file() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
fs::write(dir.path().join("staged.txt"), "staged content\n").unwrap();
repo.stage_file("staged.txt").unwrap();
// `A staged.txt`
assert_eq!(status_of(&mut repo, "staged.txt"), Some(FileStatus::StagedNew));
// The index we wrote reads back and carries the blob we stored.
let index = repo.read_index().unwrap();
let entry = index.entries.iter().find(|e| e.path == "staged.txt").unwrap();
assert_eq!(repo.read_blob(&entry.oid).unwrap(), b"staged content\n");
}
#[test]
fn test_stage_modified_file() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
fs::write(dir.path().join("file1.txt"), "changed\n").unwrap();
repo.stage_file("file1.txt").unwrap();
// `M file1.txt`
assert_eq!(status_of(&mut repo, "file1.txt"), Some(FileStatus::Staged));
assert!(repo.status().is_ok());
}
#[test]
fn test_unstage_file() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
repo.unstage_file("file1.txt").unwrap();
let index = repo.read_index().unwrap();
assert!(
!index.entries.iter().any(|e| e.path == "file1.txt"),
"file1.txt should be removed from index"
);
// `D file1.txt`: in HEAD, removed from the index (the file itself is
// still in the worktree and therefore also untracked).
let statuses: Vec<FileStatus> = repo
.status()
.unwrap()
.entries
.into_iter()
.filter(|e| e.path == "file1.txt")
.map(|e| e.status)
.collect();
assert!(
statuses.contains(&FileStatus::StagedDeleted),
"status: {:?}",
statuses
);
}
// ===== Diff Tests =====
type ChangeTuple = (String, Option<(ObjectId, u32)>, Option<(ObjectId, u32)>);
fn legacy_records(changes: Vec<TreeChange>) -> Vec<ChangeTuple> {
changes.into_iter().map(|change| match change {
TreeChange::Added { path, oid, mode } => (path, None, Some((oid, mode))),
TreeChange::Deleted { path, oid, mode } => (path, Some((oid, mode)), None),
TreeChange::Modified { path, old_oid, new_oid, old_mode, new_mode } => (path, Some((old_oid, old_mode)), Some((new_oid, new_mode))),
}).collect()
}
fn this_repository_pairs(repo: &mut Repository) -> Vec<(ObjectId, ObjectId)> {
let head = repo.head_oid().unwrap();
let mut target = head;
let mut pairs = Vec::new();
for _ in 0..24 {
let commit = repo.read_commit(&target).unwrap();
let parent = commit.first_parent().expect("this repository must have 24 first-parent pairs");
pairs.push((parent, target));
target = parent;
}
// Also cover arbitrary endpoint distance and reversal.
pairs.push((target, head));
pairs.push((head, target));
let history = repo.log(&head, usize::MAX).unwrap();
if let Some((oid, commit)) = history.iter().find(|(_, commit)| commit.parents.len() > 1) {
pairs.push((commit.parents[0], *oid));
eprintln!("bounded-tree real merge: {oid}, first parent {}", commit.parents[0]);
}
eprintln!("bounded-tree real repository: HEAD={head}, history={}, pairs={}", history.len(), pairs.len());
pairs
}
#[test]
fn bounded_tree_diff_matches_real_repository_commits() {
let mut repo = Repository::open(Path::new(env!("CARGO_MANIFEST_DIR"))).unwrap();
let pairs = this_repository_pairs(&mut repo);
assert!(pairs.len() >= 24);
let account = memory::MemoryAccount::new(64 * 1024 * 1024);
let limits = TreeDiffLimits::default();
let mut total = TreeDiffCounters::default();
let mut records = 0;
let mut elapsed = std::time::Duration::ZERO;
for (old, new) in pairs {
let before = repo.read_stats().blob_reads;
let start = std::time::Instant::now();
let bounded = repo.diff_commits_bounded(Some(old), Some(new), &limits, &account, &|| false).unwrap();
elapsed += start.elapsed();
assert!(bounded.complete, "{old}..{new}: {:?}", bounded.reason);
assert_eq!(bounded.omitted_records, 0);
assert_eq!(repo.read_stats().blob_reads, before);
let legacy = legacy_records(repo.diff_commits(&old, &new).unwrap());
let actual: Vec<_> = bounded.records.iter().map(|r| (r.path.clone(), r.old, r.new)).collect();
assert_eq!(actual, legacy, "{old}..{new}");
total.trees_decoded += bounded.counters.trees_decoded;
total.entries_visited += bounded.counters.entries_visited;
total.object_bytes += bounded.counters.object_bytes;
total.bytes_read += bounded.counters.bytes_read;
total.scratch_peak_bytes = total.scratch_peak_bytes.max(bounded.counters.scratch_peak_bytes);
total.result_bytes = total.result_bytes.max(bounded.counters.result_bytes);
records += bounded.records.len();
drop(bounded);
assert_eq!(account.reserved(), 0);
}
eprintln!("bounded-tree real totals: records={records}, counters={total:?}, account_peak={}, time_ms={:.3}, blob_reads={}", account.peak(), elapsed.as_secs_f64() * 1000.0, repo.read_stats().blob_reads);
}
#[test]
#[ignore = "read-only external Git name-status oracle on this repository"]
fn bounded_tree_diff_git_name_status_oracle() {
let mut repo = Repository::open(Path::new(env!("CARGO_MANIFEST_DIR"))).unwrap();
let pairs = this_repository_pairs(&mut repo);
let account = memory::MemoryAccount::new(64 * 1024 * 1024);
let mut records = 0;
for (old, new) in pairs {
let bounded = repo.diff_commits_bounded(Some(old), Some(new), &TreeDiffLimits::default(), &account, &|| false).unwrap();
assert!(bounded.complete);
let output = std::process::Command::new("git").current_dir(&repo.workdir)
.args(["diff", "--name-status", "--no-renames", "--no-ext-diff", "--no-textconv", "-z", &old.to_hex(), &new.to_hex(), "--"])
.output().expect("Git must be available for the ignored oracle");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let fields: Vec<_> = output.stdout.split(|b| *b == 0).filter(|s| !s.is_empty()).collect();
assert_eq!(fields.len() % 2, 0);
let expected: Vec<_> = fields.chunks_exact(2).map(|p| (std::str::from_utf8(p[0]).unwrap(), std::str::from_utf8(p[1]).unwrap())).collect();
let actual: Vec<_> = bounded.records.iter().map(|r| (match r.kind {
TreeChangeKind::Added => "A", TreeChangeKind::Deleted => "D", TreeChangeKind::TypeChanged => "T",
TreeChangeKind::Modified | TreeChangeKind::ModeOnly => "M",
}, r.path.as_str())).collect();
assert_eq!(actual, expected, "{old}..{new}");
records += actual.len();
}
eprintln!("bounded-tree Git oracle matched {records} records");
}
#[test]
fn bounded_commit_root_empty_and_missing_parent() {
let dir = test_support::tempdir().unwrap();
init_layout(dir.path());
let mut repo = Repository::open(dir.path()).unwrap();
let blob = repo.write_blob(b"root file").unwrap();
let tree = repo.write_tree(&Tree { entries: vec![TreeEntry { name: "root".into(), mode: 0o100644, oid: blob }] }).unwrap();
let root = repo.write_commit(&Commit { tree, parents: vec![], author: test_sig(), committer: test_sig(), message: "Root\n\nStudio-Local-Checkpoint: true\n".into() }).unwrap();
let account = memory::MemoryAccount::new(1024 * 1024);
let limits = TreeDiffLimits::default();
assert_eq!(repo.read_commit(&root).unwrap().first_parent(), None);
for (old, new, kind) in [(None, Some(root), TreeChangeKind::Added), (Some(root), None, TreeChangeKind::Deleted)] {
let result = repo.diff_commits_bounded(old, new, &limits, &account, &|| false).unwrap();
assert!(result.complete);
assert_eq!(result.records.len(), 1);
assert_eq!(result.records[0].kind, kind);
assert_eq!(result.records[0].path, "root");
}
assert!(matches!(repo.diff_commits_bounded(Some(ObjectId::ZERO), Some(root), &limits, &account, &|| false), Err(GitError::ObjectNotFound(_))));
assert!(matches!(repo.diff_commits_bounded(None, Some(root), &limits, &account, &|| true), Err(GitError::Cancelled)));
let refused = repo.diff_commits_bounded(None, Some(root), &TreeDiffLimits { max_scratch_bytes: 0, ..limits }, &account, &|| false).unwrap();
assert_eq!(refused.reason, Some(TreeDiffExhaustion::ScratchBytes));
assert_eq!(refused.omitted_records, 0);
assert!(!refused.omitted_records_exact);
assert_eq!(repo.read_stats().blob_reads, 0);
}
#[test]
fn bounded_tree_packed_ofs_and_ref_delta_and_alternates() {
let dir = test_support::tempdir().unwrap();
init_layout(dir.path());
let mut repo = Repository::open(dir.path()).unwrap();
let bytes = |name: &str| tree::serialize_tree(&Tree { entries: vec![TreeEntry { name: name.into(), mode: 0o100644, oid: ObjectId::ZERO }] });
let base = bytes("base");
let first = bytes("first");
let second = bytes("second");
let base_oid = oid::hash_object("tree", &base);
let delta = |result: &[u8]| { let mut delta = delta_header(base.len(), result.len()); delta.extend(delta_insert(result)); delta };
let ids = write_pack_entries(&repo.git_dir, &[
PackEntry::Full(ObjectKind::Tree, base.clone()),
PackEntry::OfsDelta { kind: ObjectKind::Tree, base_index: 0, delta: delta(&first), result: first },
PackEntry::RefDelta { kind: ObjectKind::Tree, base: base_oid, delta: delta(&second), result: second },
]).unwrap();
let account = memory::MemoryAccount::new(1024 * 1024);
for oid in &ids {
let result = repo.diff_trees_bounded(None, Some(*oid), &TreeDiffLimits::default(), &account, &|| false).unwrap();
assert!(result.complete);
assert_eq!(result.records.len(), 1);
assert!(result.counters.bytes_read > 0);
}
let alternate = test_support::tempdir().unwrap();
init_layout(alternate.path());
let mut other = Repository::open(alternate.path()).unwrap();
fs::create_dir_all(other.git_dir.join("objects/info")).unwrap();
fs::write(other.git_dir.join("objects/info/alternates"), format!("{}\n", repo.git_dir.join("objects").display())).unwrap();
let result = other.diff_trees_bounded(Some(ids[1]), Some(ids[2]), &TreeDiffLimits::default(), &account, &|| false).unwrap();
assert!(result.complete);
assert_eq!(result.records.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(), ["first", "second"]);
assert_eq!(other.read_stats().blob_reads, 0);
// A small compressed delta can announce an enormous result. Admission
// must reject that size before allocation, even with a tiny base tree.
let mut enormous = delta_header(base.len(), 8 * 1024 * 1024);
enormous.extend(delta_insert(b"x"));
let oversized = write_pack_entries(&repo.git_dir, &[
PackEntry::Full(ObjectKind::Tree, base.clone()),
PackEntry::RefDelta { kind: ObjectKind::Tree, base: base_oid, delta: enormous, result: bytes("oversized") },
]).unwrap()[1];
let limits = TreeDiffLimits { max_scratch_bytes: 128 * 1024, ..Default::default() };
let refused = repo.diff_trees_bounded(None, Some(oversized), &limits, &account, &|| false).unwrap();
assert_eq!(refused.reason, Some(TreeDiffExhaustion::ScratchBytes));
assert_eq!(refused.counters.trees_decoded, 0);
assert_eq!(refused.omitted_subtrees, 1);
assert!(!refused.omitted_records_exact);
assert!(refused.counters.scratch_peak_bytes <= limits.max_scratch_bytes);
}
#[test]
fn test_diff_trees_add_delete_modify() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
let first_head = repo.head_oid().unwrap();
// Modify file1, delete file2, add file3
fs::write(dir.path().join("file1.txt"), "modified hello\n").unwrap();
fs::remove_file(dir.path().join("file2.txt")).unwrap();
fs::write(dir.path().join("file3.txt"), "new file\n").unwrap();
commit_all(&mut repo, dir.path(), "changes");
let second_head = repo.head_oid().unwrap();
let changes = repo.diff_commits(&first_head, &second_head).unwrap();
let paths: Vec<_> = changes
.iter()
.map(|c| match c {
TreeChange::Added { path, .. } => format!("+{}", path),
TreeChange::Deleted { path, .. } => format!("-{}", path),
TreeChange::Modified { path, .. } => format!("M{}", path),
})
.collect();
assert!(paths.contains(&"+file3.txt".to_string()), "changes: {:?}", paths);
assert!(paths.contains(&"-file2.txt".to_string()), "changes: {:?}", paths);
assert!(paths.contains(&"Mfile1.txt".to_string()), "changes: {:?}", paths);
}
#[test]
fn test_diff_blobs_produces_correct_hunks() {
let old = "line1\nline2\nline3\nline4\nline5\n";
let new = "line1\nmodified\nline3\nnew line\nline4\nline5\n";
let diff = diff_blobs(
old.as_bytes(),
new.as_bytes(),
Some("test.txt".into()),
Some("test.txt".into()),
None,
None,
);
let unified = format_unified_diff(&diff, 3);
assert!(unified.contains("--- a/test.txt"));
assert!(unified.contains("+++ b/test.txt"));
assert!(unified.contains("-line2"));
assert!(unified.contains("+modified"));
assert!(unified.contains("+new line"));
}
// ===== Merge Tests =====
#[test]
fn test_merge_fast_forward() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
let main_branch = repo.current_branch().unwrap().unwrap();
// Create a branch at current HEAD
let head = repo.head_oid().unwrap();
repo.create_branch("feature", &head).unwrap();
// Add a commit on the current branch
fs::write(dir.path().join("file1.txt"), "updated on main\n").unwrap();
commit_all(&mut repo, dir.path(), "update on main");
// Switch to feature branch (which is behind)
repo.checkout_branch("feature").unwrap();
// Now merge main into feature — this should fast-forward
let result = repo.merge_branch(&main_branch, test_sig()).unwrap();
assert!(!result.has_conflict(), "expected fast-forward, got conflict");
assert!(
result.content().contains("Fast-forward"),
"result: {}",
result.content()
);
// The repository is consistent afterwards.
assert!(repo.status().is_ok());
fsck(&mut repo);
assert_eq!(
fs::read_to_string(dir.path().join("file1.txt")).unwrap(),
"updated on main\n"
);
}
#[test]
fn test_merge_base_of_diverged_branches() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
let base_oid = repo.head_oid().unwrap();
// Create branch and commit on it
checkout_new_branch(&mut repo, "feature");
fs::write(dir.path().join("feature.txt"), "feature work\n").unwrap();
let feature_oid = commit_all(&mut repo, dir.path(), "feature commit");
assert_eq!(repo.head_oid().unwrap(), feature_oid);
// Go back to main and commit
repo.checkout_branch("main").unwrap();
fs::write(dir.path().join("main.txt"), "main work\n").unwrap();
commit_all(&mut repo, dir.path(), "main commit");
let main_oid = repo.head_oid().unwrap();
// Find merge base using our API
let mb = repo.merge_base(&main_oid, &feature_oid).unwrap();
assert_eq!(mb, Some(base_oid));
}
#[test]
fn test_merge_clean_no_conflict() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
// Create feature branch
checkout_new_branch(&mut repo, "feature");
fs::write(dir.path().join("feature.txt"), "feature content\n").unwrap();
commit_all(&mut repo, dir.path(), "add feature.txt");
// Go back to main and make a different change
repo.checkout_branch("main").unwrap();
fs::write(dir.path().join("main.txt"), "main content\n").unwrap();
commit_all(&mut repo, dir.path(), "add main.txt");
// Merge feature into main using our API
let result = repo.merge_branch("feature", test_sig()).unwrap();
assert!(
!result.has_conflict(),
"expected clean merge, got: {}",
result.content()
);
// The result is consistent
fsck(&mut repo);
assert!(repo.status().is_ok());
// Verify merge commit has two parents
let head = repo.head_oid().unwrap();
let merge_commit = repo.read_commit(&head).unwrap();
assert_eq!(merge_commit.parents.len(), 2, "merge commit should have 2 parents");
// The log shows the merge
let log: Vec<String> = repo.log(&head, 5).unwrap().into_iter().map(|(_, c)| c.message).collect();
assert!(log.iter().any(|m| m.contains("Merge branch")), "log: {:?}", log);
// Verify the worktree has both files
assert!(dir.path().join("feature.txt").exists());
assert!(dir.path().join("main.txt").exists());
}
#[test]
fn test_merge_conflict_same_file() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
// Create feature branch and modify file1.txt
checkout_new_branch(&mut repo, "feature");
fs::write(dir.path().join("file1.txt"), "feature version\n").unwrap();
commit_all(&mut repo, dir.path(), "feature: modify file1");
// Go back to main and make a conflicting change to file1.txt
repo.checkout_branch("main").unwrap();
fs::write(dir.path().join("file1.txt"), "main version\n").unwrap();
commit_all(&mut repo, dir.path(), "main: modify file1");
// Merge feature into main using our API — should conflict
let result = repo.merge_branch("feature", test_sig()).unwrap();
assert!(
result.has_conflict(),
"expected conflict, got: {}",
result.content()
);
// Verify MERGE_HEAD exists
assert!(
dir.path().join(".git/MERGE_HEAD").exists(),
"MERGE_HEAD should exist during conflict"
);
// Verify the file has conflict markers
let content = fs::read_to_string(dir.path().join("file1.txt")).unwrap();
assert!(content.contains("<<<<<<<"), "should have conflict markers: {}", content);
assert!(content.contains("======="), "should have conflict markers: {}", content);
assert!(content.contains(">>>>>>>"), "should have conflict markers: {}", content);
// Verify the index has conflict entries (stages 1-3)
let index = repo.read_index().unwrap();
let conflict_entries: Vec<_> = index
.entries
.iter()
.filter(|e| e.path == "file1.txt" && e.stage() > 0)
.collect();
assert!(!conflict_entries.is_empty(), "should have conflict stages in index");
}
// ===== Branch Checkout Tests =====
#[test]
fn test_checkout_branch_switches_files() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
let main_branch = repo.current_branch().unwrap().unwrap();
// Create a feature branch with a new file
let head = repo.head_oid().unwrap();
repo.create_branch("feature", &head).unwrap();
repo.checkout_branch("feature").unwrap();
fs::write(dir.path().join("feature_only.txt"), "feature\n").unwrap();
commit_all(&mut repo, dir.path(), "add feature_only.txt");
// File should exist
assert!(dir.path().join("feature_only.txt").exists());
// Checkout main using our API
repo.checkout_branch(&main_branch).unwrap();
// File should be gone
assert!(
!dir.path().join("feature_only.txt").exists(),
"feature_only.txt should not exist on main"
);
// Verify HEAD is now on main
let current = repo.current_branch().unwrap().unwrap();
assert_eq!(current, main_branch);
// Consistent afterwards
assert!(repo.status().is_ok());
fsck(&mut repo);
}
#[test]
fn test_checkout_branch_restores_content() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
let main_branch = repo.current_branch().unwrap().unwrap();
// Modify file on a new branch
checkout_new_branch(&mut repo, "modify");
fs::write(dir.path().join("file1.txt"), "modified on branch\n").unwrap();
commit_all(&mut repo, dir.path(), "modify file1");
// Checkout back to main via our API
repo.checkout_branch(&main_branch).unwrap();
// File should have original content
let content = fs::read_to_string(dir.path().join("file1.txt")).unwrap();
assert_eq!(content, "hello\n", "file1.txt should be restored to original");
assert!(repo.status().is_ok());
}
// ===== Packed Refs Tests =====
#[test]
fn test_packed_refs_branches() {
let dir = make_repo();
let repo = Repository::open(dir.path()).unwrap();
let head = repo.head_oid().unwrap();
// Create branches and pack them (`git pack-refs --all`)
repo.create_branch("packed-a", &head).unwrap();
repo.create_branch("packed-b", &head).unwrap();
let git_dir = dir.path().join(".git");
let mut packed = String::from("# pack-refs with: peeled fully-peeled sorted\n");
for name in ["main", "packed-a", "packed-b"] {
packed.push_str(&format!("{} refs/heads/{}\n", head.to_hex(), name));
fs::remove_file(git_dir.join("refs/heads").join(name)).unwrap();
}
fs::write(git_dir.join("packed-refs"), packed).unwrap();
// Our API should still find them
let branches = repo.list_branches().unwrap();
let names: Vec<&str> = branches.iter().map(|r| r.name.as_str()).collect();
assert!(names.contains(&"refs/heads/packed-a"), "branches: {:?}", names);
assert!(names.contains(&"refs/heads/packed-b"), "branches: {:?}", names);
// Resolve should work too
let resolved = repo.resolve_ref("refs/heads/packed-a").unwrap();
assert_eq!(resolved, head);
assert_eq!(repo.head_oid().unwrap(), head);
}
// ===== Multiple Commits + History =====
#[test]
fn test_long_history_walk() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
// Create 10 additional commits
for i in 1..=10 {
fs::write(dir.path().join("file1.txt"), format!("version {}\n", i)).unwrap();
commit_all(&mut repo, dir.path(), &format!("commit {}", i));
}
let head = repo.head_oid().unwrap();
let log = repo.log(&head, 100).unwrap();
assert_eq!(log.len(), 11, "should have 11 commits (initial + 10)");
assert_eq!(log[0].1.message, "commit 10\n");
assert_eq!(log[10].1.message, "initial commit\n");
}
#[test]
fn test_diverged_history_walk() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
// Create feature branch with commits
checkout_new_branch(&mut repo, "feature");
for i in 1..=3 {
fs::write(dir.path().join("feature.txt"), format!("v{}\n", i)).unwrap();
commit_all(&mut repo, dir.path(), &format!("feature {}", i));
}
// Merge back to main
repo.checkout_branch("main").unwrap();
merge_no_ff(&mut repo, "feature", "merge feature");
// Walk the merge commit
let head = repo.head_oid().unwrap();
let log = repo.log(&head, 100).unwrap();
// Should see: merge commit, feature 3, feature 2, feature 1, initial (+ any main commits)
assert!(log.len() >= 5, "log should have at least 5 commits, got {}", log.len());
assert_eq!(log[0].1.message, "merge feature\n");
assert_eq!(log[0].1.parents.len(), 2, "merge commit should have 2 parents");
}
// ===== Write Objects and Verify Against Git's Ids =====
#[test]
fn test_write_tree_matches_git() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
// Write some blobs
let blob_a = repo.write_blob(b"aaa\n").unwrap();
let blob_b = repo.write_blob(b"bbb\n").unwrap();
assert_eq!(blob_a.to_hex(), "72943a16fb2c8f38f9dde202b7a70ccc19c52f34");
assert_eq!(blob_b.to_hex(), "f761ec192d9f0dca3329044b96ebdb12839dbff6");
// Build a tree
let tree = Tree {
entries: vec![
TreeEntry {
mode: 0o100644,
name: "a.txt".into(),
oid: blob_a,
},
TreeEntry {
mode: 0o100644,
name: "b.txt".into(),
oid: blob_b,
},
],
};
let tree_oid = repo.write_tree(&tree).unwrap();
// The id git assigns to this tree
assert_eq!(tree_oid.to_hex(), "65b70c81bbedd324eb1d79c90a72ea2bddae82b4");
let read = repo.read_tree(&tree_oid).unwrap();
let names: Vec<&str> = read.entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, ["a.txt", "b.txt"]);
// Verify type
assert_eq!(repo.read_object(&tree_oid).unwrap().kind, ObjectKind::Tree);
}
#[test]
fn test_write_commit_matches_git() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
let head = repo.head_oid().unwrap();
let head_commit = repo.read_commit(&head).unwrap();
// Create a new commit pointing to the same tree
let commit = Commit {
tree: head_commit.tree,
parents: vec![head],
author: test_sig(),
committer: test_sig(),
message: "test commit via API\n".into(),
};
let commit_oid = repo.write_commit(&commit).unwrap();
// The id git assigns to this commit
assert_eq!(commit_oid.to_hex(), "368f791a90001930f1762af7326aa54555e25254");
assert_eq!(repo.read_object(&commit_oid).unwrap().kind, ObjectKind::Commit);
let content = String::from_utf8(repo.read_object(&commit_oid).unwrap().data).unwrap();
assert!(content.contains("test commit via API"));
assert!(content.contains(&head.to_hex()));
}
// ===== Index Round-Trip Tests =====
#[test]
fn test_index_roundtrip() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
// Read, write, and read again
let index = repo.read_index().unwrap();
repo.write_index(&index).unwrap();
let again = repo.read_index().unwrap();
assert_eq!(again.entries.len(), index.entries.len());
for (a, b) in index.entries.iter().zip(again.entries.iter()) {
assert_eq!(a.path, b.path);
assert_eq!(a.oid, b.oid);
assert_eq!(a.mode, b.mode);
}
// The rewritten index still describes a clean tree.
let status = repo.status().unwrap();
assert!(!status.entries.iter().any(|e| matches!(
e.status,
FileStatus::StagedNew | FileStatus::StagedDeleted | FileStatus::Untracked
)));
}
#[test]
fn test_index_after_staging() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
// Stage a new file
fs::write(dir.path().join("new.txt"), "new content\n").unwrap();
repo.stage_file("new.txt").unwrap();
// Commit from that index
repo.commit("commit with our index\n", test_sig()).unwrap();
fsck(&mut repo);
// Verify the commit has the new file
let head = repo.head_oid().unwrap();
let head_tree = repo.read_commit(&head).unwrap().tree;
let tree = repo.read_tree(&head_tree).unwrap();
assert!(tree.entries.iter().any(|e| e.name == "new.txt"), "tree: {:?}", tree.entries);
}
// ===== Deep Nested Directory Tests =====
#[test]
fn test_deeply_nested_directories() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
// Create deeply nested structure
let deep_dir = dir.path().join("a/b/c/d/e");
fs::create_dir_all(&deep_dir).unwrap();
fs::write(deep_dir.join("deep.txt"), "deep content\n").unwrap();
commit_all(&mut repo, dir.path(), "add deep file");
let head = repo.head_oid().unwrap();
let commit = repo.read_commit(&head).unwrap();
let tree = repo.read_tree(&commit.tree).unwrap();
// Should have 'a' directory in root tree
let a_entry = tree.entries.iter().find(|e| e.name == "a");
assert!(a_entry.is_some(), "should have 'a' directory");
assert!(a_entry.unwrap().is_tree());
// Index should have the deep path
let index = repo.read_index().unwrap();
assert!(
index.entries.iter().any(|e| e.path == "a/b/c/d/e/deep.txt"),
"index should have deeply nested path"
);
}
// ===== Consistency After All Our Writes =====
#[test]
fn test_extensive_writes_pass_fsck() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
// Write many objects
for i in 0..20 {
let data = format!("blob content {}\n", i);
repo.write_blob(data.as_bytes()).unwrap();
}
// Create multiple commits
for i in 0..5 {
fs::write(dir.path().join("file1.txt"), format!("iteration {}\n", i)).unwrap();
repo.stage_file("file1.txt").unwrap();
repo.commit(&format!("automated commit {}\n", i), test_sig()).unwrap();
}
// Everything reachable hashes to its id
fsck(&mut repo);
let head = repo.head_oid().unwrap();
let log: Vec<String> = repo.log(&head, 100).unwrap().into_iter().map(|(_, c)| c.message).collect();
assert!(log.contains(&"automated commit 4\n".to_string()), "log: {:?}", log);
}
// ===== Read Deltified Objects =====
const COMMON_PREFIX: &str = "This is a file with some common content.\n\
Line 2 is the same in every file.\n\
Line 3 too.\n\
But line 4 varies: iteration ";
const COMMON_SUFFIX: &str = "\nLine 5 is common again.\n";
fn similar_file(i: usize) -> Vec<u8> {
format!("{}{}{}", COMMON_PREFIX, i, COMMON_SUFFIX).into_bytes()
}
#[test]
fn test_read_deltified_objects() {
let dir = make_repo();
let git_dir = dir.path().join(".git");
// Twenty similar files: the first stored whole, the others as deltas
// against it (ref-deltas and ofs-deltas alternately), the way a packer
// stores near-duplicates.
let base = similar_file(0);
let base_oid = makepad_git::oid::hash_object("blob", &base);
let mut entries = vec![PackEntry::Full(ObjectKind::Blob, base.clone())];
for i in 1..20 {
let result = similar_file(i);
let varying = format!("{}", i);
let mut delta = delta_header(base.len(), result.len());
delta.extend(delta_copy(0, COMMON_PREFIX.len() as u32));
delta.extend(delta_insert(varying.as_bytes()));
delta.extend(delta_copy(
(COMMON_PREFIX.len() + 1) as u32,
COMMON_SUFFIX.len() as u32,
));
entries.push(if i % 2 == 1 {
PackEntry::RefDelta {
kind: ObjectKind::Blob,
base: base_oid,
delta,
result,
}
} else {
PackEntry::OfsDelta {
kind: ObjectKind::Blob,
base_index: 0,
delta,
result,
}
});
}
let ids = write_pack_entries(&git_dir, &entries).unwrap();
// A commit whose tree names every packed file
let repo = Repository::open(dir.path()).unwrap();
let mut tree_entries: Vec<TreeEntry> = ids
.iter()
.enumerate()
.map(|(i, oid)| TreeEntry {
mode: 0o100644,
name: format!("file_{}.txt", i),
oid: *oid,
})
.collect();
tree_entries.sort_by(|a, b| a.name.cmp(&b.name));
let tree = repo.write_tree(&Tree { entries: tree_entries }).unwrap();
let head = repo.head_oid().unwrap();
let commit = repo
.write_commit(&Commit {
tree,
parents: vec![head],
author: test_sig(),
committer: test_sig(),
message: "many similar files\n".into(),
})
.unwrap();
repo.create_branch("main", &commit).unwrap();
// Verify we can read all files through the pack
let mut repo = Repository::open(dir.path()).unwrap();
let head = repo.head_oid().unwrap();
let commit = repo.read_commit(&head).unwrap();
let tree = repo.read_tree(&commit.tree).unwrap();
let mut seen = 0;
for entry in &tree.entries {
if entry.name.starts_with("file_") && entry.is_blob() {
let data = repo.read_blob(&entry.oid).unwrap();
let text = String::from_utf8(data).unwrap();
assert!(
text.contains("common content"),
"file {} should contain common content",
entry.name
);
let i: usize = entry.name["file_".len()..entry.name.len() - 4].parse().unwrap();
assert_eq!(text.as_bytes(), similar_file(i).as_slice(), "file {}", entry.name);
seen += 1;
}
}
assert_eq!(seen, 20);
}
// ===== Complex Merge Scenarios =====
#[test]
fn test_merge_with_added_files_both_sides() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
// Feature branch: add feature.txt
checkout_new_branch(&mut repo, "feature");
fs::write(dir.path().join("feature.txt"), "feature\n").unwrap();
commit_all(&mut repo, dir.path(), "add feature.txt");
// Main: add main.txt
repo.checkout_branch("main").unwrap();
fs::write(dir.path().join("main.txt"), "main\n").unwrap();
commit_all(&mut repo, dir.path(), "add main.txt");
// Merge via our API
let result = repo.merge_branch("feature", test_sig()).unwrap();
assert!(!result.has_conflict());
// Both files should exist
assert!(dir.path().join("feature.txt").exists());
assert!(dir.path().join("main.txt").exists());
// The merge is consistent
fsck(&mut repo);
let head = repo.head_oid().unwrap();
let commit = repo.read_commit(&head).unwrap();
assert_eq!(commit.parents.len(), 2);
assert!(repo.log(&head, 10).unwrap().len() >= 4);
}
#[test]
fn test_merge_delete_on_one_side() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
// Feature branch: delete file2.txt
checkout_new_branch(&mut repo, "feature");
fs::remove_file(dir.path().join("file2.txt")).unwrap();
commit_all(&mut repo, dir.path(), "delete file2.txt");
// Main: add main.txt (don't touch file2.txt)
repo.checkout_branch("main").unwrap();
fs::write(dir.path().join("main.txt"), "main\n").unwrap();
commit_all(&mut repo, dir.path(), "add main.txt");
// Merge — file2.txt was deleted by feature, untouched by main → clean delete
let result = repo.merge_branch("feature", test_sig()).unwrap();
assert!(!result.has_conflict(), "expected clean merge: {}", result.content());
fsck(&mut repo);
// The merged tree no longer carries file2.txt.
let head = repo.head_oid().unwrap();
let head_tree = repo.read_commit(&head).unwrap().tree;
let tree = repo.read_tree(&head_tree).unwrap();
assert!(!tree.entries.iter().any(|e| e.name == "file2.txt"), "tree: {:?}", tree.entries);
}
// ===== Our Commits Read Back Through Log, Diff and Show =====
#[test]
fn test_our_commits_log_diff_show() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
// Make several commits through our API
let mut commits = Vec::new();
for i in 0..3 {
let content = format!("version {}\n", i);
fs::write(dir.path().join("file1.txt"), &content).unwrap();
repo.stage_file("file1.txt").unwrap();
commits.push(repo.commit(&format!("API commit {}\n", i), test_sig()).unwrap());
}
// log
let head = repo.head_oid().unwrap();
let log: Vec<String> = repo.log(&head, 10).unwrap().into_iter().map(|(_, c)| c.message).collect();
assert!(log.contains(&"API commit 0\n".to_string()), "log: {:?}", log);
assert!(log.contains(&"API commit 2\n".to_string()), "log: {:?}", log);
assert!(log.len() >= 3);
// diff between first and last API commit
let changes = repo.diff_commits(&commits[0], &commits[2]).unwrap();
assert!(
changes.iter().any(|c| matches!(c, TreeChange::Modified { path, .. } if path == "file1.txt")),
"changes: {:?}",
changes
);
// show HEAD
let head_tree = repo.read_commit(&head).unwrap().tree;
let tree = repo.read_tree(&head_tree).unwrap();
let file1 = tree.entries.iter().find(|e| e.name == "file1.txt").unwrap();
assert_eq!(repo.read_blob(&file1.oid).unwrap(), b"version 2\n");
fsck(&mut repo);
}
// ===== Tags and Merge Commits =====
#[test]
fn test_read_repo_with_tags() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
let first = repo.head_oid().unwrap();
refs::write_ref(&repo.git_dir, "refs/tags/v1.0", &first).unwrap();
fs::write(dir.path().join("file1.txt"), "v2\n").unwrap();
let second = commit_all(&mut repo, dir.path(), "version 2");
refs::write_ref(&repo.git_dir, "refs/tags/v2.0", &second).unwrap();
let repo = Repository::open(dir.path()).unwrap();
let tags = repo.list_tags().unwrap();
let tag_names: Vec<&str> = tags.iter().map(|r| r.name.as_str()).collect();
assert!(tag_names.contains(&"refs/tags/v1.0"), "tags: {:?}", tag_names);
assert!(tag_names.contains(&"refs/tags/v2.0"), "tags: {:?}", tag_names);
assert_eq!(repo.resolve_ref("refs/tags/v1.0").unwrap(), first);
}
#[test]
fn test_read_repo_with_merge_commit() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
checkout_new_branch(&mut repo, "feature");
fs::write(dir.path().join("f.txt"), "feature\n").unwrap();
commit_all(&mut repo, dir.path(), "feature");
repo.checkout_branch("main").unwrap();
fs::write(dir.path().join("m.txt"), "main\n").unwrap();
commit_all(&mut repo, dir.path(), "main");
let result = repo.merge_branch("feature", test_sig()).unwrap();
assert!(!result.has_conflict());
// Read with our API
let mut repo = Repository::open(dir.path()).unwrap();
let head = repo.head_oid().unwrap();
let commit = repo.read_commit(&head).unwrap();
assert_eq!(commit.parents.len(), 2);
assert_eq!(commit.message, "Merge branch 'feature'\n");
// Walk full history
let log = repo.log(&head, 100).unwrap();
assert!(log.len() >= 4); // merge + main + feature + initial
}
// ===== Edge Cases =====
#[test]
fn test_empty_file() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
fs::write(dir.path().join("empty.txt"), "").unwrap();
commit_all(&mut repo, dir.path(), "empty file");
let head = repo.head_oid().unwrap();
let commit = repo.read_commit(&head).unwrap();
let tree = repo.read_tree(&commit.tree).unwrap();
let empty = tree.entries.iter().find(|e| e.name == "empty.txt").unwrap();
let data = repo.read_blob(&empty.oid).unwrap();
assert!(data.is_empty(), "empty file should have no content");
// git's id for the empty blob
assert_eq!(empty.oid.to_hex(), "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391");
}
#[test]
fn test_binary_content() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
// Write binary content
let binary_data: Vec<u8> = (0..256).map(|i| i as u8).collect();
fs::write(dir.path().join("binary.bin"), &binary_data).unwrap();
commit_all(&mut repo, dir.path(), "add binary");
let head = repo.head_oid().unwrap();
let commit = repo.read_commit(&head).unwrap();
let tree = repo.read_tree(&commit.tree).unwrap();
let bin_entry = tree.entries.iter().find(|e| e.name == "binary.bin").unwrap();
let data = repo.read_blob(&bin_entry.oid).unwrap();
assert_eq!(data, binary_data);
}
#[test]
fn test_large_file() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
// Write a 1MB file
let large_data: Vec<u8> = (0..1_000_000).map(|i| (i % 256) as u8).collect();
let oid = repo.write_blob(&large_data).unwrap();
let read_back = repo.read_blob(&oid).unwrap();
assert_eq!(read_back.len(), 1_000_000);
assert_eq!(read_back, large_data);
// The id git assigns to this content, and the object header it stores
assert_eq!(oid.to_hex(), "d6c2598c203a786efb82ca9b0a785e23e4909cb9");
let object = repo.read_object(&oid).unwrap();
assert_eq!(object.kind, ObjectKind::Blob);
assert_eq!(object.data.len(), 1_000_000);
}
#[test]
fn test_unicode_filenames() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
fs::write(dir.path().join("café.txt"), "unicode name\n").unwrap();
commit_all(&mut repo, dir.path(), "unicode filename");
let head = repo.head_oid().unwrap();
let commit = repo.read_commit(&head).unwrap();
let tree = repo.read_tree(&commit.tree).unwrap();
let cafe = tree.entries.iter().find(|e| e.name.contains("caf"));
assert!(cafe.is_some(), "should find café file in tree");
}
#[test]
fn test_commit_with_unicode_message() {
let dir = make_repo();
let mut repo = Repository::open(dir.path()).unwrap();
let commit = Commit {
tree: repo.read_commit(&repo.head_oid().unwrap()).unwrap().tree,
parents: vec![repo.head_oid().unwrap()],
author: Signature {
name: "Ñoño García".into(),
email: "garcia@ejemplo.com".into(),
timestamp: 1700000000,
tz_offset: "+0100".into(),
},
committer: test_sig(),
message: "Añadir funcionalidad 日本語\n".into(),
};
let oid = repo.write_commit(&commit).unwrap();
// The id git assigns to this commit, and the fields read back intact
assert_eq!(oid.to_hex(), "aae6f37e9a7cd218fb6c9c6d2c53cadab52ba322");
let read = repo.read_commit(&oid).unwrap();
assert_eq!(read.author.name, "Ñoño García");
assert_eq!(read.message, "Añadir funcionalidad 日本語\n");
let raw = String::from_utf8(repo.read_object(&oid).unwrap().data).unwrap();
assert!(raw.contains("Ñoño García"), "raw: {}", raw);
assert!(raw.contains("日本語"), "raw: {}", raw);
}