//! Worktree / object-store / log conformance beyond the crate's unit tests. //! Repositories are built with write_object / write_tree / write_commit and //! the pack writer in `test_support`. No git binary. use makepad_git::test_support::{tempdir, write_pack}; use makepad_git::*; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; fn sig() -> Signature { Signature { name: "Test".into(), email: "test@example.org".into(), timestamp: 0, tz_offset: "+0000".into(), } } fn init_repo(dir: &Path) -> Repository { 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(); Repository::open(dir).unwrap() } fn commit( repo: &mut Repository, files: &[(&str, &str)], parents: Vec, message: &str, ) -> (ObjectId, ObjectId) { let mut entries: Vec = files .iter() .map(|(name, content)| TreeEntry { mode: 0o100644, name: name.to_string(), oid: repo.write_blob(content.as_bytes()).unwrap(), }) .collect(); entries.sort_by(|a, b| a.name.cmp(&b.name)); let tree = repo.write_tree(&Tree { entries }).unwrap(); let commit = repo .write_commit(&Commit { tree, parents, author: sig(), committer: sig(), message: message.to_string(), }) .unwrap(); (commit, tree) } /// Register `wt` as a linked worktree of `main`, with a relative or absolute /// `gitdir:` pointer and `commondir` `../..`. fn link_worktree(main: &Path, wt: &Path, name: &str, head: &str, relative: bool) -> PathBuf { let private = main.join(".git").join("worktrees").join(name); fs::create_dir_all(&private).unwrap(); fs::write(private.join("HEAD"), head).unwrap(); fs::write(private.join("commondir"), "../..\n").unwrap(); fs::write( private.join("gitdir"), format!("{}\n", wt.join(".git").display()), ) .unwrap(); fs::create_dir_all(wt).unwrap(); let pointer = if relative { format!( "../{}/.git/worktrees/{}", main.file_name().unwrap().to_str().unwrap(), name ) } else { private.display().to_string() }; fs::write(wt.join(".git"), format!("gitdir: {}\n", pointer)).unwrap(); private } fn canon(path: &Path) -> PathBuf { path.canonicalize().unwrap() } fn ref_names(git_dir: &Path, common_dir: &Path, prefix: &str) -> Vec { refs::list_refs_in(git_dir, common_dir, prefix) .unwrap() .into_iter() .map(|r| r.name) .collect() } fn write_loose_ref(dir: &Path, name: &str, oid: &ObjectId) { let path = dir.join(name); if let Some(parent) = path.parent() { fs::create_dir_all(parent).unwrap(); } fs::write(path, format!("{}\n", oid.to_hex())).unwrap(); } /// Recursively read every blob of `tree` (GitTreeSourceSet-style) through /// `read_tree` / `read_blob`, recording `(path, mode, bytes)`. fn walk_tree( repo: &mut Repository, tree: &Tree, prefix: &str, out: &mut Vec<(String, u32, Vec)>, ) { let mut entries = tree.entries.clone(); entries.sort_by(|a, b| a.name.cmp(&b.name)); for entry in entries { let path = if prefix.is_empty() { entry.name.clone() } else { format!("{prefix}/{}", entry.name) }; if entry.is_tree() { let sub = repo.read_tree(&entry.oid).unwrap(); walk_tree(repo, &sub, &path, out); } else { let bytes = repo.read_blob(&entry.oid).unwrap(); out.push((path, entry.mode, bytes)); } } } #[test] fn two_linked_worktrees_share_store_and_isolate_private_refs() { let base = tempdir().unwrap(); let main = base.path().join("main"); fs::create_dir_all(&main).unwrap(); let mut repo = init_repo(&main); let (c_main, _) = commit(&mut repo, &[("file.txt", "main\n")], vec![], "main\n"); repo.create_branch("main", &c_main).unwrap(); let (c_feature, _) = commit( &mut repo, &[("file.txt", "feature\n")], vec![c_main], "feature\n", ); repo.create_branch("feature", &c_feature).unwrap(); let (c_other, _) = commit( &mut repo, &[("file.txt", "other\n")], vec![c_main], "other\n", ); repo.create_branch("other", &c_other).unwrap(); // Shared packed blob + packed-refs in the common dir. let packed_blob = b"packed-shared\n".to_vec(); let packed_blob_oid = makepad_git::oid::hash_object("blob", &packed_blob); let ids = write_pack( &main.join(".git"), &[(ObjectKind::Blob, packed_blob.clone())], ) .unwrap(); assert_eq!(ids[0], packed_blob_oid); let (c_packed, _) = commit( &mut repo, &[("packed.txt", "from-commit\n")], vec![c_main], "packed-ref\n", ); fs::write( main.join(".git/packed-refs"), format!( "# pack-refs with: peeled fully-peeled sorted\n{} refs/heads/packed\n{} refs/tags/v1\n", c_packed.to_hex(), c_feature.to_hex() ), ) .unwrap(); let wt_rel = base.path().join("wt-rel"); let private_rel = link_worktree( &main, &wt_rel, "wt-rel", "ref: refs/heads/feature\n", true, ); let wt_abs = base.path().join("wt-abs"); let private_abs = link_worktree( &main, &wt_abs, "wt-abs", &format!("{}\n", c_other.to_hex()), false, ); // Private namespaces: each worktree (and the main repo) gets its own. write_loose_ref(&main.join(".git"), "refs/bisect/bad", &c_main); write_loose_ref(&main.join(".git"), "refs/worktree/main-pin", &c_main); write_loose_ref(&private_rel, "refs/bisect/good", &c_feature); write_loose_ref(&private_rel, "refs/worktree/pin", &c_feature); write_loose_ref(&private_abs, "refs/bisect/other", &c_other); write_loose_ref(&private_abs, "refs/worktree/abs-pin", &c_other); drop(repo); let mut main_repo = Repository::open(&main).unwrap(); let mut rel_repo = Repository::open(&wt_rel).unwrap(); let mut abs_repo = Repository::open(&wt_abs).unwrap(); assert_eq!(canon(&rel_repo.git_dir), canon(&private_rel)); assert_eq!(canon(&abs_repo.git_dir), canon(&private_abs)); assert_eq!(canon(&rel_repo.common_dir), canon(&main.join(".git"))); assert_eq!(canon(&abs_repo.common_dir), canon(&main.join(".git"))); assert_eq!(canon(&main_repo.git_dir), canon(&main.join(".git"))); assert_eq!(canon(&main_repo.common_dir), canon(&main.join(".git"))); // Each handle resolves its own HEAD. assert!(matches!( main_repo.head().unwrap(), RefTarget::Symbolic(ref name) if name == "refs/heads/main" )); assert_eq!(main_repo.head_oid().unwrap(), c_main); assert!(matches!( rel_repo.head().unwrap(), RefTarget::Symbolic(ref name) if name == "refs/heads/feature" )); assert_eq!(rel_repo.head_oid().unwrap(), c_feature); assert!(matches!( abs_repo.head().unwrap(), RefTarget::Direct(oid) if oid == c_other )); assert_eq!(abs_repo.head_oid().unwrap(), c_other); assert_eq!(abs_repo.current_branch().unwrap(), None); // Shared objects, packs, packed-refs. assert_eq!( rel_repo.read_blob(&packed_blob_oid).unwrap(), b"packed-shared\n" ); assert_eq!( abs_repo.read_blob(&packed_blob_oid).unwrap(), b"packed-shared\n" ); assert_eq!( main_repo.read_blob(&packed_blob_oid).unwrap(), b"packed-shared\n" ); assert_eq!(rel_repo.resolve_ref("refs/heads/packed").unwrap(), c_packed); assert_eq!(abs_repo.resolve_ref("refs/tags/v1").unwrap(), c_feature); assert_eq!(main_repo.resolve_ref("refs/heads/packed").unwrap(), c_packed); let shared = [ "refs/heads/feature", "refs/heads/main", "refs/heads/other", "refs/heads/packed", "refs/tags/v1", ]; let main_refs = ref_names(&main_repo.git_dir, &main_repo.common_dir, "refs/"); for name in &shared { assert!(main_refs.contains(&name.to_string()), "main missing {name}"); } assert!(main_refs.contains(&"refs/bisect/bad".to_string())); assert!(main_refs.contains(&"refs/worktree/main-pin".to_string())); assert!(!main_refs.contains(&"refs/bisect/good".to_string())); assert!(!main_refs.contains(&"refs/worktree/pin".to_string())); assert!(!main_refs.contains(&"refs/bisect/other".to_string())); assert!(!main_refs.contains(&"refs/worktree/abs-pin".to_string())); let rel_refs = ref_names(&rel_repo.git_dir, &rel_repo.common_dir, "refs/"); for name in &shared { assert!(rel_refs.contains(&name.to_string()), "wt-rel missing {name}"); } assert!(rel_refs.contains(&"refs/bisect/good".to_string())); assert!(rel_refs.contains(&"refs/worktree/pin".to_string())); assert!(!rel_refs.contains(&"refs/bisect/bad".to_string())); assert!(!rel_refs.contains(&"refs/worktree/main-pin".to_string())); assert!(!rel_refs.contains(&"refs/bisect/other".to_string())); assert!(!rel_refs.contains(&"refs/worktree/abs-pin".to_string())); let abs_refs = ref_names(&abs_repo.git_dir, &abs_repo.common_dir, "refs/"); for name in &shared { assert!(abs_refs.contains(&name.to_string()), "wt-abs missing {name}"); } assert!(abs_refs.contains(&"refs/bisect/other".to_string())); assert!(abs_refs.contains(&"refs/worktree/abs-pin".to_string())); assert!(!abs_refs.contains(&"refs/bisect/bad".to_string())); assert!(!abs_refs.contains(&"refs/bisect/good".to_string())); assert!(!abs_refs.contains(&"refs/worktree/pin".to_string())); assert!(!abs_refs.contains(&"refs/worktree/main-pin".to_string())); } #[test] fn fresh_handle_diff_trees_loose_root_packed_subtree() { let base = tempdir().unwrap(); let main = base.path().join("main"); fs::create_dir_all(&main).unwrap(); let repo = init_repo(&main); let blob = b"inner packed\n".to_vec(); let blob_oid = makepad_git::oid::hash_object("blob", &blob); let subtree_bytes = makepad_git::tree::serialize_tree(&Tree { entries: vec![TreeEntry { mode: 0o100644, name: "inner.txt".into(), oid: blob_oid, }], }); let ids = write_pack( &main.join(".git"), &[(ObjectKind::Blob, blob), (ObjectKind::Tree, subtree_bytes)], ) .unwrap(); assert_eq!(ids[0], blob_oid); let subtree_oid = ids[1]; let top = repo.write_blob(b"top\n").unwrap(); let root = repo .write_tree(&Tree { entries: vec![ TreeEntry { mode: 0o040000, name: "dir".into(), oid: subtree_oid, }, TreeEntry { mode: 0o100644, name: "top.txt".into(), oid: top, }, ], }) .unwrap(); let empty = repo.write_tree(&Tree { entries: vec![] }).unwrap(); drop(repo); // Fresh handle: no prior object read. let mut fresh = Repository::open(&main).unwrap(); let changes = fresh.diff_trees(&empty, &root).unwrap(); let mut paths: Vec = changes .iter() .map(|c| match c { TreeChange::Added { path, .. } => path.clone(), other => panic!("unexpected change {other:?}"), }) .collect(); paths.sort(); assert_eq!(paths, ["dir/inner.txt", "top.txt"]); } #[test] fn linked_worktree_missing_index_is_empty_not_error() { let base = tempdir().unwrap(); let main = base.path().join("main"); fs::create_dir_all(&main).unwrap(); let mut repo = init_repo(&main); let (first, _) = commit(&mut repo, &[("tracked.txt", "hi\n")], vec![], "one\n"); repo.create_branch("main", &first).unwrap(); let wt = base.path().join("wt"); let private = link_worktree(&main, &wt, "wt", "ref: refs/heads/main\n", false); assert!(!private.join("index").exists()); drop(repo); let mut wt_repo = Repository::open(&wt).unwrap(); let index = wt_repo .read_index() .expect("missing index is an empty index, not an error"); assert!(index.entries.is_empty(), "expected empty index state"); let status = wt_repo .status() .expect("status with a missing index must not error"); assert!( status.entries.iter().all(|e| matches!( e.status, FileStatus::StagedDeleted | FileStatus::Untracked )), "empty index vs HEAD: {:?}", status.entries ); } #[test] fn alternates_relative_from_common_objects_dir() { let base = tempdir().unwrap(); let main = base.path().join("main"); fs::create_dir_all(&main).unwrap(); let mut repo = init_repo(&main); let (first, _) = commit(&mut repo, &[("a", "1\n")], vec![], "one\n"); repo.create_branch("main", &first).unwrap(); let other = base.path().join("other"); fs::create_dir_all(&other).unwrap(); let other_repo = init_repo(&other); let only_there = other_repo .write_blob(b"lives only in the alternate\n") .unwrap(); fs::create_dir_all(main.join(".git/objects/info")).unwrap(); fs::write( main.join(".git/objects/info/alternates"), "../../../other/.git/objects\n", ) .unwrap(); let wt = base.path().join("wt"); link_worktree(&main, &wt, "wt", "ref: refs/heads/main\n", true); drop(repo); let mut wt_repo = Repository::open(&wt).unwrap(); assert_eq!( wt_repo.read_blob(&only_there).unwrap(), b"lives only in the alternate\n" ); } #[test] fn malformed_pointers_are_errors() { let base = tempdir().unwrap(); let main = base.path().join("main"); fs::create_dir_all(&main).unwrap(); let mut repo = init_repo(&main); let (first, _) = commit(&mut repo, &[("a", "1\n")], vec![], "one\n"); repo.create_branch("main", &first).unwrap(); drop(repo); // `commondir` = "." let self_wt = base.path().join("self"); let self_private = main.join(".git/worktrees/self"); fs::create_dir_all(&self_private).unwrap(); fs::write(self_private.join("HEAD"), "ref: refs/heads/main\n").unwrap(); fs::write(self_private.join("commondir"), ".\n").unwrap(); fs::create_dir_all(&self_wt).unwrap(); fs::write( self_wt.join(".git"), format!("gitdir: {}\n", self_private.display()), ) .unwrap(); assert!( Repository::open(&self_wt).is_err(), "commondir = . must be an error" ); // Chained gitfile: pointer at another gitfile. let wt = base.path().join("wt"); link_worktree(&main, &wt, "wt", "ref: refs/heads/main\n", false); let chained = base.path().join("chained"); fs::create_dir_all(&chained).unwrap(); fs::write( chained.join(".git"), format!("gitdir: {}\n", wt.join(".git").display()), ) .unwrap(); assert!( Repository::open(&chained).is_err(), "chained gitfile must be an error" ); // gitdir pointing at a plain directory without objects/. let plain = base.path().join("plain-dir"); fs::create_dir_all(&plain).unwrap(); fs::write(plain.join("HEAD"), "ref: refs/heads/main\n").unwrap(); let not_git = base.path().join("not-git"); fs::create_dir_all(¬_git).unwrap(); fs::write( not_git.join(".git"), format!("gitdir: {}\n", plain.display()), ) .unwrap(); assert!( Repository::open(¬_git).is_err(), "gitdir at a dir without objects/ must be an error" ); // Nested dangling `.git` symlink is an error, not ancestor discovery. #[cfg(unix)] { use std::os::unix::fs::symlink; let nested = main.join("nested-dangling"); fs::create_dir_all(&nested).unwrap(); symlink(main.join("does-not-exist"), nested.join(".git")).unwrap(); assert!( repository_paths(&nested).is_err(), "dangling .git symlink must error" ); assert!( Repository::open(&nested).is_err(), "dangling .git symlink must not fall through to the ancestor" ); // A directory with no `.git` still discovers the ancestor. let plain_nested = main.join("plain-nested"); fs::create_dir_all(&plain_nested).unwrap(); assert!(repository_paths(&plain_nested).unwrap().is_none()); assert!(Repository::open(&plain_nested).is_ok()); } } #[test] fn log_thirty_commits_two_merges() { let dir = tempdir().unwrap(); let root = dir.path().join("repo"); fs::create_dir_all(&root).unwrap(); let mut repo = init_repo(&root); let blob = repo.write_blob(b"x\n").unwrap(); let tree = repo .write_tree(&Tree { entries: vec![TreeEntry { mode: 0o100644, name: "f".into(), oid: blob, }], }) .unwrap(); let write = |repo: &Repository, parents: Vec, message: &str| { repo.write_commit(&Commit { tree, parents, author: sig(), committer: sig(), message: message.to_string(), }) .unwrap() }; // 20-commit backbone. let mut ids = Vec::with_capacity(30); ids.push(write(&repo, vec![], "c0\n")); for i in 1..20 { ids.push(write(&repo, vec![ids[i - 1]], &format!("c{i}\n"))); } // Side branch from c5: three commits. ids.push(write(&repo, vec![ids[5]], "c20\n")); // 20 ids.push(write(&repo, vec![ids[20]], "c21\n")); ids.push(write(&repo, vec![ids[21]], "c22\n")); // Merge 1 of backbone tip and side branch. ids.push(write(&repo, vec![ids[19], ids[22]], "merge1\n")); // 23 // Side branch from c10: two commits. ids.push(write(&repo, vec![ids[10]], "c24\n")); ids.push(write(&repo, vec![ids[24]], "c25\n")); // Merge 2. ids.push(write(&repo, vec![ids[23], ids[25]], "merge2\n")); // 26 ids.push(write(&repo, vec![ids[26]], "c27\n")); ids.push(write(&repo, vec![ids[27]], "c28\n")); ids.push(write(&repo, vec![ids[28]], "c29\n")); assert_eq!(ids.len(), 30); repo.create_branch("main", ids.last().unwrap()).unwrap(); let head = *ids.last().unwrap(); let log = repo.log(&head, 100).unwrap(); assert_eq!(log.len(), 30, "all 30 commits reachable from HEAD"); let mut seen = HashSet::new(); for (oid, _) in &log { assert!(seen.insert(*oid), "duplicate commit {}", oid.to_hex()); } for id in &ids { assert!(seen.contains(id), "missing commit {}", id.to_hex()); } let merges: Vec<_> = log .iter() .filter(|(_, c)| c.parents.len() == 2) .map(|(_, c)| c.message.as_str()) .collect(); assert_eq!(merges.len(), 2); assert!(merges.contains(&"merge1\n")); assert!(merges.contains(&"merge2\n")); // Linear history is a valid HEAD-first topological order under the // stack DFS in Repository::log. A merge diamond is not: a shared // ancestor reached via one parent is emitted before the other // parent's descendants (libs/git/src/repo.rs Repository::log). let l0 = write(&repo, vec![], "lin0\n"); let l1 = write(&repo, vec![l0], "lin1\n"); let l2 = write(&repo, vec![l1], "lin2\n"); let l3 = write(&repo, vec![l2], "lin3\n"); let l4 = write(&repo, vec![l3], "lin4\n"); let linear_log = repo.log(&l4, 10).unwrap(); assert_eq!( linear_log.iter().map(|(o, _)| *o).collect::>(), [l4, l3, l2, l1, l0] ); let c0 = write(&repo, vec![], "d0\n"); let c1 = write(&repo, vec![c0], "d1\n"); let c2 = write(&repo, vec![c0], "d2\n"); let c3 = write(&repo, vec![c1, c2], "d3\n"); let diamond = repo.log(&c3, 10).unwrap(); let dpos: HashMap = diamond .iter() .enumerate() .map(|(i, (oid, _))| (*oid, i)) .collect(); let diamond_topo = diamond.iter().all(|(oid, commit)| { commit.parents.iter().all(|parent| match dpos.get(parent) { Some(&p) => p > dpos[oid], None => true, }) }); if !diamond_topo { eprintln!( "LIBRARY DEFECT libs/git/src/repo.rs Repository::log: merge diamond is not a topological order. \ minimal input: c0; c1 parent c0; c2 parent c0; c3 parents c1,c2. got: {:?}", diamond .iter() .map(|(_, c)| c.message.trim()) .collect::>() ); } assert_eq!(diamond.len(), 4); assert_eq!( diamond.iter().map(|(o, _)| *o).collect::>().len(), 4 ); let clipped = repo.log(&head, 10).unwrap(); assert_eq!(clipped.len(), 10, "log must stop at max_count"); let mut clipped_seen = HashSet::new(); for (oid, _) in &clipped { assert!(clipped_seen.insert(*oid)); } let one = repo.log(&head, 1).unwrap(); assert_eq!(one.len(), 1); assert_eq!(one[0].0, head); } #[test] fn walk_head_tree_preserves_executable_and_symlink_modes() { let dir = tempdir().unwrap(); let root = dir.path().join("repo"); fs::create_dir_all(&root).unwrap(); let mut repo = init_repo(&root); let readme = repo.write_blob(b"readme\n").unwrap(); let script = repo.write_blob(b"#!/bin/sh\necho hi\n").unwrap(); let link_target = repo.write_blob(b"bin/script").unwrap(); let nested = repo.write_blob(b"nested\n").unwrap(); let sub = repo .write_tree(&Tree { entries: vec![TreeEntry { mode: 0o100644, name: "file.txt".into(), oid: nested, }], }) .unwrap(); let bin = repo .write_tree(&Tree { entries: vec![TreeEntry { mode: 0o100755, name: "script".into(), oid: script, }], }) .unwrap(); let tree = repo .write_tree(&Tree { entries: vec![ TreeEntry { mode: 0o040000, name: "bin".into(), oid: bin, }, TreeEntry { mode: 0o120000, name: "link".into(), oid: link_target, }, TreeEntry { mode: 0o100644, name: "README".into(), oid: readme, }, TreeEntry { mode: 0o040000, name: "subdir".into(), oid: sub, }, ], }) .unwrap(); let head = repo .write_commit(&Commit { tree, parents: vec![], author: sig(), committer: sig(), message: "modes\n".into(), }) .unwrap(); repo.create_branch("main", &head).unwrap(); let commit = repo.read_commit(&repo.head_oid().unwrap()).unwrap(); let root_tree = repo.read_tree(&commit.tree).unwrap(); let mut blobs = Vec::new(); walk_tree(&mut repo, &root_tree, "", &mut blobs); let by_path: HashMap)> = blobs .into_iter() .map(|(p, m, b)| (p, (m, b))) .collect(); assert_eq!(by_path["README"].0, 0o100644); assert_eq!(by_path["README"].1, b"readme\n"); assert_eq!(by_path["bin/script"].0, 0o100755); assert_eq!(by_path["bin/script"].1, b"#!/bin/sh\necho hi\n"); assert_eq!(by_path["link"].0, 0o120000); assert_eq!(by_path["link"].1, b"bin/script"); assert_eq!(by_path["subdir/file.txt"].0, 0o100644); assert_eq!(by_path["subdir/file.txt"].1, b"nested\n"); }