//! Tests for the repository layer. use super::*; use crate::protocol::SignedPolicy; use crate::repo::{BisectStep, Selection}; /// Point `$OXY_HOME` at a throwaway directory once per test process so no /// test reads or writes the developer's real user configuration or keys. fn hermetic() { static ONCE: std::sync::Once = std::sync::Once::new(); static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); ONCE.call_once(|| { let d = tempfile::tempdir().unwrap(); std::env::set_var("OXY_HOME", d.path()); DIR.set(d).ok(); }); } fn write(root: &Path, rel: &str, content: &[u8]) { let p = root.join(rel); fs::create_dir_all(p.parent().unwrap()).unwrap(); fs::write(p, content).unwrap(); } fn statuses(s: &SnapshotResult) -> Vec<(String, ChangeStatus)> { s.statuses .iter() .map(|p| (p.path.clone(), p.status)) .collect() } #[test] fn init_snapshot_commit_log_undo() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); assert!(Repo::init(root).is_err()); let ops = repo.op_log().unwrap(); assert_eq!(ops.len(), 1); assert_eq!(ops[0].1.command, "oxy init"); // Empty working copy: snapshot records no operation (spec §12.3). let s = repo.snapshot("oxy status").unwrap(); assert!(!s.changed); assert_eq!(repo.op_log().unwrap().len(), 1); write(root, "src/main.rs", b"fn main() {}\n"); write(root, "README.md", b"# Hello\n"); write(root, ".oxyignore", b"/target\n"); write(root, "target/junk.o", b"\0\0\0"); let s = repo.snapshot("oxy status").unwrap(); assert!(s.changed); assert_eq!( statuses(&s), vec![ (".oxyignore".into(), ChangeStatus::Added), ("README.md".into(), ChangeStatus::Added), ("src/main.rs".into(), ChangeStatus::Added), ] ); assert_eq!(repo.op_log().unwrap().len(), 2); // Snapshotting again with nothing changed records no op. let s2 = repo.snapshot("oxy status").unwrap(); assert!(!s2.changed); assert_eq!(s2.wc_hash, s.wc_hash); assert_eq!(repo.op_log().unwrap().len(), 2); let h1 = repo.commit("Initial import", &[]).unwrap(); let c1 = repo.store().get_change(&h1).unwrap(); assert_eq!(c1.kind, ChangeKind::Normal); assert!(c1.deps.is_empty(), "first change has nothing to depend on"); assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]); let (wc_hash, wc) = repo.working_copy().unwrap(); assert_eq!(wc.deps, vec![h1]); assert!(wc.edits.is_empty()); assert!(repo.commit("nothing", &[]).is_err()); // Modify one file, remove another, add a binary file. write( root, "src/main.rs", b"fn main() {\n println!(\"hi\");\n}\n", ); fs::remove_file(root.join("README.md")).unwrap(); write(root, "logo.png", b"\x89PNG\0\0\x01"); let s = repo.snapshot("oxy status").unwrap(); assert_eq!( statuses(&s), vec![ ("README.md".into(), ChangeStatus::Removed), ("logo.png".into(), ChangeStatus::Added), ("src/main.rs".into(), ChangeStatus::Modified), ] ); assert_ne!(s.wc_hash, wc_hash); // The rewritten working copy keeps its ChangeId and depends on h1. assert_eq!(s.wc.change_id, wc.change_id); assert!(s.wc.deps.contains(&h1)); let h2 = repo.commit("Second", &[]).unwrap(); let c2 = repo.store().get_change(&h2).unwrap(); assert_eq!( c2.deps, vec![h1], "everything it touches was introduced by h1" ); assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]); // The materialized head state matches the working copy. let st = repo.load_state(&[h2]).unwrap(); let snap = st.materialize(); assert!(!snap.is_conflicted()); assert_eq!( snap.files.keys().cloned().collect::>(), [".oxyignore", "logo.png", "src", "src/main.rs"] ); assert!( matches!(&snap.files["src/main.rs"].content, FileContent::Text { bytes, .. } if bytes == b"fn main() {\n println!(\"hi\");\n}\n") ); assert!(snap.files["logo.png"].mode.opaque); if let FileContent::Opaque { blobs } = &snap.files["logo.png"].content { assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\x89PNG\0\0\x01"); } else { panic!("logo should be opaque"); } // Log: newest first, working copy included. let log = repo.log().unwrap(); assert_eq!(log.len(), 3); assert!(log[0].is_working_copy); assert_eq!(log[1].hash, h2); assert!(log[1].is_head); assert_eq!(log[2].hash, h1); let d = repo.describe_change(&h2).unwrap(); assert!(d.contains("D README.md"), "{d}"); assert!(d.contains("+ println!(\"hi\");"), "{d}"); assert!(d.contains("-fn main() {}"), "{d}"); // Resolution by prefix. assert_eq!(repo.resolve_change(&c2.change_id.short(6)).unwrap(), h2); assert_eq!( repo.resolve_change(&format!("#{}", h1.short(8))).unwrap(), h1 ); assert!(repo.resolve_change("zzzz").is_err()); // Undo the commit: bookmark and working copy go back. repo.undo().unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]); let s = repo.snapshot("oxy status").unwrap(); assert!( !s.changed, "files on disk still match the pre-commit working copy" ); assert_eq!(s.statuses.len(), 3); } /// Make `main` conflicted: two commits on the same base that edit the same line. fn conflicted_repo() -> (tempfile::TempDir, Repo, ChangeHash, ChangeHash, ChangeHash) { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "f.txt", b"a\nb\nc\n"); let h1 = repo.commit("base", &[]).unwrap(); write(root, "f.txt", b"a\nB\nc\n"); let h2 = repo.commit("upper", &[]).unwrap(); repo.checkout(&[h1], false, "checkout").unwrap(); write(root, "f.txt", b"a\nX\nc\n"); let h3 = repo.commit("ex", &[]).unwrap(); (dir, repo, h1, h2, h3) } #[test] fn concurrent_edits_conflict_and_can_be_resolved() { hermetic(); let (dir, repo, h1, h2, h3) = conflicted_repo(); let root = dir.path(); let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect(); assert_eq!(heads, [h2, h3].into_iter().collect()); let st = repo.load_state(&[h2, h3]).unwrap(); assert!(st.materialize().is_conflicted()); // Checking out main writes markers. let r = repo .checkout(&repo.bookmark_heads().unwrap(), false, "checkout main") .unwrap(); assert!(r.conflicted); let marked = fs::read_to_string(root.join("f.txt")).unwrap(); assert!( marked.starts_with("a\n<<<<<<< oxy conflict 1 of 1"), "{marked}" ); assert!( marked.contains("B\n") && marked.contains("X\n") && marked.ends_with("end\nc\n"), "{marked}" ); assert!(has_conflict_markers(marked.as_bytes())); // Untouched markers: clean working copy; commit has nothing to do. let s = repo.snapshot("status").unwrap(); assert!(s.statuses.is_empty()); assert!(!s.resolves); // Partially edited file that still has markers cannot be committed. write(root, "f.txt", marked.replace("B\n", "BB\n").as_bytes()); let s = repo.snapshot("status").unwrap(); assert_eq!( statuses(&s), vec![("f.txt".into(), ChangeStatus::ConflictEdited)] ); assert!(repo.commit("no", &[]).is_err()); // Resolve by keeping one side and adding a line elsewhere. write(root, "f.txt", b"a\nX\nc\nd\n"); let s = repo.snapshot("status").unwrap(); assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::Resolved)]); assert!(s.resolves); let h4 = repo.commit("resolve", &[]).unwrap(); let c4 = repo.store().get_change(&h4).unwrap(); assert_eq!(c4.kind, ChangeKind::Resolution); let mut want = vec![h1, h2, h3]; want.sort(); assert_eq!( c4.deps, want, "a resolution depends on exactly the contributors and what it references" ); assert_eq!(repo.bookmark_heads().unwrap(), vec![h4]); let snap = repo.load_state(&[h4]).unwrap().materialize(); assert!(!snap.is_conflicted()); assert!( matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n") ); assert!(!repo.snapshot("status").unwrap().changed); // A peer holding only h2 and h3 and receiving h4 sees the same result, // whatever order the changes arrive in. let mut st = State::new(); for h in [h4, h3, h1, h2] { st.add_unchecked(repo.store().get_change(&h).unwrap()); } st.validate().unwrap(); assert!( matches!(&st.materialize().files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n") ); } #[test] fn resolution_may_keep_both_sides_or_neither() { hermetic(); let (dir, repo, _h1, _h2, _h3) = conflicted_repo(); let root = dir.path(); repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main") .unwrap(); // Keep both sides in a chosen order. write(root, "f.txt", b"a\nX\nB\nc\n"); let h = repo.commit("both", &[]).unwrap(); let snap = repo.load_state(&[h]).unwrap().materialize(); assert!(!snap.is_conflicted()); assert!( matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nB\nc\n") ); // Undo and instead drop the whole region. repo.undo().unwrap(); write(root, "f.txt", b"a\nc\n"); let h = repo.commit("neither", &[]).unwrap(); let snap = repo.load_state(&[h]).unwrap().materialize(); assert!(!snap.is_conflicted()); assert!( matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nc\n") ); } #[test] fn name_conflict_resolved_by_removing_a_candidate() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "keep.txt", b"k\n"); let h1 = repo.commit("base", &[]).unwrap(); write(root, "new.txt", b"one\n"); repo.commit("one", &[]).unwrap(); repo.checkout(&[h1], false, "checkout").unwrap(); write(root, "new.txt", b"two\n"); repo.commit("two", &[]).unwrap(); let r = repo .checkout(&repo.bookmark_heads().unwrap(), false, "checkout main") .unwrap(); assert!(r.conflicted); assert!( root.join("new.txt.oxy-conflict-1").exists() && root.join("new.txt.oxy-conflict-2").exists() ); assert!(!root.join("new.txt").exists()); // Candidate numbering follows change-hash order, so pick by content: // keep the "two" candidate under the real name and drop the other. let (keep, drop) = if fs::read(root.join("new.txt.oxy-conflict-1")).unwrap() == b"two\n" { (1, 2) } else { (2, 1) }; fs::remove_file(root.join(format!("new.txt.oxy-conflict-{drop}"))).unwrap(); fs::rename( root.join(format!("new.txt.oxy-conflict-{keep}")), root.join("new.txt"), ) .unwrap(); let h = repo.commit("pick two", &[]).unwrap(); assert_eq!( repo.store().get_change(&h).unwrap().kind, ChangeKind::Resolution ); // The resolution does not touch keep.txt, so it does not depend on h1; // the bookmark keeps both as heads and their union is the state. let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect(); assert_eq!(heads, [h1, h].into_iter().collect()); let snap = repo .load_state(&repo.bookmark_heads().unwrap()) .unwrap() .materialize(); assert!(!snap.is_conflicted(), "{:?}", snap.tree.conflicts); assert_eq!( snap.files.keys().cloned().collect::>(), ["keep.txt", "new.txt"] ); assert!( matches!(&snap.files["new.txt"].content, FileContent::Text { bytes, .. } if bytes == b"two\n") ); } #[test] fn identities_and_signed_commits() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"a\n"); let h0 = repo.commit("unsigned", &[]).unwrap(); assert_eq!(repo.verify_change(&h0).unwrap(), SignatureStatus::Unsigned); assert_eq!( repo.store().get_change(&h0).unwrap().author, ObjectRef::ZERO ); let (key, id) = repo .create_identity("Ada", "ada@example.org", IdentityScope::Repo) .unwrap(); assert_eq!(id.hash(), key); assert_eq!(repo.config.author_key, Some(key)); // Config survives reopen; the secret key is found again. let reopened = Repo::open(root).unwrap().with_home(root.join("home")); assert_eq!(reopened.config.author_key, Some(key)); assert_eq!(reopened.secret_key().unwrap().unwrap().0, key); // Default core.sign = seal: commits carry the author but no signature. write(root, "a.txt", b"a\nb\n"); let h1 = repo.commit("authored", &[]).unwrap(); assert_eq!(repo.store().get_change(&h1).unwrap().author, key); assert_eq!(repo.verify_change(&h1).unwrap(), SignatureStatus::Unsigned); // Signing later works too. repo.sign_change(&h1).unwrap(); assert!( matches!(repo.verify_change(&h1).unwrap(), SignatureStatus::Valid { key: k, .. } if k == key) ); repo.config.set("core.sign", "change").unwrap(); repo.save_config().unwrap(); write(root, "a.txt", b"a\nb\nc\n"); let h2 = repo.commit("signed", &[]).unwrap(); assert_eq!( repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, name: "Ada".into(), email: "ada@example.org".into(), sealed: false, revoked: false } ); // A signature by a key that is not the author or a delegate is rejected // when the change names an author; an anonymous change (made before any // identity, author zero) is adopted by whoever signs it (spec §14.4). let other = SecretKey::generate(); let other_id = other.identity("Mallory", "m@example.org", 0); let other_key = repo.store().put_identity(&other_id).unwrap(); write(root, "a.txt", b"a\nb\nc\nd\n"); repo.config.set("core.sign", "seal").unwrap(); let h3 = repo.commit("ada's, unsigned", &[]).unwrap(); let forged = other.sign(Domain::Change, &other_key, &h3); repo.store().put_signature(&h3, &forged).unwrap(); assert!( matches!(repo.verify_change(&h3).unwrap(), SignatureStatus::Invalid(m) if m.contains("neither the author")) ); let adopted = other.sign(Domain::Change, &other_key, &h0); repo.store().put_signature(&h0, &adopted).unwrap(); assert!( matches!(repo.verify_change(&h0).unwrap(), SignatureStatus::Valid { name, .. } if name == "Mallory") ); // Tampering with the stored signature invalidates it. let mut sig = repo.store().signatures(&h2).unwrap().remove(0); sig.ed[0] ^= 1; repo.store().put_signature(&h2, &sig).unwrap(); assert!(matches!( repo.verify_change(&h2).unwrap(), SignatureStatus::Invalid(_) )); // Without the secret key, signing at commit fails cleanly. let no_keys = Repo::open(root).unwrap().with_home(root.join("nowhere")); write(root, "a.txt", b"z\n"); assert!(no_keys.commit("cannot sign", &[]).is_err()); } #[test] fn checkout_moves_the_working_copy() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"one\n"); write(root, "sub/deep/b.txt", b"b\n"); let h1 = repo.commit("first", &[]).unwrap(); write(root, "a.txt", b"two\n"); fs::remove_file(root.join("sub/deep/b.txt")).unwrap(); write(root, "bin.dat", &[0, 1, 2, 3]); let h2 = repo.commit("second", &[]).unwrap(); let r = repo.checkout(&[h1], false, "oxy checkout").unwrap(); assert_eq!( r, CheckoutReport { written: 2, removed: 1, conflicted: false, notes: vec![] } ); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n"); assert_eq!(fs::read(root.join("sub/deep/b.txt")).unwrap(), b"b\n"); assert!(!root.join("bin.dat").exists()); let (_, wc) = repo.working_copy().unwrap(); assert_eq!(wc.deps, vec![h1]); assert!( !repo.snapshot("status").unwrap().changed, "checkout leaves a clean working copy" ); // The bookmark still points at h2. assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]); // Dirty working copy refuses without --discard. write(root, "a.txt", b"dirty\n"); assert!(repo.checkout(&[h2], false, "oxy checkout").is_err()); let r = repo .checkout(&[h2], true, "oxy checkout --discard") .unwrap(); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"two\n"); assert_eq!(fs::read(root.join("bin.dat")).unwrap(), [0, 1, 2, 3]); assert!(!root.join("sub").exists(), "empty directories are pruned"); assert_eq!(r.removed, 1); assert!(!repo.snapshot("status").unwrap().changed); // Refuses to write through a symlinked directory. #[cfg(unix)] { fs::remove_dir_all(root.join("sub")).ok(); std::os::unix::fs::symlink("/tmp", root.join("sub")).unwrap(); assert!(repo.checkout(&[h1], true, "oxy checkout").is_err()); } } #[test] fn large_files_are_chunked_and_read_back() { hermetic(); let dir = tempfile::tempdir().unwrap(); let repo = Repo::init(dir.path()).unwrap(); let mut big = vec![0u8; 700 * 1024]; for (i, b) in big.iter_mut().enumerate() { *b = (i as u32).wrapping_mul(2654435761).to_le_bytes()[0]; } let r = repo.store_blob(&big).unwrap(); let blob = Blob::decode(&repo.store().get_typed(&r, ObjectType::Blob).unwrap()).unwrap(); assert!(blob.chunks.len() >= 3, "{}", blob.chunks.len()); assert_eq!(repo.read_blob(&r).unwrap(), big); assert_eq!(Repo::blob_ref(&big), r); } #[test] fn clone_push_pull_with_seals_and_attestations() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_path = root.join("origin"); let origin = Repo::init(&origin_path).unwrap(); let origin_url = origin_path.to_string_lossy().to_string(); // A clones, creates an identity (default core.sign = seal), commits, pushes. let mut a = Repo::clone(&origin_url, &root.join("a")) .unwrap() .with_home(root.join("home-a")); assert_eq!(a.id().unwrap(), origin.id().unwrap()); let (key_a, _) = a .create_identity("Ada", "ada@example.org", IdentityScope::Repo) .unwrap(); write(&root.join("a"), "f.txt", b"a\n"); let h1 = a.commit("first", &[]).unwrap(); let results = a.push("origin", false).unwrap(); assert!(results[0].ok, "{}", results[0].reason); assert_eq!(origin.bookmark_heads().unwrap(), vec![h1]); // The change was sealed at push and the reference attested. assert!( matches!(origin.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { key, sealed: true, .. } if key == key_a) ); assert_eq!( origin.verify_change(&h1).unwrap(), SignatureStatus::Unsigned ); let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap(); assert!(matches!(att.attest.body, AttestBody::Ref { serial: 1, .. })); let again = a.push("origin", false).unwrap(); assert!(again[0].ok && again[0].reason.contains("up to date")); let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap(); assert!( matches!(att.attest.body, AttestBody::Ref { serial: 1, .. }), "a no-op push does not attest again" ); // B clones and sees the file and the seal. let b = Repo::clone(&origin_url, &root.join("b")).unwrap(); assert_eq!(fs::read(root.join("b/f.txt")).unwrap(), b"a\n"); assert_eq!(b.bookmark_heads().unwrap(), vec![h1]); assert!(matches!( b.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { sealed: true, .. } )); // B (no identity) commits and pushes unsigned; the unverified origin accepts. write(&root.join("b"), "f.txt", b"a\nb\n"); let h2 = b.commit("second", &[]).unwrap(); assert!(b.push("origin", false).unwrap()[0].ok); assert_eq!(origin.bookmark_heads().unwrap(), vec![h2]); // A commits an independent change; push is refused until A pulls. write(&root.join("a"), "g.txt", b"g\n"); let h3 = a.commit("third", &[]).unwrap(); assert!(a.push("origin", false).is_err()); let pr = a.pull("origin").unwrap(); assert!(pr.changed && pr.checked_out); let mut want = vec![h2, h3]; want.sort(); assert_eq!(pr.heads, want); assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"a\nb\n"); assert_eq!(fs::read(root.join("a/g.txt")).unwrap(), b"g\n"); let results = a.push("origin", false).unwrap(); assert!(results[0].ok, "{}", results[0].reason); let mut origin_heads = origin.bookmark_heads().unwrap(); origin_heads.sort(); assert_eq!(origin_heads, want); let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap(); assert!(matches!(att.attest.body, AttestBody::Ref { serial: 2, .. })); // B pulls and ends up identical. let pr = b.pull("origin").unwrap(); assert_eq!(pr.heads, want); assert_eq!(fs::read(root.join("b/g.txt")).unwrap(), b"g\n"); assert!(!b.snapshot("status").unwrap().changed); // Concurrent edits of the same line: the union is a reified conflict. write(&root.join("a"), "f.txt", b"a\nA\n"); a.commit("a-edit", &[]).unwrap(); a.push("origin", false).unwrap(); write(&root.join("b"), "f.txt", b"a\nB\n"); b.commit("b-edit", &[]).unwrap(); let pr = b.pull("origin").unwrap(); assert!(pr.changed && pr.checked_out); let text = fs::read_to_string(root.join("b/f.txt")).unwrap(); assert!(text.contains("<<<<<<< oxy conflict"), "{text}"); write(&root.join("b"), "f.txt", b"a\nAB\n"); let hr = b.commit("resolve", &[]).unwrap(); assert_eq!( b.store().get_change(&hr).unwrap().kind, ChangeKind::Resolution ); assert!(b.push("origin", false).unwrap()[0].ok); a.pull("origin").unwrap(); assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"a\nAB\n"); // The resolution depends on the conflicting edits, but nothing depends // on "third" (it only added g.txt), so it stays a head beside it. let mut want = vec![hr, h3]; want.sort(); assert_eq!(a.bookmark_heads().unwrap(), want); assert!(!a.load_state(&want).unwrap().materialize().is_conflicted()); } #[test] fn receive_push_rejects_bad_updates() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = Repo::init(&root.join("origin")).unwrap(); let url = root.join("origin").to_string_lossy().to_string(); let mut a = Repo::clone(&url, &root.join("a")) .unwrap() .with_home(root.join("home")); let (key, _) = a .create_identity("Ada", "a@b", IdentityScope::Repo) .unwrap(); write(&root.join("a"), "f.txt", b"x\n"); let h1 = a.commit("one", &[]).unwrap(); assert!(a.push("origin", false).unwrap()[0].ok); let empty = origin.store().put_state(&StateObject::new(vec![])).unwrap(); let s1 = origin .store() .put_state(&StateObject::new(vec![h1])) .unwrap(); // Missing head. let ghost = origin .store() .put_state(&StateObject::new(vec![ObjectRef([9; 32])])) .unwrap(); let r = origin .receive_push( Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: ghost, force: false, attestation: None, }], None, vec![], ) .unwrap(); assert!( !r[0].ok && r[0].reason.contains("missing"), "{}", r[0].reason ); // Stale old_state. let r = origin .receive_push( Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: None, }], None, vec![], ) .unwrap(); assert!(!r[0].ok && r[0].reason.contains("stale"), "{}", r[0].reason); // Non-fast-forward without force is refused; with force it passes. let r = origin .receive_push( Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: false, attestation: None, }], None, vec![], ) .unwrap(); assert!( !r[0].ok && r[0].reason.contains("fast-forward"), "{}", r[0].reason ); let r = origin .receive_push( Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: true, attestation: None, }], None, vec![], ) .unwrap(); assert!(r[0].ok); assert_eq!(origin.bookmark_heads().unwrap(), vec![]); // Attestation with a stale serial is a rollback and is refused. let sa = a .sign_attest(&Attest { repo: a.id().unwrap(), hlc: Hlc::now(), body: AttestBody::Ref { name: "main".into(), old_state: empty, new_state: s1, serial: 1, }, }) .unwrap(); let r = origin .receive_push( Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa.clone()), }], None, vec![], ) .unwrap(); assert!( !r[0].ok && r[0].reason.contains("serial"), "{}", r[0].reason ); // An attestation for a different update is refused too. let sa2 = a .sign_attest(&Attest { repo: a.id().unwrap(), hlc: Hlc::now(), body: AttestBody::Ref { name: "main".into(), old_state: s1, new_state: empty, serial: 5, }, }) .unwrap(); let r = origin .receive_push( Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa2), }], None, vec![], ) .unwrap(); assert!( !r[0].ok && r[0].reason.contains("describe"), "{}", r[0].reason ); let _ = key; } #[test] fn user_config_supplies_defaults_and_clones_sign() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let home = root.join("home"); let origin = Repo::init(&root.join("origin")).unwrap(); let url = root.join("origin").to_string_lossy().to_string(); // Identity at user scope: the repository file stays free of author keys. let mut a = Repo::init(&root.join("a")).unwrap().with_home(&home); let (key, _) = a .create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); assert!(!a.config.repo_layer().contains_key("author.key")); assert_eq!(a.config.author_key, Some(key)); assert!(home.join("config.toml").exists()); a.set_user_config("core.sign", "change").unwrap(); assert!(a.config.sign_changes); // Repository settings override user settings and are the only ones saved. a.config.set("core.sign", "seal").unwrap(); a.save_config().unwrap(); let again = Repo::open(&root.join("a")).unwrap().with_home(&home); assert!(!again.config.sign_changes); assert_eq!(again.config.author_key, Some(key)); assert_eq!( read_config_file(&root.join("a/.oxy/config")) .unwrap() .get("core.sign") .map(String::as_str), Some("seal") ); // A fresh clone made by this user signs with the user-level identity, // even though the clone's store has never seen the identity object. let b = Repo::clone(&url, &root.join("b")).unwrap().with_home(&home); assert!(b.config.sign_changes); write(&root.join("b"), "f.txt", b"f\n"); let h = b.commit("signed from a clone", &[]).unwrap(); assert!( matches!(b.verify_change(&h).unwrap(), SignatureStatus::Valid { key: k, .. } if k == key) ); assert!(b.push("origin", false).unwrap()[0].ok); assert!(matches!( origin.verify_change(&h).unwrap(), SignatureStatus::Valid { .. } )); } #[test] fn format1_repositories_are_migrated_on_open() { hermetic(); use crate::oplog::{Format1Op, Format1View}; let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"a\n"); let h1 = repo.commit("one", &[]).unwrap(); let (wc_hash, _) = repo.working_copy().unwrap(); // Rewrite the op log the way format 1 stored it: two ops, head lists. let mut v1 = Format1View::default(); v1.bookmarks.insert("main".into(), vec![]); v1.workspaces.insert(repo.workspace(), wc_hash); let init = Format1Op { parents: vec![], hlc: Hlc::default(), command: "oxy init".into(), view: v1.clone(), }; let init_ref = repo.store().put(ObjectType::Op, &init.encode()).unwrap(); v1.bookmarks.insert("main".into(), vec![h1]); let commit = Format1Op { parents: vec![init_ref], hlc: Hlc::default(), command: "oxy commit".into(), view: v1, }; let commit_ref = repo.store().put(ObjectType::Op, &commit.encode()).unwrap(); write_atomic(&root.join(".oxy/ops/head"), commit_ref.to_hex().as_bytes()).unwrap(); write_atomic(&root.join(".oxy/format"), FORMAT1_LINE.as_bytes()).unwrap(); drop(repo); let repo = Repo::open(root).unwrap(); assert_eq!( fs::read_to_string(root.join(".oxy/format")).unwrap(), FORMAT_LINE ); assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]); let ops = repo.op_log().unwrap(); assert_eq!( ops.iter() .map(|(_, o)| o.command.as_str()) .collect::>(), ["oxy commit", "oxy init"] ); assert_eq!(repo.heads_of(&ops[1].1.view, "main").unwrap(), vec![]); assert!(!repo.snapshot("status").unwrap().changed); // Reopening again is a no-op. assert_eq!(Repo::open(root).unwrap().op_log().unwrap().len(), 2); } #[test] fn undo_skips_snapshot_operations() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"a\n"); let h1 = repo.commit("a", &[]).unwrap(); // A later command snapshots new edits on top of the commit. write(root, "b.txt", b"b\n"); let s = repo.snapshot("oxy status").unwrap(); assert!(s.changed); assert!(repo.head_op().unwrap().1.is_snapshot()); assert_eq!(repo.last_command().unwrap().command, "oxy commit -m \"a\""); // Undo must undo the commit, not the snapshot. repo.undo().unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![]); let s = repo.snapshot("oxy status").unwrap(); let paths: Vec<_> = s.statuses.iter().map(|p| p.path.as_str()).collect(); assert_eq!( paths, ["a.txt", "b.txt"], "both files are back in the working copy" ); assert!(repo.store().get_change(&h1).unwrap().deps.is_empty()); // Only snapshots remain above `init`, so there is nothing left to undo. assert_eq!(repo.last_command().unwrap().command, "oxy init"); assert!(repo.undo().is_err()); } #[test] fn independent_changes_commute_and_make_multiple_heads() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"a\n"); let h1 = repo.commit("a", &[]).unwrap(); write(root, "b.txt", b"b\n"); let h2 = repo.commit("b", &[]).unwrap(); let c2 = repo.store().get_change(&h2).unwrap(); assert!(c2.deps.is_empty(), "b.txt touches nothing from h1"); let mut heads = repo.bookmark_heads().unwrap(); heads.sort(); let mut want = vec![h1, h2]; want.sort(); assert_eq!(heads, want); // Editing a.txt depends on h1 only. write(root, "a.txt", b"a\na2\n"); let h3 = repo.commit("a2", &[]).unwrap(); assert_eq!(repo.store().get_change(&h3).unwrap().deps, vec![h1]); let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect(); assert_eq!(heads, [h2, h3].into_iter().collect()); // Explicit dependency. write(root, "c.txt", b"c\n"); let h4 = repo.commit("c", &[h2]).unwrap(); assert_eq!(repo.store().get_change(&h4).unwrap().deps, vec![h2]); } #[test] fn mode_change_and_text_binary_switch() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "run.sh", b"#!/bin/sh\n"); repo.commit("add script", &[]).unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap(); let s = repo.snapshot("status").unwrap(); assert_eq!( statuses(&s), vec![("run.sh".into(), ChangeStatus::ModeChanged)] ); let h = repo.commit("exec", &[]).unwrap(); let snap = repo.load_state(&[h]).unwrap().materialize(); assert!(snap.files["run.sh"].mode.exec); } // Text → binary → text. write(root, "run.sh", b"\0binary"); let h = repo.commit("binary", &[]).unwrap(); let snap = repo.load_state(&[h]).unwrap().materialize(); assert!(snap.files["run.sh"].mode.opaque); write(root, "run.sh", b"text again\n"); let h = repo.commit("text", &[]).unwrap(); let snap = repo.load_state(&[h]).unwrap().materialize(); assert!(!snap.files["run.sh"].mode.opaque); assert!( matches!(&snap.files["run.sh"].content, FileContent::Text { bytes, .. } if bytes == b"text again\n") ); assert!(!snap.is_conflicted()); } #[test] fn trust_policy_governs_pushes_and_fetches() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = Repo::init(&root.join("origin")).unwrap(); let url = root.join("origin").to_string_lossy().to_string(); // A is the root. Before the policy, an unsigned push from B is fine. let mut a = Repo::clone(&url, &root.join("a")) .unwrap() .with_home(root.join("home-a")); let (key_a, _) = a .create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); let mut b = Repo::clone(&url, &root.join("b")) .unwrap() .with_home(root.join("home-b")); write(&root.join("b"), "b.txt", b"b\n"); b.commit("unsigned before policy", &[]).unwrap(); assert!(b.push("origin", false).unwrap()[0].ok); // A creates the policy and pushes it; origin now enforces it. write(&root.join("a"), "a.txt", b"a\n"); a.commit("a", &[]).unwrap(); let sp = a.policy_init().unwrap(); assert_eq!(sp.policy.serial, 1); a.pull("origin").unwrap(); let results = a.push("origin", false).unwrap(); assert!(results.iter().all(|r| r.ok), "{results:?}"); assert_eq!(origin.current_policy().unwrap().unwrap().1.policy.serial, 1); // B pulls the policy. Without an identity B cannot push at all. b.pull("origin").unwrap(); assert!( b.current_policy().unwrap().is_some(), "the policy arrived with the fetch" ); assert!(b.push("origin", false).is_err()); // With an identity the policy does not list, the push is rejected by origin. let (key_b, id_b) = b .create_identity("Bob", "bob@example.org", IdentityScope::User) .unwrap(); write(&root.join("b"), "b.txt", b"b2\n"); let h_b2 = b.commit("b2 as Bob", &[]).unwrap(); assert_eq!(b.store().get_change(&h_b2).unwrap().author, key_b); let r = b.push("origin", false).unwrap(); assert!( !r[0].ok && r[0].reason.contains("may not update"), "{:?}", r ); // A grants B (identity handed over as a file) and publishes the policy. let sp = a.policy_grant(id_b.clone(), None).unwrap(); assert_eq!(sp.policy.serial, 2); let r = a.push("origin", false).unwrap(); assert!(r.iter().any(|x| x.name == "policy" && x.ok), "{r:?}"); // B must fetch the new policy before pushing. assert!(b .push("origin", false) .unwrap_or_else(|e| vec![RefResult { name: "main".into(), ok: false, reason: e.to_string() }]) .iter() .all(|x| !x.ok)); b.pull("origin").unwrap(); assert_eq!(b.current_policy().unwrap().unwrap().1.policy.serial, 2); let r = b.push("origin", false).unwrap(); assert!(r[0].ok, "{:?}", r); // Origin verified B's change under the policy. let heads = origin.bookmark_heads().unwrap(); for h in &heads { let c = origin.store().get_change(h).unwrap(); if c.author == key_b { origin .change_allowed( h, &origin.current_policy().unwrap().unwrap().1.policy, "main", ) .unwrap(); } } // A policy signed by a non-root (B) is refused, as is a replayed serial. let mut forged = b.current_policy().unwrap().unwrap().1.policy.clone(); forged.serial = 3; forged.roots = vec![key_b]; let fh = forged.hash(); let (_, sk_b) = b.secret_key().unwrap().unwrap(); let forged = SignedPolicy { policy: forged, sigs: vec![sk_b.sign(Domain::Policy, &key_b, &fh)], }; let r = origin .receive_push(Bundle::default(), vec![], Some(forged), vec![]) .unwrap(); assert!(!r[0].ok && r[0].reason.contains("root"), "{:?}", r); let stale = a.current_policy().unwrap().unwrap().1; let mut old = stale.clone(); old.policy.serial = 1; let r = origin .receive_push(Bundle::default(), vec![], Some(old), vec![]) .unwrap(); assert!(!r[0].ok && r[0].reason.contains("serial"), "{:?}", r); // A revokes B; after B pulls the policy, B's pushes are refused again. a.pull("origin").unwrap(); a.policy_revoke(&key_b).unwrap(); let r = a.push("origin", false).unwrap(); assert!(r.iter().any(|x| x.name == "policy" && x.ok), "{r:?}"); b.pull("origin").unwrap(); write(&root.join("b"), "b.txt", b"b3\n"); b.commit("b3", &[]).unwrap(); let r = b.push("origin", false).unwrap(); assert!(!r[0].ok, "{:?}", r); // A clone by a third party receives and verifies everything. let c = Repo::clone(&url, &root.join("c")) .unwrap() .with_home(root.join("home-c")); assert_eq!(c.current_policy().unwrap().unwrap().1.policy.serial, 3); assert!(c .current_policy() .unwrap() .unwrap() .1 .policy .roots .contains(&key_a)); assert!(!c.snapshot("status").unwrap().changed); } #[test] fn gc_packs_objects_and_history_still_works() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); repo.create_identity("Ada", "a@b", IdentityScope::User) .unwrap(); repo.set_user_config("core.sign", "change").unwrap(); write(root, "a.txt", b"one\n"); let h1 = repo.commit("one", &[]).unwrap(); write(root, "a.txt", b"two\n"); write(root, "big.bin", &vec![0u8; 100_000]); let h2 = repo.commit("two", &[]).unwrap(); // Several snapshots leave superseded working-copy versions behind. for i in 0..5 { write(root, "a.txt", format!("two\n{i}\n").as_bytes()); repo.snapshot("status").unwrap(); } write(root, "a.txt", b"two\n"); repo.snapshot("status").unwrap(); let before = repo.store().list().unwrap(); assert!(repo.store().loose_count().unwrap() > 0); // With the default retention every operation's view is a root: nothing prunes. let r = repo.gc(true).unwrap(); assert_eq!(r.packed.objects, before.len()); assert_eq!(r.pruned.objects, 0); assert_eq!(repo.store().loose_count().unwrap(), 0); assert_eq!(repo.store().list().unwrap(), before); // With zero retention only the current view is a root: superseded // working copies go, everything reachable stays. repo.config.set("core.op-retention", "0").unwrap(); repo.save_config().unwrap(); let repo = Repo::open(root).unwrap().with_home(root.join("home")); let r = repo.gc(true).unwrap(); assert!(r.pruned.objects >= 5, "{r:?}"); let after = repo.store().list().unwrap(); assert!(after.len() < before.len()); assert!(after.contains(&h1) && after.contains(&h2)); for (_, op) in repo.op_log().unwrap() { assert!(!op.command.is_empty(), "operation objects are all kept"); } // Everything still works from the pruned, packed store. assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]); assert!(matches!( repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { .. } )); assert_eq!(repo.log().unwrap().len(), 3); repo.checkout(&[h1], false, "checkout").unwrap(); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n"); repo.checkout(&[h2], false, "checkout").unwrap(); assert_eq!(fs::read(root.join("big.bin")).unwrap().len(), 100_000); assert!(!repo.snapshot("status").unwrap().changed); // Grace period defers pruning of young garbage. write(root, "a.txt", b"three\n"); repo.commit("three", &[]).unwrap(); let r = repo.gc(false).unwrap(); assert_eq!(r.pruned.objects, 0); assert!(r.pruned.deferred > 0); let r = repo.gc(true).unwrap(); assert!(r.pruned.objects > 0); assert_eq!(repo.log().unwrap().len(), 4); } #[test] fn describe_amend_abandon_heads() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); repo.create_identity("Ada", "a@b", IdentityScope::User) .unwrap(); repo.set_user_config("core.sign", "change").unwrap(); write(root, "a.txt", b"one\n"); let h1 = repo.commit("first", &[]).unwrap(); write(root, "a.txt", b"one\ntwo\n"); let h2 = repo.commit("secnod", &[]).unwrap(); let id2 = repo.store().get_change(&h2).unwrap().change_id; // describe: same ChangeId, new hash, bookmark and working copy follow. let h2b = repo.describe(&h2, "second").unwrap(); assert_ne!(h2b, h2); let c = repo.store().get_change(&h2b).unwrap(); assert_eq!( (c.change_id, c.message.as_str(), c.deps.clone()), (id2, "second", vec![h1]) ); assert_eq!(repo.bookmark_heads().unwrap(), vec![h2b]); assert!(matches!( repo.verify_change(&h2b).unwrap(), SignatureStatus::Valid { .. } )); let s = repo.snapshot("status").unwrap(); assert!(s.statuses.is_empty(), "{:?}", s.statuses); assert_eq!( repo.resolve_change(&id2.short(8)).unwrap(), h2b, "the old object is no longer reachable from the log" ); // Describing a non-head rewrites its dependents (h2b → h2c') and the working copy. let h1b = repo.describe(&h1, "first, described").unwrap(); assert_ne!(h1b, h1); let heads = repo.bookmark_heads().unwrap(); assert_eq!(heads.len(), 1); let top = repo.store().get_change(&heads[0]).unwrap(); assert_eq!((top.change_id, top.deps.clone()), (id2, vec![h1b])); assert!( matches!( repo.verify_change(&heads[0]).unwrap(), SignatureStatus::Valid { .. } ), "rewritten own change is re-signed" ); let (_, wc) = repo.working_copy().unwrap(); assert_eq!( wc.deps, vec![heads[0]], "the working copy follows the rewrite" ); assert!(!repo.snapshot("status").unwrap().changed); let snap = repo.load_state(&heads).unwrap().materialize(); assert!( matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n") ); // amend: fold an edit into the head; the result is one change from h1. write(root, "a.txt", b"one\ntwo\nthree\n"); write(root, "b.txt", b"b\n"); let h2c = repo.amend(None).unwrap(); let c = repo.store().get_change(&h2c).unwrap(); assert_eq!(c.change_id, id2); assert_eq!(c.message, "second"); assert_eq!(repo.bookmark_heads().unwrap(), vec![h2c]); assert_eq!( repo.log().unwrap().len(), 3, "working copy, amended head, first" ); let snap = repo.load_state(&[h2c]).unwrap().materialize(); assert!( matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\nthree\n") ); assert!(snap.files.contains_key("b.txt")); assert!(!repo.snapshot("status").unwrap().changed); assert!( repo.amend(None).is_err(), "clean working copy and no message" ); let h2d = repo.amend(Some("second, amended")).unwrap(); assert_eq!( repo.store().get_change(&h2d).unwrap().message, "second, amended" ); // abandon: the head goes, its content stays as uncommitted work. repo.abandon(&h2d).unwrap(); assert_eq!( repo.bookmark_heads().unwrap(), vec![h1b], "the described first change is the head again" ); let s = repo.snapshot("status").unwrap(); let paths: Vec<_> = s .statuses .iter() .map(|p| (p.path.as_str(), p.status)) .collect(); assert_eq!( paths, vec![ ("a.txt", ChangeStatus::Modified), ("b.txt", ChangeStatus::Added) ] ); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\ntwo\nthree\n"); let h3 = repo.commit("recommitted", &[]).unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![h3]); // undo brings the abandoned head back. repo.undo().unwrap(); repo.undo().unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![h2d]); // Abandoning a non-head whose dependent references its atoms reifies // the references (here: a.txt vanishes with it, so the dependent's edit // of a.txt is dropped with a note) instead of refusing; undo restores it. let base = repo.bookmark_heads().unwrap()[0]; let base_change = repo.store().get_change(&base).unwrap(); let h1_now = base_change.deps[0]; let notes = repo.abandon(&h1_now).unwrap(); assert!( !notes.is_empty(), "the dependent's edit of a.txt was reified" ); let heads = repo.bookmark_heads().unwrap(); let after = repo.load_state(&heads).unwrap().materialize(); assert!( !after.files.contains_key("a.txt") && after.files.contains_key("b.txt"), "{:?}", after.files.keys() ); repo.undo().unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![base]); write(root, "c.txt", b"c\n"); let hc = repo.commit("c", &[]).unwrap(); write(root, "d.txt", b"d\n"); let hd = repo.commit("d", &[hc]).unwrap(); assert_eq!(repo.store().get_change(&hd).unwrap().deps, vec![hc]); repo.abandon(&hc).unwrap(); let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect(); assert!(!heads.contains(&hc)); let d_now = heads .iter() .find(|h| repo.store().get_change(h).unwrap().message == "d") .copied() .unwrap(); assert!( repo.store().get_change(&d_now).unwrap().deps.is_empty(), "d inherited c's (empty) dependencies" ); let s = repo.snapshot("status").unwrap(); assert_eq!( s.statuses .iter() .map(|p| p.path.as_str()) .collect::>(), vec!["c.txt"], "c's content is now uncommitted work" ); } #[test] fn git_export_mirrors_history() { hermetic(); if std::process::Command::new("git") .arg("--version") .output() .is_err() { eprintln!("git not installed; skipping"); return; } let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(&root.join("r")) .unwrap() .with_home(root.join("home")); repo.create_identity("Ada Lovelace", "ada@example.org", IdentityScope::User) .unwrap(); let r = root.join("r"); write(&r, "README.md", b"hello\n"); write(&r, "src/main.rs", b"fn main() {}\n"); let h1 = repo.commit("first", &[]).unwrap(); write(&r, "src/main.rs", b"fn main() { run(); }\n"); write(&r, "logo.png", b"\x89PNG\0\0"); let h2 = repo.commit("second", &[]).unwrap(); let git_dir = root.join("mirror.git"); let rep = repo.git_export(&git_dir).unwrap(); assert_eq!((rep.exported, rep.skipped, rep.heads), (2, 0, 1)); let git = |args: &[&str]| -> String { let out = std::process::Command::new("git") .arg("-C") .arg(&git_dir) .args(args) .output() .unwrap(); assert!( out.status.success(), "{}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_string() }; assert_eq!(git(&["log", "--format=%s", "main"]), "second\nfirst"); assert_eq!(git(&["show", "main:src/main.rs"]), "fn main() { run(); }"); assert_eq!(git(&["show", "main:README.md"]), "hello"); let body = git(&["log", "-1", "--format=%B", "main"]); assert!( body.contains(&format!("Oxy-Hash: {}", h2.to_hex())), "{body}" ); assert!(body.contains("Change-Id: "), "{body}"); assert_eq!( git(&["log", "-1", "--format=%an <%ae>", "main"]), "Ada Lovelace " ); assert_eq!( git(&["ls-tree", "--name-only", "main"]), "README.md\nlogo.png\nsrc" ); // Re-export is incremental. let rep = repo.git_export(&git_dir).unwrap(); assert_eq!((rep.exported, rep.skipped), (0, 2)); // An independent change makes two heads; the next state is one more // commit on the linear branch whose tree is the union. repo.checkout(&[h1], false, "checkout").unwrap(); write(&r, "other.txt", b"o\n"); let h3 = repo.commit("third", &[]).unwrap(); let rep = repo.git_export(&git_dir).unwrap(); assert_eq!((rep.exported, rep.heads), (1, 2)); assert_eq!(git(&["log", "--format=%s", "main"]), "third\nsecond\nfirst"); let parents = git(&["rev-list", "--parents", "-1", "main"]); assert_eq!(parents.split(' ').count(), 2, "one parent: {parents}"); assert_eq!(git(&["show", "main:other.txt"]), "o"); assert_eq!(git(&["show", "main:src/main.rs"]), "fn main() { run(); }"); assert_eq!( git(&["ls-tree", "--name-only", "main"]), "README.md\nlogo.png\nother.txt\nsrc" ); let body = git(&["log", "-1", "--format=%B", "main"]); assert!( body.contains(&format!("Oxy-Hash: {}", h3.to_hex())) && body.contains("Oxy-State: "), "{body}" ); // A rewrite is a new state whose one new change is the rewritten one. repo.describe(&h3, "third, described").unwrap(); let rep = repo.git_export(&git_dir).unwrap(); assert_eq!(rep.exported, 1); assert_eq!( git(&["log", "-1", "--format=%s", "main"]), "third, described" ); assert_eq!(git(&["log", "--format=%s", "main"]).lines().count(), 4); let _ = h2; } #[test] fn git_import_reproduces_trees() { hermetic(); if std::process::Command::new("git") .arg("--version") .output() .is_err() { eprintln!("git not installed; skipping"); return; } let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let g = root.join("src.git"); fs::create_dir_all(&g).unwrap(); let git = |args: &[&str]| -> String { let out = std::process::Command::new("git") .arg("-C") .arg(&g) .args([ "-c", "user.name=Grace", "-c", "user.email=grace@example.org", "-c", "commit.gpgsign=false", ]) .args(args) .output() .unwrap(); assert!( out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_string() }; git(&["init", "-q", "-b", "main"]); write(&g, "README.md", b"# Hi\n"); write(&g, "src/lib.rs", b"pub fn a() {}\n"); git(&["add", "."]); git(&["commit", "-q", "-m", "initial"]); write(&g, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\n"); write(&g, "run.sh", b"#!/bin/sh\n"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(g.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap(); } write(&g, "logo.png", b"\x89PNG\0\0\x01"); git(&["add", "."]); git(&[ "commit", "-q", "-m", "second\n\nChange-Id: 0123456789abcdef0123456789abcdef", ]); // A branch that edits README, merged back with a conflict-free merge. git(&["checkout", "-q", "-b", "topic", "HEAD~1"]); write(&g, "README.md", b"# Hi\n\nMore.\n"); git(&["commit", "-q", "-am", "readme"]); git(&["checkout", "-q", "main"]); git(&["merge", "-q", "--no-ff", "-m", "merge topic", "topic"]); // A branch from the initial commit inserting at the same place as // "second": concurrent in Oxygen too, so the merge commit is a resolution. let initial = git(&["rev-list", "--max-parents=0", "HEAD"]); git(&["checkout", "-q", "-b", "clash", &initial]); write(&g, "src/lib.rs", b"pub fn a() {}\npub fn c() {}\n"); git(&["commit", "-q", "-am", "clash"]); git(&["checkout", "-q", "main"]); let _ = std::process::Command::new("git") .arg("-C") .arg(&g) .args(["merge", "-q", "clash"]) .output(); write( &g, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\npub fn c() {}\n", ); git(&["add", "."]); git(&["commit", "-q", "-m", "resolve clash"]); git(&["rm", "-q", "logo.png"]); git(&["commit", "-q", "-m", "drop logo"]); // A rename with a small edit is imported as a move of the same node. git(&["mv", "src/lib.rs", "src/lib2.rs"]); write( &g, "src/lib2.rs", b"pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n", ); git(&["add", "."]); git(&["commit", "-q", "-m", "rename lib"]); let expected_count: usize = git(&["rev-list", "--count", "main"]).parse().unwrap(); let dest = root.join("oxy"); let repo = Repo::init(&dest).unwrap().with_home(root.join("home")); let rep = repo.import_git(&g, "main").unwrap(); assert_eq!(rep.commits, expected_count); assert_eq!(rep.merges, 2); // The working copy equals the Git tree at main. assert_eq!( fs::read(dest.join("src/lib2.rs")).unwrap(), b"pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n" ); assert!(!dest.join("src/lib.rs").exists()); assert_eq!( fs::read(dest.join("README.md")).unwrap(), b"# Hi\n\nMore.\n" ); assert!(!dest.join("logo.png").exists()); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; assert!( fs::metadata(dest.join("run.sh")) .unwrap() .permissions() .mode() & 0o111 != 0 ); } assert!( !repo.snapshot("status").unwrap().changed, "clean after import" ); let heads = repo.bookmark_heads().unwrap(); let st = repo.load_state(&heads).unwrap(); assert!(!st.materialize().is_conflicted()); assert_eq!(st.len(), expected_count); let tip = repo .log() .unwrap() .into_iter() .find(|e| e.change.message.starts_with("rename lib")) .unwrap(); let c = repo.store().get_change(&tip.hash).unwrap(); assert!( c.tree_ops .iter() .any(|op| matches!(op, TreeOp::Move { name, .. } if name == "lib2.rs")), "{:?}", c.tree_ops ); assert!(!c .tree_ops .iter() .any(|op| matches!(op, TreeOp::AddNode { .. }))); // Messages carry Git trailers; the Change-Id trailer was honoured. let log = repo.log().unwrap(); let second = log .iter() .find(|e| e.change.message.starts_with("second")) .unwrap(); assert_eq!( second.change.change_id, ChangeId::from_hex("0123456789abcdef0123456789abcdef").unwrap() ); assert!(second .change .message .contains("Git-Author: Grace ")); let resolve = log .iter() .find(|e| e.change.message.starts_with("resolve clash")) .unwrap(); assert_eq!( resolve.change.kind, ChangeKind::Resolution, "a conflicting Git merge becomes a resolution" ); // Re-importing into another fresh repository gives the same ChangeIds. let repo2 = Repo::init(&root.join("oxy2")) .unwrap() .with_home(root.join("home")); repo2.import_git(&g, "main").unwrap(); let ids: BTreeSet = repo .log() .unwrap() .iter() .filter(|e| !e.is_working_copy) .map(|e| e.change.change_id) .collect(); let ids2: BTreeSet = repo2 .log() .unwrap() .iter() .filter(|e| !e.is_working_copy) .map(|e| e.change.change_id) .collect(); assert_eq!(ids, ids2); // Round trip back out to Git reproduces the tip tree. let mirror = root.join("mirror.git"); repo.git_export(&mirror).unwrap(); let out = std::process::Command::new("git") .arg("-C") .arg(&mirror) .args(["show", "main:src/lib2.rs"]) .output() .unwrap(); assert_eq!( String::from_utf8_lossy(&out.stdout), "pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n" ); let out = std::process::Command::new("git") .arg("-C") .arg(&mirror) .args(["show", "main:src/lib.rs"]) .output() .unwrap(); assert!( !out.status.success(), "the old path is gone from the export" ); } #[test] fn checkpoints_accelerate_loading_and_stay_correct() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); repo.config.set("core.checkpoint-interval", "5").unwrap(); repo.save_config().unwrap(); let repo = Repo::open(root).unwrap().with_home(root.join("home")); let mut hashes = Vec::new(); for i in 0..12 { write( root, "a.txt", format!( "{}\n", (0..=i) .map(|k| k.to_string()) .collect::>() .join("\n") ) .as_bytes(), ); write(root, &format!("f{i}.txt"), b"x\n"); hashes.push(repo.commit(&format!("c{i}"), &[]).unwrap()); } let (_, cp) = repo .latest_checkpoint("main") .unwrap() .expect("automatic checkpoint"); assert!(cp.count >= 5 && cp.count <= 12, "{}", cp.count); // Loading through the checkpoint equals loading everything. let heads = repo.bookmark_heads().unwrap(); let fast = repo.load_state(&heads).unwrap(); let full = repo.load_state_full(&heads).unwrap(); assert_eq!(fast.len(), full.len()); assert_eq!(fast.heads(), full.heads()); let (a, b) = (fast.materialize(), full.materialize()); assert_eq!( a.files.keys().collect::>(), b.files.keys().collect::>() ); for (p, f) in &a.files { assert_eq!(f.content, b.files[p].content, "{p}"); } assert!(repo.load_state_checkpointed(&heads).unwrap().is_some()); // A head set that does not contain the checkpoint's heads falls back. assert!(repo .load_state_checkpointed(&[hashes[0]]) .unwrap() .is_none()); assert_eq!(repo.load_state(&[hashes[0]]).unwrap().len(), 1); // Everything else keeps working on top of the checkpoint. assert!(!repo.snapshot("status").unwrap().changed); let h = repo.describe(&hashes[3], "c3 described").unwrap(); assert_eq!(repo.store().get_change(&h).unwrap().message, "c3 described"); let (r, n, bytes) = repo.checkpoint().unwrap(); assert!(n >= 12 && bytes > 0); assert_eq!(repo.latest_checkpoint("main").unwrap().unwrap().0, r); write(root, "a.txt", b"after\n"); let h_after = repo.commit("after", &[]).unwrap(); let st = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap(); assert!(st.contains(&h_after)); assert!( matches!(&st.materialize().files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"after\n") ); assert_eq!(repo.log().unwrap().len(), 14); // Checkpoints survive gc. repo.gc(true).unwrap(); assert!(repo.latest_checkpoint("main").unwrap().is_some()); assert!(repo .load_state_checkpointed(&repo.bookmark_heads().unwrap()) .unwrap() .is_some()); } #[test] fn quick_status_for_prompts() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = Repo::init(&root.join("origin")).unwrap(); let url = root.join("origin").to_string_lossy().to_string(); let a = Repo::clone(&url, &root.join("a")) .unwrap() .with_home(root.join("home")); let ops_before = a.op_log().unwrap().len(); let info = a.quick_status().unwrap(); assert_eq!( ( info.bookmark.as_str(), info.heads, info.dirty, info.conflicted, info.ahead, info.behind ), ("main", 0, false, false, Some(0), Some(0)) ); write(&root.join("a"), "f.txt", b"f\n"); assert!(a.quick_status().unwrap().dirty, "a new file is dirty"); assert_eq!( a.op_log().unwrap().len(), ops_before, "quick_status records nothing" ); // A snapshotted but uncommitted change is still dirty. a.snapshot("status").unwrap(); assert!(a.quick_status().unwrap().dirty); a.commit("f", &[]).unwrap(); let info = a.quick_status().unwrap(); assert!(!info.dirty, "clean right after a commit"); assert_eq!((info.ahead, info.behind), (Some(1), Some(0))); assert_eq!( info.format("%b %c%d%x%A%D"), format!("main {}+1", info.change_id) ); assert_eq!(info.format("[%h heads] %a/%B %%"), "[1 heads] 1/0 %"); // Touching a file without changing it is not dirty; changing content of // the same size is. let p = root.join("a/f.txt"); let content = fs::read(&p).unwrap(); fs::write(&p, &content).unwrap(); assert!(!a.quick_status().unwrap().dirty); fs::write(&p, b"g\n").unwrap(); assert!(a.quick_status().unwrap().dirty); fs::write(&p, b"f\n").unwrap(); // Push, then a second clone commits and pushes: we are behind. assert!(a.push("origin", false).unwrap()[0].ok); assert_eq!(a.quick_status().unwrap().ahead, Some(0)); let b = Repo::clone(&url, &root.join("b")) .unwrap() .with_home(root.join("home-b")); write(&root.join("b"), "g.txt", b"g\n"); b.commit("g", &[]).unwrap(); b.push("origin", false).unwrap(); a.fetch("origin").unwrap(); let info = a.quick_status().unwrap(); assert_eq!((info.ahead, info.behind), (Some(0), Some(1))); assert_eq!(info.format("%A%D"), "-1"); let _ = origin; } #[test] fn lazy_blobs_are_fetched_on_first_read() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_dir = root.join("origin"); let origin = Repo::init(&origin_dir) .unwrap() .with_home(root.join("home")); let url = origin_dir.to_string_lossy().to_string(); let big: Vec = (0..200_000u32).map(|i| (i % 251) as u8).collect(); // has NUL bytes: opaque write(&origin_dir, "big.bin", &big); write(&origin_dir, "small.bin", &[0u8, 1, 2]); write(&origin_dir, "t.txt", b"t\n"); let h = origin.commit("blobs", &[]).unwrap(); let big_ref = Repo::blob_ref(&big); // Fetch only (no checkout) with a filter: the big blob stays absent. let a = Repo::init_with_id(&root.join("a"), origin.id().unwrap()) .unwrap() .with_home(root.join("home")); let a = { let mut a = a; a.config.set("remote.origin.url", &url).unwrap(); a.config.set("filter.max-blob", "100K").unwrap(); a.save_config().unwrap(); a }; assert_eq!(a.config.max_blob, Some(100 * 1024)); a.fetch("origin").unwrap(); assert!(a.store().has(&h)); assert!(!a.store().has(&big_ref), "withheld"); assert!( a.store().has(&Repo::blob_ref(&[0u8, 1, 2])), "small blob came along" ); assert_eq!(a.lazy_remote(&big_ref).as_deref(), Some("origin")); // status and commit do not need it: adopt the state, edit a text file, commit. let (op_ref, op) = a.head_op().unwrap(); let mut view = op.view.clone(); view.bookmarks.insert( "main".into(), a.store().put_state(&StateObject::new(vec![h])).unwrap(), ); a.record_op(&[op_ref], "adopt", view).unwrap(); // Checkout reads the blob lazily, which fetches it. a.checkout(&[h], true, "checkout").unwrap(); assert!(a.store().has(&big_ref), "fetched on first read"); assert_eq!(fs::read(root.join("a/big.bin")).unwrap(), big); assert!(a.lazy_remote(&big_ref).is_none(), "no longer registered"); write(&root.join("a"), "t.txt", b"t2\n"); a.commit("text edit", &[]).unwrap(); // clone --max-blob records the filter; the checkout fetches lazily. let b = Repo::clone_with(&url, &root.join("b"), Some(1024), &[]).unwrap(); assert_eq!(b.config.max_blob, Some(1024)); assert_eq!(fs::read(root.join("b/big.bin")).unwrap(), big); assert_eq!(parse_size("2M").unwrap(), 2 << 20); assert!(parse_size("lots").is_err()); } #[test] fn sparse_checkout_materializes_only_chosen_paths() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_dir = root.join("origin"); let origin = Repo::init(&origin_dir) .unwrap() .with_home(root.join("home")); write(&origin_dir, "src/main.rs", b"fn main() {}\n"); write(&origin_dir, "src/lib.rs", b"pub fn a() {}\n"); write(&origin_dir, "docs/guide.md", b"# guide\n"); let logo: Vec = (0..50_000u32).map(|i| (i % 7) as u8).collect(); write(&origin_dir, "docs/logo.png", &logo); write(&origin_dir, "README.md", b"# top\n"); let h1 = origin.commit("tree", &[]).unwrap(); let url = origin_dir.to_string_lossy().to_string(); let a = Repo::clone_with(&url, &root.join("a"), None, &["src".into()]) .unwrap() .with_home(root.join("home")); assert_eq!(a.config.sparse_paths, vec!["src"]); assert_eq!(a.config.max_blob, Some(0), "sparse implies lazy blobs"); assert!(root.join("a/src/main.rs").exists()); assert!(!root.join("a/docs").exists() && !root.join("a/README.md").exists()); assert!( !a.store().has(&Repo::blob_ref(&logo)), "out-of-scope blob never fetched" ); assert!( !a.snapshot("status").unwrap().changed, "out-of-scope files are not removals" ); assert!(!a.quick_status().unwrap().dirty); // Edits in scope commit normally and keep the rest of the tree intact. write( &root.join("a"), "src/lib.rs", b"pub fn a() {}\npub fn b() {}\n", ); let h2 = a.commit("edit lib", &[]).unwrap(); let st = a.load_state(&[h2]).unwrap().materialize(); assert!( st.files.contains_key("docs/guide.md"), "the state still has everything" ); assert!(a.push("origin", false).unwrap()[0].ok); // Files created outside the scope are ignored by status. write(&root.join("a"), "docs/new.md", b"ignored\n"); assert!(a.snapshot("status").unwrap().statuses.is_empty()); // Widening the filter checks out the rest; narrowing leaves stray files alone. let mut a = a; a.config.set("filter.paths", "src,docs").unwrap(); a.save_config().unwrap(); let r = a .checkout(&a.bookmark_heads().unwrap(), true, "widen") .unwrap(); assert!(r.written >= 2); assert_eq!( fs::read(root.join("a/docs/logo.png")).unwrap(), logo, "now fetched lazily" ); assert!(!root.join("a/README.md").exists()); // Origin sees the change made under a sparse checkout with the full tree intact. origin.pull("origin").err(); let full = origin .load_state(&origin.bookmark_heads().unwrap()) .unwrap() .materialize(); assert!( matches!(&full.files["src/lib.rs"].content, FileContent::Text { bytes, .. } if bytes == b"pub fn a() {}\npub fn b() {}\n") ); assert!(full.files.contains_key("README.md")); let _ = h1; } #[test] fn shallow_clone_and_deepen() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_dir = root.join("origin"); let origin = Repo::init(&origin_dir) .unwrap() .with_home(root.join("home")); let mut hashes = Vec::new(); for i in 0..8 { write( &origin_dir, "a.txt", format!( "{}\n", (0..=i) .map(|k| k.to_string()) .collect::>() .join("\n") ) .as_bytes(), ); hashes.push(origin.commit(&format!("c{i}"), &[]).unwrap()); } let url = origin_dir.to_string_lossy().to_string(); let a = Repo::clone_shallow(&url, &root.join("a"), 3, None, &[]) .unwrap() .with_home(root.join("home")); assert!(a.is_shallow()); assert_eq!( fs::read_to_string(root.join("a/a.txt")).unwrap(), "0\n1\n2\n3\n4\n5\n6\n7\n" ); assert!( a.store().has(&hashes[7]) && a.store().has(&hashes[5]), "three levels present" ); assert!(!a.store().has(&hashes[4]), "beyond the depth is absent"); assert_eq!(a.boundary(), [hashes[4]].into_iter().collect()); assert_eq!( a.log().unwrap().len(), 9, "the log shows every change from the checkpoint's metadata" ); assert!(!a.snapshot("status").unwrap().changed); assert_eq!(a.quick_status().unwrap().heads, 1); // Work continues on top of the shallow history and pushes back. write(&root.join("a"), "a.txt", b"0\n1\n2\n3\n4\n5\n6\n7\n8\n"); let h_new = a.commit("c8", &[]).unwrap(); assert!(a.push("origin", false).unwrap()[0].ok); assert_eq!(origin.bookmark_heads().unwrap(), vec![h_new]); // Fetching new work from origin into the shallow clone works too. write(&origin_dir, "b.txt", b"b\n"); origin.pull("origin").err(); let (_, wc) = origin.working_copy().unwrap(); let _ = wc; origin .checkout(&origin.bookmark_heads().unwrap(), true, "co") .unwrap(); write(&origin_dir, "b.txt", b"b\n"); let h_b = origin.commit("b", &[]).unwrap(); let pr = a.pull("origin").unwrap(); assert!(pr.changed); assert!(a.store().has(&h_b)); assert!(root.join("a/b.txt").exists()); // Deepening fetches older history until the boundary is gone. // Every commit also depends on the change that created the file, so two // levels below the boundary hold h4, h3, and the root. assert!(a.deepen("origin", 2).unwrap() >= 2); assert!(a.store().has(&hashes[3])); assert!(a.is_shallow()); a.deepen("origin", 100).unwrap(); assert!(!a.is_shallow(), "boundary reached the root"); assert!(a.store().has(&hashes[0])); assert_eq!( a.load_state_full(&a.bookmark_heads().unwrap()) .unwrap() .len(), 10 ); } #[test] fn tags_are_immutable_named_states_and_travel() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = Repo::init(&root.join("origin")).unwrap(); let url = root.join("origin").to_string_lossy().to_string(); let mut a = Repo::clone(&url, &root.join("a")) .unwrap() .with_home(root.join("home-a")); let (key_a, _) = a .create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); write(&root.join("a"), "f.txt", b"1\n"); let h1 = a.commit("one", &[]).unwrap(); write(&root.join("a"), "f.txt", b"1\n2\n"); let h2 = a.commit("two", &[]).unwrap(); let t = a.tag_create("v1", &[h1], "first release").unwrap(); assert_eq!( (t.heads.clone(), t.signer.as_deref()), (vec![h1], Some("Ada")) ); assert!(a.tag_create("v1", &[h2], "").is_err(), "immutable"); assert!(a.tag_create("bad/name", &[h2], "").is_err()); assert_eq!(a.tag_heads("v1").unwrap(), Some(vec![h1])); assert_eq!(a.resolve_heads("v1").unwrap(), vec![h1]); assert_eq!(a.resolve_heads("main").unwrap(), vec![h2]); let (_, sa) = a.tag_attest("v1").unwrap().unwrap(); assert_eq!(sa.sigs[0].key, key_a); // Checkout by tag. a.checkout(&a.resolve_heads("v1").unwrap(), false, "co") .unwrap(); assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"1\n"); a.checkout(&a.bookmark_heads().unwrap(), false, "co") .unwrap(); // Push carries the tag; a second attestation for the name is refused. let r = a.push("origin", false).unwrap(); assert!(r.iter().any(|x| x.name == "tags/v1" && x.ok), "{r:?}"); assert_eq!(origin.tag_heads("v1").unwrap(), Some(vec![h1])); let mut forged = a .tag_refs() .unwrap() .into_iter() .find(|t| t.name == "v1") .unwrap(); forged.state = origin .store() .put_state(&StateObject::new(vec![h2])) .unwrap(); forged.heads = vec![h2]; forged.attestation = None; let r = origin .receive_push(Bundle::default(), vec![], None, vec![forged]) .unwrap(); assert!(!r[0].ok && r[0].reason.contains("immutable"), "{r:?}"); // A clone gets the tag; a later tag arrives on fetch. let b = Repo::clone(&url, &root.join("b")) .unwrap() .with_home(root.join("home-b")); assert_eq!(b.tag_heads("v1").unwrap(), Some(vec![h1])); assert!(b.tags().unwrap()[0].signer.as_deref() == Some("Ada")); a.tag_create("v2", &[h2], "second").unwrap(); a.push("origin", false).unwrap(); let rep = b.fetch("origin").unwrap(); assert_eq!(rep.new_tags, 1); assert_eq!(b.tag_heads("v2").unwrap(), Some(vec![h2])); // Under a policy, an unattested tag is refused and an attested one by the root passes. a.policy_init().unwrap(); a.push("origin", false).unwrap(); let unattested = crate::protocol::TagRef { name: "v3".into(), state: origin .store() .put_state(&StateObject::new(vec![h2])) .unwrap(), heads: vec![h2], attestation: None, }; let r = origin .receive_push(Bundle::default(), vec![], None, vec![unattested]) .unwrap(); assert!(!r[0].ok && r[0].reason.contains("attestation"), "{r:?}"); a.tag_create("v3", &[h2], "").unwrap(); let r = a.push("origin", false).unwrap(); assert!(r.iter().any(|x| x.name == "tags/v3" && x.ok), "{r:?}"); assert!(a.gc(true).is_ok()); assert_eq!(a.tags().unwrap().len(), 3, "tags survive gc"); } #[test] fn multiple_workspaces_share_a_repository() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let main_dir = root.join("main"); let repo = Repo::init(&main_dir).unwrap().with_home(root.join("home")); write(&main_dir, "a.txt", b"a\n"); let h1 = repo.commit("a", &[]).unwrap(); assert_eq!(repo.workspaces().unwrap().len(), 1); // A second workspace elsewhere, checked out on the bookmark. let ws_dir = root.join("ws2"); let ws = repo.workspace_add(&ws_dir, None).unwrap(); assert!(ws_dir.join(".oxy").is_file()); assert_eq!(fs::read(ws_dir.join("a.txt")).unwrap(), b"a\n"); assert_ne!(ws.workspace(), repo.workspace()); let list = repo.workspaces().unwrap(); assert_eq!(list.len(), 2); assert!(list .iter() .any(|w| w.id == ws.workspace() && w.path == ws_dir.canonicalize().unwrap() && !w.current)); assert!(!ws.snapshot("status").unwrap().changed); // Work in the second workspace: its own working copy, shared bookmark. write(&ws_dir, "b.txt", b"b\n"); let s = ws.snapshot("status").unwrap(); assert_eq!(s.statuses.len(), 1); assert!( !repo.snapshot("status").unwrap().changed, "the first workspace is untouched" ); let h2 = ws.commit("b from ws2", &[]).unwrap(); let mut heads = repo.bookmark_heads().unwrap(); heads.sort(); let mut want = vec![h1, h2]; want.sort(); assert_eq!(heads, want, "both workspaces see the bookmark move"); assert!(!main_dir.join("b.txt").exists()); repo.checkout(&repo.bookmark_heads().unwrap(), false, "co") .unwrap(); assert!(main_dir.join("b.txt").exists()); // Reopening from inside the second workspace finds the shared repository. let reopened = Repo::open(&ws_dir).unwrap(); assert_eq!(reopened.workspace(), ws.workspace()); assert_eq!(reopened.id().unwrap(), repo.id().unwrap()); assert_eq!(reopened.quick_status().unwrap().heads, 2); // Workspaces cannot nest; forget drops one from the view but keeps files. assert!(repo.workspace_add(&main_dir.join("inner"), None).is_err()); assert!(repo.workspace_forget(&repo.workspace()).is_err()); repo.workspace_forget(&ws.workspace()).unwrap(); assert_eq!(repo.workspaces().unwrap().len(), 1); assert!(ws_dir.join("b.txt").exists()); assert!( ws.snapshot("status").is_err(), "a forgotten workspace is no longer in the view" ); // The snapshot lock: a fresh lock blocks a second, a stale one is taken over. let lock = repo.lock_snapshot().unwrap(); drop(lock); let lock_path = main_dir .join(".oxy/workspaces") .join(repo.workspace().to_hex()) .join("lock"); fs::write(&lock_path, "1").unwrap(); let old = std::time::SystemTime::now() - std::time::Duration::from_secs(120); fs::File::open(&lock_path) .unwrap() .set_modified(old) .unwrap(); assert!(repo.lock_snapshot().is_ok(), "stale lock taken over"); } #[test] fn attributes_binary_eol_nocompress_lockable() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write( root, ".oxyattributes", b"*.dat binary nocompress\n*.txt eol=crlf\nassets/** lockable\n", ); write(root, "plain.dat", b"no nul bytes here\n"); write(root, "notes.txt", b"one\r\ntwo\r\n"); write(root, "assets/model.bin", b"\0model"); let h = repo.commit("attrs", &[]).unwrap(); let snap = repo.load_state(&[h]).unwrap().materialize(); assert!( snap.files["plain.dat"].mode.opaque, "binary attribute forces opaque without NUL bytes" ); assert!( matches!(&snap.files["notes.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n"), "stored with LF" ); assert!( !repo.snapshot("status").unwrap().changed, "CRLF on disk equals LF in the state" ); // nocompress chunks pack raw. let r = repo.gc(true).unwrap(); assert!(r.packed.objects > 0); // Checkout writes CRLF for eol=crlf and makes lockable files read-only. fs::remove_file(root.join("notes.txt")).unwrap(); repo.checkout(&[h], true, "co").unwrap(); assert_eq!(fs::read(root.join("notes.txt")).unwrap(), b"one\r\ntwo\r\n"); assert!(fs::metadata(root.join("assets/model.bin")) .unwrap() .permissions() .readonly()); assert!(repo.lock_path("plain.dat").is_err(), "not lockable"); repo.lock_path("assets/model.bin").unwrap(); assert!(!fs::metadata(root.join("assets/model.bin")) .unwrap() .permissions() .readonly()); assert!(repo.is_locked("assets/model.bin")); write(root, "assets/model.bin", b"\0model2"); repo.commit("edit model", &[]).unwrap(); repo.unlock_path("assets/model.bin").unwrap(); assert!(fs::metadata(root.join("assets/model.bin")) .unwrap() .permissions() .readonly()); // A later checkout can still overwrite a read-only lockable file. repo.checkout(&[h], true, "co").unwrap(); assert_eq!(fs::read(root.join("assets/model.bin")).unwrap(), b"\0model"); } #[cfg(unix)] #[test] fn hooks_can_veto_observe_and_add_dependencies() { use std::os::unix::fs::PermissionsExt; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); let hooks = root.join(".oxy/hooks"); fs::create_dir_all(&hooks).unwrap(); let hook = |name: &str, body: &str| { let p = hooks.join(name); fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap(); fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap(); }; hook("pre-commit", "cat > \"$OXY_ROOT/.last-pre-commit\"; grep -q WIP \"$OXY_ROOT/.last-pre-commit\" && { echo 'no WIP commits' >&2; exit 1; }; exit 0"); hook("post-commit", "cat > \"$OXY_ROOT/.last-post-commit\""); hook("post-checkout", "cat > \"$OXY_ROOT/.last-post-checkout\""); hook("pre-push", "echo pushing blocked >&2; exit 3"); write(root, ".oxyignore", b".last-*\n"); write(root, "a.txt", b"a\n"); let err = repo.commit("WIP: nope", &[]).unwrap_err().to_string(); assert!(err.contains("pre-commit refused: no WIP commits"), "{err}"); let h1 = repo.commit("first", &[]).unwrap(); let pre = fs::read_to_string(root.join(".last-pre-commit")).unwrap(); assert!( pre.contains("\"event\":\"pre-commit\"") && pre.contains("\"path\":\"a.txt\"") && pre.contains("\"message\":\"first\""), "{pre}" ); let post = fs::read_to_string(root.join(".last-post-commit")).unwrap(); assert!(post.contains(&h1.to_hex()), "{post}"); repo.checkout(&[h1], false, "co").unwrap(); assert!(fs::read_to_string(root.join(".last-post-checkout")) .unwrap() .contains("post-checkout")); repo.config.set("remote.origin.url", "/nowhere").unwrap(); repo.save_config().unwrap(); let err = repo.push("origin", false).unwrap_err().to_string(); assert!(err.contains("pre-push refused: pushing blocked"), "{err}"); // semantic-deps: only when enabled; adds an explicit dependency. write(root, "b.txt", b"b\n"); hook( "semantic-deps", &format!( "cat > \"$OXY_ROOT/.last-semantic\"; echo '{{\"deps\": [\"{}\"]}}'", h1.to_hex() ), ); let h2 = repo.commit("b without hook", &[]).unwrap(); assert!( repo.store().get_change(&h2).unwrap().deps.is_empty(), "hook disabled by default" ); repo.config.set("hooks.semantic-deps", "true").unwrap(); repo.save_config().unwrap(); write(root, "c.txt", b"c\n"); let h3 = repo.commit("c with hook", &[]).unwrap(); assert_eq!(repo.store().get_change(&h3).unwrap().deps, vec![h1]); let sem = fs::read_to_string(root.join(".last-semantic")).unwrap(); assert!( sem.contains("\"touched_paths\":[\"c.txt\"]") && sem.contains("candidate_deps"), "{sem}" ); assert_eq!(json_str("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\""); } #[test] fn amend_into_older_change_and_split() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); repo.create_identity("Ada", "a@b", IdentityScope::User) .unwrap(); repo.set_user_config("core.sign", "change").unwrap(); write(root, "a.txt", b"one\ntwo\n"); let h0 = repo.commit("base", &[]).unwrap(); write(root, "a.txt", b"one\nTWO\n"); let h1 = repo.commit("edit two", &[]).unwrap(); write(root, "a.txt", b"one\nTWO\nthree\n"); let h2 = repo.commit("add three", &[]).unwrap(); let id1 = repo.store().get_change(&h1).unwrap().change_id; // Fixing the line h1 introduced folds into h1; h2 is rewritten on top. write(root, "a.txt", b"one\nTWO!\nthree\n"); let h1b = repo.amend_into(&h1, None).unwrap(); assert_ne!(h1b, h1); let c1b = repo.store().get_change(&h1b).unwrap(); assert_eq!(c1b.change_id, id1); assert_eq!(c1b.deps, vec![h0]); assert!(matches!( repo.verify_change(&h1b).unwrap(), SignatureStatus::Valid { .. } )); let heads = repo.bookmark_heads().unwrap(); assert_eq!(heads.len(), 1); let top = repo.store().get_change(&heads[0]).unwrap(); assert_eq!(top.message, "add three"); assert_eq!( top.deps.iter().copied().collect::>(), [h0, h1b].into_iter().collect(), "depends on the file's creator and the rewritten line" ); let snap = repo.load_state(&heads).unwrap().materialize(); assert!( matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\nTWO!\nthree\n") ); assert!( !repo.snapshot("status").unwrap().changed, "the working copy is clean after folding" ); // Only h1b's own state shows the fix without "three". let only = repo.load_state(&[h1b]).unwrap().materialize(); assert!( matches!(&only.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\nTWO!\n") ); assert_eq!(repo.log().unwrap().len(), 4); // An edit to a line introduced by h2 cannot fold into h1. write(root, "a.txt", b"one\nTWO!\nthree!\n"); let err = repo.amend_into(&h1b, None).unwrap_err().to_string(); assert!(err.contains("introduced after"), "{err}"); assert!( repo.snapshot("status").unwrap().statuses.len() == 1, "nothing was changed by the refusal" ); write(root, "a.txt", b"one\nTWO!\nthree\n"); // Split the head that touches two files. write(root, "b.txt", b"b\n"); write(root, "c.txt", b"c\n"); let h3 = repo.commit("b and c", &[]).unwrap(); let id3 = repo.store().get_change(&h3).unwrap().change_id; let (p1, p2) = repo.split(&h3, &["b.txt".into()]).unwrap(); let c_1 = repo.store().get_change(&p1).unwrap(); let c_2 = repo.store().get_change(&p2).unwrap(); assert!(c_1.message.contains("part 1: b.txt")); assert_eq!(c_2.change_id, id3); let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect(); assert!( heads.contains(&p1) && heads.contains(&p2), "independent parts are both heads" ); let snap = repo .load_state(&repo.bookmark_heads().unwrap()) .unwrap() .materialize(); assert!(snap.files.contains_key("b.txt") && snap.files.contains_key("c.txt")); let only1 = repo.load_state(&[p1]).unwrap().materialize(); assert!(only1.files.contains_key("b.txt") && !only1.files.contains_key("c.txt")); assert!(!repo.snapshot("status").unwrap().changed); assert!(repo.split(&p2, &["nope.txt".into()]).is_err()); let _ = h2; } #[test] fn identity_lifecycle() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = Repo::init(&root.join("origin")).unwrap(); let url = root.join("origin").to_string_lossy().to_string(); let mut a = Repo::clone(&url, &root.join("a")) .unwrap() .with_home(root.join("home-a")); a.set_user_config("core.sign", "change").unwrap(); let (key_a, _) = a .create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); write(&root.join("a"), "a.txt", b"a\n"); let h1 = a.commit("one", &[]).unwrap(); assert!(a.policy_init().unwrap().applied); assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); // Expiry: an identity that had expired when the change was made does not authenticate it. let mut e = Repo::init(&root.join("e")) .unwrap() .with_home(root.join("home-e")); e.set_user_config("core.sign", "change").unwrap(); e.create_identity_with( "Eve", "eve@example.org", IdentityScope::User, Some(Hlc::now().wall_ms - 1), ) .unwrap(); write(&root.join("e"), "e.txt", b"e\n"); let he = e.commit("late", &[]).unwrap(); assert!( matches!(e.verify_change(&he).unwrap(), SignatureStatus::Invalid(m) if m.contains("expired")) ); // Renewal re-issues the identity (same keys, new KeyId) with a later expiry. let r = e .renew_identity(Some(Hlc::now().wall_ms + 86_400_000)) .unwrap(); assert_eq!(e.identity(&r.new).unwrap().previous, Some(r.old)); write(&root.join("e"), "e.txt", b"e2\n"); let he2 = e.commit("in time", &[]).unwrap(); assert!( matches!(e.verify_change(&he2).unwrap(), SignatureStatus::Valid { key, .. } if key == r.new) ); // Rotation: a fresh key; the old change still verifies; the policy follows. let r = a.rotate_identity().unwrap(); assert_eq!(r.old, key_a); assert_eq!(a.config.author_key, Some(r.new)); assert_eq!(a.identity(&r.new).unwrap().previous, Some(key_a)); assert!(r.policy.as_ref().unwrap().applied); let (_, sp) = a.current_policy().unwrap().unwrap(); assert_eq!( (sp.policy.serial, sp.policy.roots.clone()), (2, vec![r.new]) ); assert!( !sp.policy.revoked.contains(&key_a) && sp.policy.authorizes(&r.new, "main"), "superseded, not revoked" ); assert!(sp.policy.succeeds(&r.new, &key_a) && !sp.policy.succeeds(&key_a, &r.new)); assert!( matches!(a.verify_change(&h1).unwrap(), SignatureStatus::Valid { key, revoked: false, .. } if key == key_a) ); write(&root.join("a"), "a.txt", b"a2\n"); let h2 = a.commit("two", &[]).unwrap(); assert!( matches!(a.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, revoked: false, .. } if key == r.new) ); // Origin accepts the rotated policy (signed by the old root) and the new key's change. let res = a.push("origin", false).unwrap(); assert!(res.iter().all(|x| x.ok), "{res:?}"); assert_eq!(origin.current_policy().unwrap().unwrap().1.policy.serial, 2); let key_a = r.new; // Delegation: Bob may sign on Ada's behalf. Delegates live in the // identity object, so Ada's KeyId changes while her keys stay the same. let mut b = Repo::clone(&url, &root.join("b")) .unwrap() .with_home(root.join("home-b")); let (key_b, id_b) = b .create_identity("Bob", "bob@example.org", IdentityScope::User) .unwrap(); let r = a.delegate_add(id_b.clone()).unwrap(); assert_eq!(r.old, key_a); let key_a2 = r.new; assert_eq!(a.identity(&key_a2).unwrap().delegates, vec![key_b]); assert_eq!( a.identity(&key_a2).unwrap().ed25519_pk, a.identity(&key_a).unwrap().ed25519_pk ); assert!(a.delegate_add(id_b.clone()).is_err(), "already a delegate"); assert!(a.push("origin", false).unwrap().iter().all(|x| x.ok)); write(&root.join("a"), "a.txt", b"a3\n"); let h3 = a.commit("three", &[]).unwrap(); assert!( matches!(a.verify_change(&h3).unwrap(), SignatureStatus::Valid { key, .. } if key == key_a2), "the same secret signs under the new KeyId" ); // A change authored by Ada but signed only by Bob verifies through the delegation. a.set_user_config("core.sign", "seal").unwrap(); write(&root.join("a"), "a.txt", b"a4\n"); let h4 = a.commit("four", &[]).unwrap(); assert_eq!(a.verify_change(&h4).unwrap(), SignatureStatus::Unsigned); let (_, sk_b) = b.secret_key().unwrap().unwrap(); a.store() .put_signature(&h4, &sk_b.sign(Domain::Change, &key_b, &h4)) .unwrap(); assert!( matches!(a.verify_change(&h4).unwrap(), SignatureStatus::Valid { key, .. } if key == key_b) ); a.set_user_config("core.sign", "change").unwrap(); // Withdrawing the delegation re-issues the identity again. let r = a.delegate_remove(&key_b).unwrap(); assert!(a.identity(&r.new).unwrap().delegates.is_empty()); assert!(a.delegate_remove(&key_b).is_err()); let key_a3 = r.new; let res = a.push("origin", false).unwrap(); assert!(res.iter().all(|x| x.ok), "{res:?}"); // Multi-signer policy: Bob becomes a root and the threshold rises to 2, // both applied under the old threshold of 1. A grant then needs Bob too. assert!(a.policy_root_add(id_b.clone()).unwrap().applied); assert!(a.policy_root_add(id_b.clone()).is_err()); assert!(a.policy_set_threshold(3).is_err()); assert!(a.policy_set_threshold(2).unwrap().applied); assert!(a.push("origin", false).unwrap().iter().all(|x| x.ok)); b.pull("origin").unwrap(); assert_eq!( b.current_policy().unwrap().unwrap().1.policy.root_threshold, 2 ); let mut c = Repo::init(&root.join("c")) .unwrap() .with_home(root.join("home-c")); let (key_c, id_c) = c .create_identity("Cy", "cy@example.org", IdentityScope::User) .unwrap(); let serial_before = a.current_policy().unwrap().unwrap().1.policy.serial; let proposal = a.policy_grant(id_c, None).unwrap(); assert!(!proposal.applied && proposal.sigs.len() == 1); assert_eq!(proposal.policy.serial, serial_before + 1); assert_eq!( a.current_policy().unwrap().unwrap().1.policy.serial, serial_before, "a proposal changes nothing yet" ); // Bob signs the proposal, handed over as a file, and it applies for him. let bytes = proposal.signed().encode(); let signed = b .policy_sign(SignedPolicy::decode(&bytes).unwrap()) .unwrap(); assert!(signed.applied && signed.sigs.len() == 2); assert!(b .current_policy() .unwrap() .unwrap() .1 .policy .authorizes(&key_c, "main")); assert!( b.policy_sign(signed.signed()).is_err(), "a stale proposal is refused" ); // Bob publishes it and Ada picks it up. assert!(b .push("origin", false) .unwrap() .iter() .any(|x| x.name == "policy" && x.ok)); a.pull("origin").unwrap(); assert_eq!( a.current_policy().unwrap().unwrap().1.policy.serial, serial_before + 1 ); // A root cannot revoke itself; revoking Cy needs both roots but is recorded locally at once. assert!(a.policy_revoke(&key_a3).is_err()); let u = a .revoke_identity(&key_c, "left the project") .unwrap() .unwrap(); assert!(!u.applied); assert!(a.is_revoked(&key_c).unwrap()); assert!(!b.is_revoked(&key_c).unwrap()); let u = b.policy_sign(u.signed()).unwrap(); assert!(u.applied && u.policy.is_revoked(&key_c)); assert!(b.is_revoked(&key_c).unwrap()); // Bob can revoke Ada's root key, leaving himself as the only root (threshold 2 would be unmeetable). assert!( b.policy_revoke(&key_a3).is_err(), "would leave one root for a threshold of 2" ); // Revoking one's own key without a policy root works locally: a plain marker plus attestation. let key_c_own = c.config.author_key.unwrap(); assert!(c .revoke_identity(&key_c_own, "lost laptop") .unwrap() .is_none()); assert!(c.is_revoked(&key_c_own).unwrap()); assert_eq!(key_c_own, key_c); } #[test] fn renames_are_detected_and_tracked_as_moves() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write( root, "src/old.rs", b"fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n", ); write(root, "img.bin", b"\x89PNG\0\0\x01\x02"); write(root, "tiny.txt", b"x\n"); let h1 = repo.commit("base", &[]).unwrap(); let node_old = repo.load_state(&[h1]).unwrap().materialize().files["src/old.rs"].node; let node_img = repo.load_state(&[h1]).unwrap().materialize().files["img.bin"].node; // Rename with a small edit, a binary moved unchanged, and a rewrite // that shares nothing with the removed file. fs::remove_file(root.join("src/old.rs")).unwrap(); write( root, "lib/new.rs", b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n", ); fs::remove_file(root.join("img.bin")).unwrap(); write(root, "assets/img.bin", b"\x89PNG\0\0\x01\x02"); fs::remove_file(root.join("tiny.txt")).unwrap(); write(root, "other.txt", b"completely different\n"); let snap = repo.snapshot("status").unwrap(); let statuses: Vec<(&str, ChangeStatus)> = snap .statuses .iter() .map(|s| (s.path.as_str(), s.status)) .collect(); assert_eq!( statuses, vec![ ("assets/img.bin", ChangeStatus::Renamed), ("lib/new.rs", ChangeStatus::Renamed), ("other.txt", ChangeStatus::Added), ("src", ChangeStatus::Removed), ("tiny.txt", ChangeStatus::Removed) ] ); let h2 = repo.commit("move things", &[]).unwrap(); let c = repo.store().get_change(&h2).unwrap(); let moves: Vec<_> = c .tree_ops .iter() .filter(|op| matches!(op, TreeOp::Move { .. })) .collect(); assert_eq!(moves.len(), 2, "{:?}", c.tree_ops); assert!(c.tree_ops.iter().any( |op| matches!(op, TreeOp::Move { node, name, .. } if *node == node_old && name == "new.rs") )); assert!(c.tree_ops.iter().any( |op| matches!(op, TreeOp::Move { node, name, .. } if *node == node_img && name == "img.bin") )); let snap = repo.load_state(&[h2]).unwrap().materialize(); assert_eq!( snap.files["lib/new.rs"].node, node_old, "the node survives the rename" ); assert!( matches!(&snap.files["lib/new.rs"].content, FileContent::Text { bytes, .. } if bytes == b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n") ); assert!(!snap.files.contains_key("src/old.rs") && !snap.files.contains_key("src")); assert!(render_change( &c, &repo.load_state(&[h1]).unwrap(), &repo.load_state(&[h1]).unwrap().materialize() ) .contains("R ")); // A fresh checkout reproduces the tree. let other = tempfile::tempdir().unwrap(); let clone = Repo::clone(&root.to_string_lossy(), other.path()).unwrap(); assert_eq!( fs::read(other.path().join("lib/new.rs")).unwrap(), b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n" ); assert!(!other.path().join("src").exists()); assert!(!clone.snapshot("status").unwrap().changed); // Similarity is line based, byte weighted. assert_eq!(similarity(b"a\nb\n", b"a\nb\n"), 100); assert_eq!(similarity(b"a\nb\nc\nd\n", b"a\nb\nx\ny\n"), 50); assert_eq!(similarity(b"hello\n", b"world\n"), 0); assert_eq!(similarity(b"", b"x\n"), 0); } #[test] fn git_fetch_and_push_round_trip() { hermetic(); if std::process::Command::new("git") .arg("--version") .output() .is_err() { eprintln!("git not installed; skipping"); return; } let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let git_in = |d: &Path, args: &[&str]| -> String { let out = std::process::Command::new("git") .arg("-C") .arg(d) .args([ "-c", "user.name=Grace", "-c", "user.email=grace@example.org", "-c", "commit.gpgsign=false", ]) .args(args) .output() .unwrap(); assert!( out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_string() }; // A bare "remote" and a work tree that pushes to it. let remote = root.join("remote.git"); fs::create_dir_all(&remote).unwrap(); git_in(&remote, &["init", "-q", "--bare", "-b", "main"]); let work = root.join("work"); git_in( root, &[ "clone", "-q", remote.to_str().unwrap(), work.to_str().unwrap(), ], ); git_in(&work, &["checkout", "-q", "-b", "main"]); write(&work, "README.md", b"# Project\n"); write(&work, "src/lib.rs", b"pub fn a() {}\n"); git_in(&work, &["add", "."]); git_in(&work, &["commit", "-q", "-m", "initial"]); write(&work, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\n"); git_in(&work, &["commit", "-q", "-am", "second"]); git_in(&work, &["push", "-q", "origin", "main"]); let tip1 = git_in(&work, &["rev-parse", "HEAD"]); // Fetch into a fresh oxy repository. let o = root.join("oxy"); let mut repo = Repo::init(&o).unwrap().with_home(root.join("home")); repo.create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); let url = remote.to_string_lossy().to_string(); let r = repo.git_fetch(&url, "main").unwrap(); assert_eq!((r.commits, r.heads), (2, 1)); assert_eq!( fs::read(o.join("src/lib.rs")).unwrap(), b"pub fn a() {}\npub fn b() {}\n" ); assert!(!repo.snapshot("status").unwrap().changed); assert_eq!( repo.git_fetch(&url, "main").unwrap().commits, 0, "nothing new" ); // An oxy change, pushed back: one commit on top of the Git tip. write( &o, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\npub fn c() {}\n", ); write(&o, "NOTES.md", b"from oxy\n"); let h_oxy = repo.commit("add c and notes", &[]).unwrap(); let r = repo.git_push(&url, "main").unwrap(); assert_eq!(r.exported, 1, "{r:?}"); git_in(&work, &["pull", "-q", "--ff-only", "origin", "main"]); assert_eq!( git_in(&work, &["log", "--format=%s", "main"]), "add c and notes\nsecond\ninitial" ); assert_eq!(git_in(&work, &["rev-parse", "HEAD~1"]), tip1); assert_eq!(fs::read(work.join("NOTES.md")).unwrap(), b"from oxy\n"); assert_eq!(git_in(&work, &["log", "-1", "--format=%an", "main"]), "Ada"); assert!(git_in(&work, &["log", "-1", "--format=%B", "main"]) .contains(&format!("Oxy-Hash: {}", h_oxy.to_hex()))); assert_eq!( repo.git_push(&url, "main").unwrap().exported, 0, "already pushed" ); // Git moves on; pushing without fetching is refused, fetching brings // the commit in and the working copy follows. write(&work, "README.md", b"# Project\n\nMore.\n"); git_in(&work, &["commit", "-q", "-am", "readme from git"]); git_in(&work, &["push", "-q", "origin", "main"]); let tip2 = git_in(&work, &["rev-parse", "HEAD"]); assert!(repo .git_push(&url, "main") .unwrap_err() .to_string() .contains("fetch")); let r = repo.git_fetch(&url, "main").unwrap(); assert_eq!( (r.commits, r.heads), (1, 2), "the Git change and the oxy change commute" ); assert_eq!( fs::read(o.join("README.md")).unwrap(), b"# Project\n\nMore.\n" ); assert_eq!(fs::read(o.join("NOTES.md")).unwrap(), b"from oxy\n"); assert!(!repo.snapshot("status").unwrap().changed); // Another oxy change pushes on top of Git's newest commit. write(&o, "NOTES.md", b"from oxy\nagain\n"); repo.commit("notes again", &[]).unwrap(); assert_eq!(repo.git_push(&url, "main").unwrap().exported, 1); git_in(&work, &["pull", "-q", "--ff-only", "origin", "main"]); assert_eq!(git_in(&work, &["rev-parse", "HEAD~1"]), tip2); assert_eq!( fs::read(work.join("NOTES.md")).unwrap(), b"from oxy\nagain\n" ); assert_eq!( git_in(&work, &["log", "--format=%s", "main"]) .lines() .count(), 5 ); } /// Benchmark, not a gate: `cargo test --release -p oxy-core --lib bench_one_gibibyte -- --ignored --nocapture`. /// Commits, stores, clones, and reads back a 1 GiB incompressible file. #[test] #[ignore] fn bench_one_gibibyte_file() { hermetic(); // /tmp is often a small tmpfs; `OXY_BENCH_DIR` picks a roomier disk. let dir = match std::env::var_os("OXY_BENCH_DIR") { Some(d) => tempfile::tempdir_in(d).unwrap(), None => tempfile::tempdir().unwrap(), }; let root = dir.path(); let repo = Repo::init(&root.join("r")) .unwrap() .with_home(root.join("home")); let size = 1usize << 30; let mut data = vec![0u8; size]; let mut x = 0x9e3779b97f4a7c15u64; for chunk in data.chunks_mut(8) { x ^= x << 13; x ^= x >> 7; x ^= x << 17; let b = x.to_le_bytes(); chunk.copy_from_slice(&b[..chunk.len()]); } let t = std::time::Instant::now(); write(&root.join("r"), "big.bin", &data); eprintln!("write 1 GiB to disk: {:.2?}", t.elapsed()); let t = std::time::Instant::now(); let h = repo.commit("big", &[]).unwrap(); eprintln!("commit (chunk + hash + store): {:.2?}", t.elapsed()); let t = std::time::Instant::now(); assert!(!repo.snapshot("status").unwrap().changed); eprintln!("status on a clean 1 GiB working copy: {:.2?}", t.elapsed()); let t = std::time::Instant::now(); let objects = repo.store().list().unwrap().len(); eprintln!("objects stored: {objects} ({:.2?} to list)", t.elapsed()); let t = std::time::Instant::now(); let g = repo.gc(true).unwrap(); eprintln!("gc (pack): {:.2?} {g:?}", t.elapsed()); let t = std::time::Instant::now(); let clone = Repo::clone(&root.join("r").to_string_lossy(), &root.join("c")).unwrap(); eprintln!("clone (fetch + checkout): {:.2?}", t.elapsed()); let t = std::time::Instant::now(); let back = fs::read(root.join("c/big.bin")).unwrap(); assert_eq!(blake3::hash(&back), blake3::hash(&data)); eprintln!("read back and verify: {:.2?}", t.elapsed()); assert_eq!(clone.bookmark_heads().unwrap(), vec![h]); let store_bytes: u64 = walkdir_size(&root.join("r/.oxy/objects")); eprintln!( "store size: {:.1} MiB for 1 GiB of data", store_bytes as f64 / (1024.0 * 1024.0) ); } fn walkdir_size(p: &Path) -> u64 { let mut total = 0; if let Ok(rd) = fs::read_dir(p) { for e in rd.flatten() { let path = e.path(); if path.is_dir() { total += walkdir_size(&path); } else if let Ok(m) = fs::metadata(&path) { total += m.len(); } } } total } /// An opaque file whose size and mtime match the manifest is not read on /// the next snapshot: its blob comes from the manifest's stat cache. #[cfg(unix)] #[test] fn stat_cache_skips_reading_unchanged_opaque_files() { use std::os::unix::fs::PermissionsExt; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); let mut big = vec![0u8; 300 * 1024]; for (i, b) in big.iter_mut().enumerate() { *b = (i as u32).wrapping_mul(2654435761).to_le_bytes()[1]; } big[0] = 0; write(root, "big.bin", &big); write(root, "a.txt", b"text\n"); let h1 = repo.commit("base", &[]).unwrap(); let (_, _, m) = repo.read_manifest().unwrap().unwrap(); assert!( m.entries["big.bin"].blob.is_some(), "opaque files record their blob" ); assert!(m.entries["a.txt"].blob.is_none()); let blob = m.entries["big.bin"].blob.unwrap(); assert!(repo.store().has(&blob)); // Files modified within two seconds of the manifest are racy and still // read; age the file so the cache applies, then snapshot to record it. let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30); fs::File::options() .write(true) .open(root.join("big.bin")) .unwrap() .set_modified(old) .unwrap(); assert!(!repo.snapshot("status").unwrap().changed); // Now the file cannot be read at all; the snapshot succeeds without it. fs::set_permissions(root.join("big.bin"), fs::Permissions::from_mode(0o000)).unwrap(); let snap = repo.snapshot("status").unwrap(); assert!( !snap.changed && snap.statuses.is_empty(), "{:?}", snap.statuses ); fs::set_permissions(root.join("big.bin"), fs::Permissions::from_mode(0o644)).unwrap(); // A real change (new mtime, new content) is seen and committed correctly. let mut big2 = big.clone(); big2[10] ^= 0xff; write(root, "big.bin", &big2); let snap = repo.snapshot("status").unwrap(); assert_eq!( snap.statuses .iter() .map(|s| (s.path.as_str(), s.status)) .collect::>(), vec![("big.bin", ChangeStatus::Modified)] ); let h2 = repo.commit("edit", &[]).unwrap(); let files = repo.load_state(&[h2]).unwrap().materialize().files; assert_eq!( files["big.bin"].content, FileContent::Opaque { blobs: vec![Repo::blob_ref(&big2)] } ); // Checkout writes a manifest with blobs too, so the next status is cached. repo.checkout(&[h1], false, "checkout").unwrap(); let (_, _, m) = repo.read_manifest().unwrap().unwrap(); assert_eq!(m.entries["big.bin"].blob, Some(Repo::blob_ref(&big))); assert!(!repo.snapshot("status").unwrap().changed); } #[test] fn status_json_lists_conflicts_with_contributors() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"one\n"); let h1 = repo.commit("base", &[]).unwrap(); write(root, "a.txt", b"one\nleft\n"); let ha = repo.commit("left", &[]).unwrap(); repo.checkout(&[h1], true, "checkout").unwrap(); write(root, "a.txt", b"one\nright\n"); let hb = repo.commit("right", &[]).unwrap(); repo.checkout(&[ha, hb], true, "checkout").unwrap(); let snap = repo.snapshot("status").unwrap(); assert!(snap.base_snapshot.is_conflicted()); let json = repo.status_json(&snap).unwrap(); let ida = repo.store().get_change(&ha).unwrap().change_id.to_hex(); let idb = repo.store().get_change(&hb).unwrap().change_id.to_hex(); assert!(json.contains("\"conflicted\":true"), "{json}"); assert!( json.contains("\"kind\":\"text\",\"path\":\"a.txt\""), "{json}" ); assert!(json.contains(&ida) && json.contains(&idb), "{json}"); assert!(json.contains("\"resolved\":false"), "{json}"); assert!(json.starts_with('{') && json.ends_with('}')); // Resolve and the report says so; a clean copy has no conflicts. write(root, "a.txt", b"one\nleft\nright\n"); let snap = repo.snapshot("status").unwrap(); let json = repo.status_json(&snap).unwrap(); assert!(json.contains("\"resolved\":true"), "{json}"); repo.commit("resolve", &[]).unwrap(); let snap = repo.snapshot("status").unwrap(); let json = repo.status_json(&snap).unwrap(); assert!( json.contains("\"clean\":true,\"conflicted\":false,\"paths\":[],\"conflicts\":[]"), "{json}" ); } #[test] fn ignore_rules_do_not_untrack_files() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "src.c", b"int x;\n"); write(root, "build/out.o", b"\0obj"); write(root, "sub/keep.log", b"keep\n"); write(root, "sub/junk.log", b"junk\n"); write(root, ".oxyignore", b"/build\n*.log\n!sub/keep.log\n"); let snap = repo.snapshot("status").unwrap(); let added: Vec<&str> = snap.statuses.iter().map(|s| s.path.as_str()).collect(); assert_eq!( added, vec![".oxyignore", "src.c", "sub/keep.log"], "anchored, glob, and negated rules" ); let h1 = repo.commit("base", &[]).unwrap(); // Ignoring a tracked file changes nothing about it. write(root, ".oxyignore", b"/build\n*.log\n!sub/keep.log\nsrc.c\n"); let snap = repo.snapshot("status").unwrap(); assert_eq!( snap.statuses .iter() .map(|s| s.path.as_str()) .collect::>(), vec![".oxyignore"] ); let h2 = repo.commit("ignore src.c", &[]).unwrap(); assert!(repo .load_state(&[h2]) .unwrap() .materialize() .files .contains_key("src.c")); // Its edits are still seen, by status and by the prompt. assert_eq!(repo.quick_dirty().unwrap(), Some(false)); write(root, "src.c", b"int x, y;\n"); assert_eq!(repo.quick_dirty().unwrap(), Some(true)); let snap = repo.snapshot("status").unwrap(); assert_eq!( snap.statuses .iter() .map(|s| (s.path.as_str(), s.status)) .collect::>(), vec![("src.c", ChangeStatus::Modified)] ); let h3 = repo.commit("edit src.c", &[]).unwrap(); assert!( matches!(&repo.load_state(&[h3]).unwrap().materialize().files["src.c"].content, FileContent::Text { bytes, .. } if bytes == b"int x, y;\n") ); // Deleting it on disk removes it, ignored or not; a checkout brings it back. fs::remove_file(root.join("src.c")).unwrap(); let snap = repo.snapshot("status").unwrap(); assert_eq!( snap.statuses .iter() .map(|s| (s.path.as_str(), s.status)) .collect::>(), vec![("src.c", ChangeStatus::Removed)] ); repo.checkout(&[h3], true, "checkout").unwrap(); assert_eq!(fs::read(root.join("src.c")).unwrap(), b"int x, y;\n"); assert!(!repo.snapshot("status").unwrap().changed); let _ = h1; } #[test] fn split_works_on_non_heads_by_node() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"one\n"); write(root, "junk/x.lock", b"lock\n"); let h1 = repo.commit("work plus junk", &[]).unwrap(); write(root, "a.txt", b"one\ntwo\n"); let h2 = repo.commit("more work", &[]).unwrap(); assert_eq!(repo.store().get_change(&h2).unwrap().deps, vec![h1]); let id1 = repo.store().get_change(&h1).unwrap().change_id; // h1 is not a head: split it by node. let (hj, h1b) = repo.split(&h1, &["junk".into()]).unwrap(); assert_ne!(h1b, h1); assert_eq!( repo.store().get_change(&h1b).unwrap().change_id, id1, "the rest keeps the ChangeId" ); let junk = repo.store().get_change(&hj).unwrap(); assert!(junk.tree_ops.iter().all( |op| matches!(op, TreeOp::AddNode { name, .. } if name == "junk" || name == "x.lock") )); let heads = repo.bookmark_heads().unwrap(); assert!( heads.contains(&hj), "the split-off part is a head of its own" ); let snap = repo.load_state(&heads).unwrap().materialize(); assert!( matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n"), "the dependent was rewritten onto the new hash" ); assert!(snap.files.contains_key("junk/x.lock")); let h2b = heads .iter() .find(|h| repo.store().get_change(h).unwrap().message == "more work") .copied() .unwrap(); assert_eq!(repo.store().get_change(&h2b).unwrap().deps, vec![h1b]); assert!(!repo.snapshot("status").unwrap().changed); // Abandoning the split-off head removes the junk from history. repo.abandon(&hj).unwrap(); let heads = repo.bookmark_heads().unwrap(); let snap = repo.load_state(&heads).unwrap().materialize(); assert!(!snap.files.contains_key("junk/x.lock") && !snap.files.contains_key("junk")); assert!(snap.files.contains_key("a.txt")); // A dependent that touches the split-off paths blocks the split. write(root, "junk/y.lock", b"y\n"); let hb = repo.commit("junk base", &[]).unwrap(); write(root, "junk/y.lock", b"yy\n"); write(root, "b.txt", b"b\n"); repo.commit("touches junk", &[]).unwrap(); let err = repo.split(&hb, &["junk".into()]).unwrap_err().to_string(); assert!(err.contains("depends on the split-off paths"), "{err}"); } #[test] fn bookmarks_switch_merge_and_push() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = Repo::init(&root.join("origin")).unwrap(); let url = root.join("origin").to_string_lossy().to_string(); let mut repo = Repo::clone(&url, &root.join("r")) .unwrap() .with_home(root.join("home")); let r = root.join("r"); write(&r, "a.txt", b"a\n"); let h_a = repo.commit("a", &[]).unwrap(); assert!(repo.push("origin", false).unwrap()[0].ok); // A bookmark at the working copy's base, then work on it. assert!(repo.bookmark_create("bad name..", None).is_err()); assert!(repo.bookmark_create("-x", None).is_err()); assert_eq!(repo.bookmark_create("feature", None).unwrap(), vec![h_a]); assert!(repo.bookmark_create("feature", None).is_err(), "exists"); repo.bookmark_switch("feature", false).unwrap(); assert_eq!(repo.config.bookmark, "feature"); write(&r, "a.txt", b"a\nb\n"); write(&r, "b.txt", b"b\n"); let h_b = repo.commit("b on feature", &[]).unwrap(); assert_eq!( repo.bookmark_heads().unwrap(), vec![h_b], "b edits a.txt, so it depends on a" ); assert_eq!( repo.resolve_heads("main").unwrap(), vec![h_a], "main is untouched" ); assert_eq!( repo.resolve_change("feature").unwrap(), h_b, "a one-head bookmark names its change" ); // Reopening keeps the workspace's bookmark. let reopened = Repo::open(&r).unwrap().with_home(root.join("home")); assert_eq!(reopened.config.bookmark, "feature"); // Back to main: b.txt goes away; work there. repo.bookmark_switch("main", false).unwrap(); assert!(!r.join("b.txt").exists()); assert_eq!(fs::read(r.join("a.txt")).unwrap(), b"a\n"); write(&r, "c.txt", b"c\n"); let h_c = repo.commit("c on main", &[]).unwrap(); let list = repo.bookmarks().unwrap(); assert_eq!( list.iter() .map(|b| (b.name.as_str(), b.current)) .collect::>(), vec![("feature", false), ("main", true)] ); assert_eq!( list.iter().find(|b| b.name == "main").unwrap().ahead, Some(1), "c is not on origin yet" ); // Merge feature into main: the union, both heads, no merge change. let m = repo.merge("feature").unwrap(); assert!(m.changed && m.checked_out); let mut want = vec![h_b, h_c]; want.sort(); assert_eq!(m.heads, want); assert_eq!(fs::read(r.join("b.txt")).unwrap(), b"b\n"); assert!(!repo.merge("feature").unwrap().changed, "already merged"); assert!(repo.merge("main").is_err()); // The other bookmark can be pushed by name and appears at origin. assert!(repo.push_bookmark("origin", "feature", false).unwrap()[0].ok); assert_eq!(origin.resolve_heads("feature").unwrap(), vec![h_b]); assert!(repo.push("origin", false).unwrap()[0].ok); assert_eq!( repo.bookmarks() .unwrap() .iter() .find(|b| b.name == "main") .unwrap() .ahead, Some(0) ); // A second workspace inherits the current bookmark and can switch independently. let ws = repo.workspace_add(&root.join("ws"), None).unwrap(); assert_eq!(ws.config.bookmark, "main"); let mut ws = ws; ws.bookmark_switch("feature", false).unwrap(); assert_eq!( Repo::open(&r).unwrap().config.bookmark, "main", "the first workspace is unaffected" ); assert!( repo.bookmark_delete("feature").is_err(), "current bookmark of another workspace" ); ws.bookmark_switch("main", false).unwrap(); // Set and delete. assert_eq!(repo.bookmark_set("feature", &[h_a]).unwrap(), vec![h_a]); assert!(repo.bookmark_delete("main").is_err(), "current here"); repo.bookmark_delete("feature").unwrap(); assert!(repo.resolve_heads("feature").is_err()); assert!(repo.bookmark_set("feature", &[h_a]).is_err()); assert!( repo.checkout(&[h_a], false, "checkout").is_ok(), "changes of a deleted bookmark remain" ); } #[test] fn oxygen_subrepos_pin_follow_and_update() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); // A library repository with two commits, and a bare-ish origin for it. let lib_origin = Repo::init(&root.join("lib-origin")).unwrap(); let lib_url = root.join("lib-origin").to_string_lossy().to_string(); let lib = Repo::clone(&lib_url, &root.join("lib")) .unwrap() .with_home(root.join("home")); write(&root.join("lib"), "lib.txt", b"v1\n"); let l1 = lib.commit("lib v1", &[]).unwrap(); assert!(lib.push("origin", false).unwrap()[0].ok); let _ = lib_origin; // The application (a clone of its own origin) adds it as a subrepo. let app_origin = Repo::init(&root.join("app-origin")).unwrap(); let app_url = root.join("app-origin").to_string_lossy().to_string(); let _ = app_origin; let app_dir = root.join("app"); let app = Repo::clone(&app_url, &app_dir) .unwrap() .with_home(root.join("home")); write(&app_dir, "main.txt", b"app\n"); let pin = app.submodule_add(&lib_url, "libs/lib", None).unwrap(); assert!(matches!(&pin, crate::subrepo::Pin::Oxy { heads, .. } if heads == &vec![l1])); assert!(app_dir.join(".oxymodules").exists() && app_dir.join("libs/lib/.oxy").is_dir()); let snap = app.snapshot("status").unwrap(); let paths: Vec<&str> = snap.statuses.iter().map(|s| s.path.as_str()).collect(); assert_eq!( paths, vec![".oxymodules", "libs/lib", "main.txt"], "the nested files are not walked" ); let a1 = app.commit("app with lib", &[]).unwrap(); let files = app.load_state(&[a1]).unwrap().materialize().files; assert_eq!(files["libs/lib"].kind, NodeKind::Subrepo); assert!(!files.contains_key("libs/lib/lib.txt")); assert!(!app.snapshot("status").unwrap().changed); let subs = app.subrepos().unwrap(); assert_eq!(subs.len(), 1); assert_eq!(subs[0].actual.as_ref(), Some(&subs[0].pin)); // Advance the nested repository: the parent sees a modified pin. let nested = Repo::open(&app_dir.join("libs/lib")) .unwrap() .with_home(root.join("home")); write(&app_dir.join("libs/lib"), "lib.txt", b"v2\n"); let l2 = nested.commit("lib v2", &[]).unwrap(); assert!(nested.push("origin", false).unwrap()[0].ok); let snap = app.snapshot("status").unwrap(); assert_eq!( snap.statuses .iter() .map(|s| (s.path.as_str(), s.status)) .collect::>(), vec![("libs/lib", ChangeStatus::Modified)] ); let a2 = app.commit("bump lib", &[]).unwrap(); // Checking out the older app state moves the nested repository back. let r = app.checkout(&[a1], false, "checkout").unwrap(); assert!(r.notes.is_empty(), "{:?}", r.notes); assert_eq!(fs::read(app_dir.join("libs/lib/lib.txt")).unwrap(), b"v1\n"); assert_eq!( Repo::open(&app_dir.join("libs/lib")) .unwrap() .bookmark_heads() .unwrap(), vec![l1] ); assert!(!app.snapshot("status").unwrap().changed); // A dirty nested repository is left alone and reported. write(&app_dir.join("libs/lib"), "scratch.txt", b"wip\n"); let r = app.checkout(&[a2], false, "checkout").unwrap(); assert!( r.notes.iter().any(|n| n.contains("uncommitted")), "{:?}", r.notes ); assert_eq!(fs::read(app_dir.join("libs/lib/lib.txt")).unwrap(), b"v1\n"); fs::remove_file(app_dir.join("libs/lib/scratch.txt")).unwrap(); let r = app.submodule_update(false, &[]).unwrap(); assert_eq!(r, vec![("libs/lib".to_string(), SubrepoSync::Updated)]); assert_eq!(fs::read(app_dir.join("libs/lib/lib.txt")).unwrap(), b"v2\n"); let _ = l2; // A fresh clone of the app has the pin but no nested repository until update --init. assert!(app.push("origin", false).unwrap()[0].ok); let clone_dir = root.join("app-clone"); let clone = Repo::clone(&app_url, &clone_dir) .unwrap() .with_home(root.join("home")); assert!(!clone_dir.join("libs/lib/.oxy").exists()); assert_eq!( clone.submodule_update(false, &[]).unwrap(), vec![("libs/lib".to_string(), SubrepoSync::Missing)] ); assert_eq!( clone.submodule_update(true, &[]).unwrap(), vec![("libs/lib".to_string(), SubrepoSync::Updated)] ); assert_eq!( fs::read(clone_dir.join("libs/lib/lib.txt")).unwrap(), b"v2\n" ); assert!(!clone.snapshot("status").unwrap().changed); // Removing the subrepo leaves the directory and drops the node. clone.submodule_remove("libs/lib").unwrap(); let snap = clone.snapshot("status").unwrap(); assert!( snap.statuses .iter() .any(|s| s.path == "libs/lib" && s.status == ChangeStatus::Removed), "{:?}", snap.statuses ); let c = clone.commit("drop lib", &[]).unwrap(); assert!(!clone .load_state(&[c]) .unwrap() .materialize() .files .contains_key("libs/lib")); assert!(clone_dir.join("libs/lib/lib.txt").exists()); assert!( !clone.snapshot("status").unwrap().changed, "an unregistered nested repository is ignored" ); } #[test] fn git_submodules_import_and_export_as_gitlinks() { hermetic(); if std::process::Command::new("git") .arg("--version") .output() .is_err() { eprintln!("git not installed; skipping"); return; } let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let git_in = |d: &Path, args: &[&str]| -> String { let out = std::process::Command::new("git") .arg("-C") .arg(d) .args([ "-c", "user.name=Grace", "-c", "user.email=grace@example.org", "-c", "commit.gpgsign=false", "-c", "protocol.file.allow=always", ]) .args(args) .output() .unwrap(); assert!( out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_string() }; let sub = root.join("sub"); fs::create_dir_all(&sub).unwrap(); git_in(&sub, &["init", "-q", "-b", "main"]); write(&sub, "s.txt", b"sub\n"); git_in(&sub, &["add", "."]); git_in(&sub, &["commit", "-q", "-m", "sub initial"]); let sub_sha = git_in(&sub, &["rev-parse", "HEAD"]); let g = root.join("main.git"); fs::create_dir_all(&g).unwrap(); git_in(&g, &["init", "-q", "-b", "main"]); write(&g, "README.md", b"# app\n"); git_in(&g, &["add", "."]); git_in(&g, &["commit", "-q", "-m", "initial"]); git_in( &g, &[ "submodule", "add", "-q", sub.to_str().unwrap(), "vendor/sub", ], ); git_in(&g, &["commit", "-q", "-m", "add submodule"]); let dest = root.join("oxy"); let repo = Repo::init(&dest).unwrap().with_home(root.join("home")); let rep = repo.import_git(&g, "main").unwrap(); assert_eq!((rep.commits, rep.submodules), (2, 1)); let files = repo .load_state(&repo.bookmark_heads().unwrap()) .unwrap() .materialize() .files; assert_eq!(files["vendor/sub"].kind, NodeKind::Subrepo); let FileContent::Opaque { blobs } = &files["vendor/sub"].content else { panic!() }; assert_eq!( crate::subrepo::Pin::decode(&repo.read_blob(&blobs[0]).unwrap()).unwrap(), crate::subrepo::Pin::Git { sha: sub_sha.clone(), branch: "HEAD".into() } ); let modules = crate::subrepo::Modules::parse(&fs::read_to_string(dest.join(".oxymodules")).unwrap()); assert_eq!(modules.entries["vendor/sub"].url, sub.to_string_lossy()); assert!(modules.entries["vendor/sub"].git); assert!( files.contains_key(".gitmodules"), "the Git file is kept for round trips" ); assert!( !dest.join("vendor/sub/.git").exists(), "not cloned until update --init" ); assert!(!repo.snapshot("status").unwrap().changed); // update --init clones it at the pinned commit; status is unchanged. assert_eq!( repo.submodule_update(true, &[]).unwrap(), vec![("vendor/sub".to_string(), SubrepoSync::Updated)] ); assert_eq!( git_in(&dest.join("vendor/sub"), &["rev-parse", "HEAD"]), sub_sha ); assert!(!repo.snapshot("status").unwrap().changed); // Export writes the gitlink and .gitmodules back. let mirror = root.join("mirror.git"); let rep = repo.git_export(&mirror).unwrap(); assert_eq!(rep.skipped_subrepos, 0); let ls = git_in(&mirror, &["ls-tree", "main", "vendor/sub"]); assert!( ls.starts_with("160000 commit ") && ls.contains(&sub_sha), "{ls}" ); assert_eq!( git_in(&mirror, &["ls-tree", "--name-only", "main"]), ".gitmodules\nREADME.md\nvendor" ); let orig_tree = git_in(&g, &["rev-parse", "main^{tree}"]); assert_eq!( git_in(&mirror, &["rev-parse", "main^{tree}"]), orig_tree, "tree round trip" ); } #[test] fn partial_commits_by_path_and_by_hunk() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"1\n2\n3\n"); write(root, "b.txt", b"b\n"); let h0 = repo.commit("base", &[]).unwrap(); write(root, "a.txt", b"0\n1\n2\n3\n4\n"); write(root, "b.txt", b"b\nbb\n"); write(root, "c.txt", b"c\n"); // By path: only a.txt goes in; the rest stays on disk as uncommitted work. let h1 = repo .commit_selection("a only", &[], &Selection::Paths(vec!["a.txt".into()])) .unwrap(); let c1 = repo.store().get_change(&h1).unwrap(); assert!( c1.tree_ops.is_empty() && c1.edits.iter().all(|e| e.node() == c1.edits[0].node()), "{:?}", c1.tree_ops ); assert_eq!(c1.deps, vec![h0]); let files = repo.load_state(&[h1]).unwrap().materialize().files; assert!( matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"0\n1\n2\n3\n4\n") ); assert!(matches!(&files["b.txt"].content, FileContent::Text { bytes, .. } if bytes == b"b\n")); assert!(!files.contains_key("c.txt")); let snap = repo.snapshot("status").unwrap(); assert_eq!( snap.statuses .iter() .map(|s| (s.path.as_str(), s.status)) .collect::>(), vec![ ("b.txt", ChangeStatus::Modified), ("c.txt", ChangeStatus::Added) ] ); assert_eq!( fs::read(root.join("b.txt")).unwrap(), b"b\nbb\n", "disk untouched" ); // By hunk: pick the b.txt insert; c.txt's addition is left. let (_, hunks) = repo.hunks().unwrap(); let b_hunk = hunks .iter() .find(|h| h.path == "b.txt" && h.text.contains("+bb")) .unwrap() .index; let h2 = repo .commit_selection("b only", &[], &Selection::Hunks(vec![b_hunk])) .unwrap(); let files = repo.load_state(&[h2]).unwrap().materialize().files; assert!( matches!(&files["b.txt"].content, FileContent::Text { bytes, .. } if bytes == b"b\nbb\n") ); assert!(!files.contains_key("c.txt")); let snap = repo.snapshot("status").unwrap(); assert_eq!( snap.statuses .iter() .map(|s| s.path.as_str()) .collect::>(), vec!["c.txt"] ); // Choosing only the content hunk of a file in a new directory pulls in // the directory and file additions it needs. write(root, "d/e.txt", b"e\n"); let (_, hunks) = repo.hunks().unwrap(); let e_insert = hunks .iter() .find(|h| h.path == "d/e.txt" && h.text.contains("+e")) .unwrap() .index; let h3 = repo .commit_selection("e only", &[], &Selection::Hunks(vec![e_insert])) .unwrap(); let c3 = repo.store().get_change(&h3).unwrap(); assert_eq!(c3.tree_ops.len(), 2, "{:?}", c3.tree_ops); let files = repo.load_state(&[h3]).unwrap().materialize().files; assert!(files.contains_key("d/e.txt") && !files.contains_key("c.txt")); // Choosing a new file's addition hunk brings its content along. write(root, "f/g.txt", b"g\n"); let (_, hunks) = repo.hunks().unwrap(); let g_add = hunks .iter() .find(|h| h.path == "f/g.txt" && h.text.starts_with("A ")) .unwrap() .index; let h4 = repo .commit_selection("g by addition", &[], &Selection::Hunks(vec![g_add])) .unwrap(); let files = repo.load_state(&[h4]).unwrap().materialize().files; assert!( matches!(&files["f/g.txt"].content, FileContent::Text { bytes, .. } if bytes == b"g\n") ); assert!(!repo .snapshot("status") .unwrap() .statuses .iter() .any(|s| s.path == "f/g.txt")); // Everything left commits normally; the working copy is then clean. repo.commit("the rest", &[]).unwrap(); assert!(!repo.snapshot("status").unwrap().changed); let files = repo .load_state(&repo.bookmark_heads().unwrap()) .unwrap() .materialize() .files; assert!(files.contains_key("c.txt")); } #[test] fn blame_attributes_lines_to_introducing_changes_across_renames() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); repo.create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); write(root, "a.txt", b"one\n"); let h1 = repo.commit("first", &[]).unwrap(); write(root, "a.txt", b"one\ntwo\n"); let h2 = repo.commit("second", &[]).unwrap(); let b = repo.blame("a.txt").unwrap(); assert_eq!( b.iter() .map(|l| (l.line, l.hash, l.text.as_slice())) .collect::>(), vec![(1, h1, b"one\n".as_slice()), (2, h2, b"two\n".as_slice())] ); assert!(b.iter().all(|l| l.author == "Ada")); assert_eq!( b[0].change_id, repo.store().get_change(&h1).unwrap().change_id ); fs::rename(root.join("a.txt"), root.join("b.txt")).unwrap(); repo.commit("rename", &[]).unwrap(); let b = repo.blame("b.txt").unwrap(); assert_eq!( b.iter().map(|l| l.hash).collect::>(), vec![h1, h2], "the node survives the rename" ); assert!(repo.blame("a.txt").is_err()); } #[test] fn bisect_finds_the_first_bad_change() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); let mut content = String::new(); let mut hashes = Vec::new(); for i in 1..=8 { content.push_str(&format!("line {i}{}\n", if i == 5 { " bug" } else { "" })); write(root, "f.txt", content.as_bytes()); hashes.push(repo.commit(&format!("step {i}"), &[]).unwrap()); } // Each step appends after the previous line, so the chain is linear. assert_eq!(repo.bookmark_heads().unwrap(), vec![hashes[7]]); repo.bisect_start().unwrap(); assert!(matches!( repo.bisect_mark(Some(true), &[hashes[0]]).unwrap(), BisectStep::NeedMarks { good: 1, bad: 0 } )); let step = repo.bisect_mark(Some(false), &[hashes[7]]).unwrap(); assert!( matches!(step, BisectStep::Testing { remaining: 7, .. }), "{step:?}" ); // Automated: bad when the bug line is present. let step = repo .bisect_run(&["sh".into(), "-c".into(), "! grep -q bug f.txt".into()]) .unwrap(); assert_eq!(step, BisectStep::Found { culprit: hashes[4] }); assert_eq!( repo.bisect_status().unwrap(), Some(BisectStep::Found { culprit: hashes[4] }) ); repo.bisect_reset().unwrap(); assert_eq!(fs::read_to_string(root.join("f.txt")).unwrap(), content); assert!(repo.bisect_status().unwrap().is_none()); assert!(!repo.snapshot("status").unwrap().changed); // Manual marks and a skip. repo.bisect_start().unwrap(); repo.bisect_mark(Some(true), &[hashes[0]]).unwrap(); let mut step = repo.bisect_mark(Some(false), &[hashes[7]]).unwrap(); let mut skipped_once = false; let mut guard = 0; while let BisectStep::Testing { .. } = step { guard += 1; assert!(guard < 20); let text = fs::read_to_string(root.join("f.txt")).unwrap(); if !skipped_once && text.contains("line 6") { // An untestable state away from the boundary: the search routes around it. skipped_once = true; step = repo.bisect_mark(None, &[]).unwrap(); continue; } step = repo.bisect_mark(Some(!text.contains("bug")), &[]).unwrap(); } assert!(skipped_once); assert_eq!(step, BisectStep::Found { culprit: hashes[4] }); repo.bisect_reset().unwrap(); // Skipping the culprit itself leaves it among the candidates reported. repo.bisect_start().unwrap(); repo.bisect_mark(Some(true), &[hashes[0]]).unwrap(); let mut step = repo.bisect_mark(Some(false), &[hashes[7]]).unwrap(); let mut guard = 0; while let BisectStep::Testing { heads, .. } = &step { guard += 1; assert!(guard < 20); let text = fs::read_to_string(root.join("f.txt")).unwrap(); let is_culprit_state = heads.contains(&hashes[4]) && !heads.iter().any(|h| hashes[5..].contains(h)); step = if is_culprit_state { repo.bisect_mark(None, &[]).unwrap() } else { repo.bisect_mark(Some(!text.contains("bug")), &[]).unwrap() }; } match step { BisectStep::Inconclusive { candidates } => assert!( candidates.contains(&hashes[4]) && candidates.len() <= 2, "{candidates:?}" ), other => panic!("{other:?}"), } repo.bisect_reset().unwrap(); } #[test] fn squash_folds_a_change_into_its_dependency() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"1\n"); let a = repo.commit("A", &[]).unwrap(); write(root, "a.txt", b"1\n2\n"); let c = repo.commit("C", &[]).unwrap(); write(root, "a.txt", b"1\n2\n3\n"); let e = repo.commit("E", &[]).unwrap(); write(root, "b.txt", b"b\n"); let b = repo.commit("B", &[]).unwrap(); let id_a = repo.store().get_change(&a).unwrap().change_id; let id_e = repo.store().get_change(&e).unwrap().change_id; // E depends on C which A lacks: refused, naming it. let err = repo.squash(&e, &a).unwrap_err().to_string(); assert!(err.contains(&c.short(12)), "{err}"); // C into A: A' has both, C is gone, E follows and still reads the same. let a2 = repo.squash(&c, &a).unwrap(); let a2c = repo.store().get_change(&a2).unwrap(); assert_eq!(a2c.change_id, id_a); assert!(a2c.deps.is_empty()); let heads = repo.bookmark_heads().unwrap(); let st = repo.load_state(&heads).unwrap(); assert!(!st.contains(&c) && !st.contains(&a) && !st.contains(&e)); let e2 = st .changes() .find(|(_, m)| m.change_id == id_e) .map(|(h, _)| *h) .unwrap(); assert_eq!( repo.store().get_change(&e2).unwrap().deps, vec![a2], "E now depends on A' alone" ); let files = st.materialize().files; assert!( matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"1\n2\n3\n") ); assert!(files.contains_key("b.txt")); assert!(st.contains(&b)); assert!(!repo.snapshot("status").unwrap().changed); assert_eq!(repo.log().unwrap().len(), 4, "working copy, B, E', A'"); // Undo restores the three original changes. repo.undo().unwrap(); repo.undo().unwrap(); let st = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap(); assert!(st.contains(&c) && st.contains(&a) && st.contains(&e)); } #[test] fn rebase_re_expresses_a_change_against_another_base() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"1\n2\n"); let a = repo.commit("A", &[]).unwrap(); write(root, "a.txt", b"1\n2\n3\n"); let c = repo.commit("C: append 3", &[]).unwrap(); write(root, "a.txt", b"1\n2\n3\n4\n"); let e = repo.commit("E: append 4", &[]).unwrap(); // A different base: A plus a change that prepends a line. repo.checkout(&[a], true, "checkout").unwrap(); write(root, "a.txt", b"0\n1\n2\n"); let d = repo.commit("D: prepend 0", &[]).unwrap(); let id_c = repo.store().get_change(&c).unwrap().change_id; let id_e = repo.store().get_change(&e).unwrap().change_id; // Rebase C onto D: its effect (append 3) re-derived against D's text. let c2 = repo.rebase(&c, &[d]).unwrap(); let c2c = repo.store().get_change(&c2).unwrap(); assert_eq!(c2c.change_id, id_c); assert!(c2c.deps.contains(&d), "{:?}", c2c.deps); let heads = repo.bookmark_heads().unwrap(); let st = repo.load_state(&heads).unwrap(); assert!(!st.contains(&c) && st.contains(&d)); let e2 = st .changes() .find(|(_, m)| m.change_id == id_e) .map(|(h, _)| *h) .unwrap(); let e2deps = repo.store().get_change(&e2).unwrap().deps; assert!( e2deps.contains(&c2) && !e2deps.contains(&c) && e2deps.contains(&a), "E follows C by content and still depends on A, which introduced the file: {e2deps:?}" ); let files = st.materialize().files; assert!( matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"0\n1\n2\n3\n4\n"), "{:?}", files["a.txt"].content ); assert!(!files["a.txt"].is_conflicted()); // Rebasing onto a state that already contains the change is refused. assert!(repo.rebase(&c2, &[c2]).is_err()); let err = repo.rebase(&e2, &[d]).unwrap_err().to_string(); assert!( err.contains("lacks dependencies"), "E needs C, which D's state does not have: {err}" ); } #[test] fn unsigned_tags_keep_their_message() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"a\n"); repo.commit("a", &[]).unwrap(); assert!(repo.secret_key().unwrap().is_none()); repo.tag_create("v1", &repo.bookmark_heads().unwrap(), "first release") .unwrap(); let tags = repo.tags().unwrap(); assert_eq!( ( tags[0].name.as_str(), tags[0].message.as_str(), tags[0].signer.as_deref() ), ("v1", "first release", None) ); assert!( repo.tag_refs().unwrap()[0].attestation.is_none(), "an unsigned attestation is not advertised" ); } #[test] fn fsck_passes_a_sound_repository_and_names_each_fault() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_path = root.join("origin"); let origin = Repo::init(&origin_path).unwrap(); let mut a = Repo::clone(&origin_path.to_string_lossy(), &root.join("a")) .unwrap() .with_home(root.join("home-a")); a.create_identity("Ada", "ada@example.org", IdentityScope::Repo) .unwrap(); write(&root.join("a"), "f.txt", b"one\n"); write(&root.join("a"), "bin.dat", b"\0\x01\x02binary"); let h1 = a.commit("first", &[]).unwrap(); write(&root.join("a"), "f.txt", b"one\ntwo\n"); let h2 = a.commit("second", &[]).unwrap(); a.tag_create("v1", &[h2], "first release").unwrap(); a.checkpoint().unwrap(); assert!(a.push("origin", false).unwrap()[0].ok); // Sound: signed changes, a seal, a tag, a checkpoint, a ref attestation. for repo in [&a, &origin] { let rep = repo.fsck().unwrap(); assert_eq!(rep.problems, vec![], "{rep:?}"); // The two commits, plus the working copy's change in `a`. assert!(rep.changes >= 2 && rep.signed >= 2, "{rep:?}"); assert!(rep.ops > 1 && rep.objects > 5); } assert!(origin.fsck().unwrap().attestations >= 1); // A flipped byte in a stored object is corruption, named by hash. let c1 = a.store().path_for(&h1); let pristine = fs::read(&c1).unwrap(); let mut bytes = pristine.clone(); *bytes.last_mut().unwrap() ^= 1; fs::write(&c1, &bytes).unwrap(); let rep = a.fsck().unwrap(); assert!(rep.errors() >= 1); assert!( rep.problems .iter() .any(|p| p.object == Some(h1) && p.what.contains("unreadable")), "{rep:?}" ); fs::write(&c1, &pristine).unwrap(); assert_eq!(a.fsck().unwrap().errors(), 0); // A missing change is named with what needed it. fs::remove_file(&c1).unwrap(); let rep = a.fsck().unwrap(); assert!( rep.problems.iter().any(|p| p.severity == Severity::Error && p.object == Some(h1) && p.what.contains("change missing, needed by change")), "{rep:?}" ); fs::write(&c1, &pristine).unwrap(); // A missing blob is an error in a full clone. let c2 = a.store().get_change(&h1).unwrap(); let blob = c2 .edits .iter() .find_map(|e| match e { Edit::Replace { new: Some(b), .. } => Some(*b), _ => None, }) .expect("the binary file is a blob"); let bp = a.store().path_for(&blob); let blob_bytes = fs::read(&bp).unwrap(); fs::remove_file(&bp).unwrap(); let rep = a.fsck().unwrap(); assert!( rep.problems .iter() .any(|p| p.object == Some(blob) && p.what.contains("blob missing")), "{rep:?}" ); fs::write(&bp, &blob_bytes).unwrap(); // A broken op log head is reported, not a crash. let head = root.join("a/.oxy/ops/head"); let saved = fs::read(&head).unwrap(); fs::write(&head, b"not a hash").unwrap(); let rep = a.fsck().unwrap(); assert!( rep.problems.iter().any(|p| p.what.contains("ops/head")), "{rep:?}" ); fs::write(&head, saved).unwrap(); assert_eq!(a.fsck().unwrap().problems, vec![]); } /// Copy a directory tree (for restoring a pristine repository). fn copy_tree(from: &Path, to: &Path) { fs::create_dir_all(to).unwrap(); for e in fs::read_dir(from).unwrap() { let e = e.unwrap(); let target = to.join(e.file_name()); if e.file_type().unwrap().is_dir() { copy_tree(&e.path(), &target); } else { fs::copy(e.path(), &target).unwrap(); } } } /// Spec §13.6: stop `commit` and `push` at every durable step, as a crash /// would, and check that both repositories still open, pass `fsck`, and /// accept the same operation again. #[test] fn crash_at_every_step_of_commit_and_push_leaves_sound_repositories() { use crate::durable::failpoint; hermetic(); let dir = tempfile::tempdir().unwrap(); let pristine = dir.path().join("pristine"); let origin_path = pristine.join("origin"); Repo::init(&origin_path).unwrap(); let mut a = Repo::clone(&origin_path.to_string_lossy(), &pristine.join("a")) .unwrap() .with_home(dir.path().join("home")); a.create_identity("Ada", "ada@example.org", IdentityScope::Repo) .unwrap(); write(&pristine.join("a"), "f.txt", b"one\n"); a.commit("first", &[]).unwrap(); assert!(a.push("origin", false).unwrap()[0].ok); drop(a); type Op = fn(&Repo) -> Result<()>; let edit_and_commit: Op = |r| { write(r.root(), "f.txt", b"one\ntwo\n"); write(r.root(), "big.bin", &vec![7u8; 300_000]); r.commit("second", &[]).map(|_| ()) }; let push: Op = |r| { let res = r.push("origin", false)?; if res.iter().all(|x| x.ok) { Ok(()) } else { Err(RepoError::Msg(format!("{res:?}"))) } }; for (name, prepare, op) in [ ("commit", None, edit_and_commit), ("push", Some(edit_and_commit), push), ] { let mut steps = 0; for n in 1.. { let run = dir.path().join(format!("{name}-{n}")); copy_tree(&pristine, &run); // Clones record their remote by path; point it at this copy. let open = |p: &Path| { let mut r = Repo::open(p).unwrap().with_home(dir.path().join("home")); r.config .set("remote.origin.url", &run.join("origin").to_string_lossy()) .unwrap(); r.save_config().unwrap(); r }; let a = open(&run.join("a")); if let Some(prep) = prepare { prep(&a).unwrap(); } failpoint::crash_after(n); let outcome = op(&a); let crashed = failpoint::disarm(); drop(a); if !crashed { outcome.unwrap_or_else(|e| panic!("{name} failed without a crash: {e}")); steps = n - 1; break; } assert!( outcome.is_err(), "{name}: a crash at step {n} was swallowed" ); for repo in ["a", "origin"] { let r = Repo::open(&run.join(repo)).unwrap_or_else(|e| { panic!("{name} crash at step {n}: {repo} no longer opens: {e}") }); let rep = r.fsck().unwrap(); assert_eq!( rep.errors(), 0, "{name} crash at step {n}: {repo} fails fsck: {:?}", rep.problems ); } // The interrupted operation can simply be run again. let a = open(&run.join("a")); // A crash after the commit point leaves the commit done. let landed = a .bookmark_heads() .unwrap() .iter() .any(|h| a.store().get_change(h).unwrap().message == "second"); let again = match (name, landed) { ("commit", true) => Ok(()), ("commit", false) => a.commit("second", &[]).map(|_| ()), _ => op(&a), }; again.unwrap_or_else(|e| panic!("{name} crash at step {n}: retry failed: {e}")); assert_eq!(a.fsck().unwrap().errors(), 0); fs::remove_dir_all(&run).unwrap(); } assert!(steps >= 3, "{name} has only {steps} durable steps?"); } } /// Spec §12.1: commands in two workspaces of one repository, and pushes, /// running at the same time lose nothing: every commit ends up on the /// bookmark, locally and on the remote, and the repository passes fsck. #[test] fn concurrent_commits_in_two_workspaces_lose_nothing() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = root.join("origin"); Repo::init_bare_unbound(&origin).unwrap(); let main = root.join("main"); let mut repo = Repo::init(&main).unwrap(); repo.config .set("remote.origin.url", &origin.to_string_lossy()) .unwrap(); repo.save_config().unwrap(); write(&main, "seed.txt", b"seed\n"); repo.commit("seed", &[]).unwrap(); let second = root.join("second"); repo.workspace_add(&second, None).unwrap(); drop(repo); const EACH: usize = 15; std::thread::scope(|s| { for (tag, dir, push) in [("a", main.clone(), true), ("b", second.clone(), false)] { s.spawn(move || { let r = Repo::open(&dir).unwrap(); for i in 0..EACH { write( &dir, &format!("{tag}-{i}.txt"), format!("{tag} {i}\n").as_bytes(), ); r.commit(&format!("{tag} {i}"), &[]).unwrap(); if push && i % 5 == 4 { // A push may be refused while the other workspace moves the bookmark; nothing may be lost either way. let _ = r.push("origin", false); } } }); } }); let repo = Repo::open(&main).unwrap(); let heads = repo.bookmark_heads().unwrap(); let state = repo.load_state(&heads).unwrap(); let messages: BTreeSet = state.changes().map(|(_, m)| m.message.clone()).collect(); for tag in ["a", "b"] { for i in 0..EACH { assert!( messages.contains(&format!("{tag} {i}")), "lost commit {tag} {i}; have {messages:?}" ); } } let rep = repo.fsck().unwrap(); assert_eq!(rep.errors(), 0, "{:?}", rep.problems); assert!(repo.push("origin", false).unwrap().iter().all(|r| r.ok)); let hosted = Repo::open(&origin).unwrap(); let hosted_heads = hosted.bookmark_heads().unwrap(); assert_eq!( hosted.load_state(&hosted_heads).unwrap().len(), state.len(), "the remote has every commit" ); } /// Two operations recorded from the same parent (what two processes do /// when they race) are merged: both bookmarks' heads survive, and a /// change made on one side only is kept. #[test] fn operations_from_the_same_parent_are_merged() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"a\n"); let a = repo.commit("a", &[]).unwrap(); write(root, "b.txt", b"b\n"); let b = repo.commit("b", &[]).unwrap(); let (x, op) = repo.head_op().unwrap(); let state = |heads: Vec| repo.store().put_state(&StateObject::new(heads)).unwrap(); // Side one moves main to just `a` and adds a bookmark; side two moves main to just `b`. let mut one = op.view.clone(); one.bookmarks.insert("main".into(), state(vec![a])); one.bookmarks.insert("topic".into(), state(vec![a])); let mut two = op.view.clone(); two.bookmarks.insert("main".into(), state(vec![b])); repo.record_op(&[x], "one", one).unwrap(); let merged = repo.record_op(&[x], "two", two).unwrap(); let (head, op) = repo.head_op().unwrap(); assert_eq!(head, merged); assert_eq!(op.parents.len(), 2, "a merge of the two operations"); assert!(op.command.contains("merged with a concurrent operation")); let mut main = repo .store() .get_state(&op.view.bookmarks["main"]) .unwrap() .heads; main.sort(); let mut want = vec![a, b]; want.sort(); assert_eq!(main, want, "both sides' heads are kept"); assert!( op.view.bookmarks.contains_key("topic"), "a one-sided change survives" ); assert_eq!(repo.fsck().unwrap().errors(), 0); } #[test] fn failed_requests_come_with_a_next_step() { use crate::protocol::hint_for; assert!(hint_for("https://h/x/oxy/v1/push: HTTP 401: bad token") .unwrap() .contains("token")); assert!(hint_for("…: HTTP 403: forbidden") .unwrap() .contains("repo:write")); assert!(hint_for("…: HTTP 404: no such repository") .unwrap() .contains("owner and repository name")); assert!(hint_for("invalid peer certificate: UnknownIssuer") .unwrap() .contains("oxyhub-ca.pem")); assert!(hint_for("Connect error: Connection refused (os error 111)") .unwrap() .contains("running")); assert!(hint_for("Dns Failed: failed to lookup address information") .unwrap() .contains("resolve")); assert_eq!(hint_for("something else entirely"), None); } #[test] fn doctor_names_problems_and_what_to_do() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = root.join("origin"); Repo::init_bare_unbound(&origin).unwrap(); let mut repo = Repo::init(&root.join("r")) .unwrap() .with_home(root.join("home")); let find = |r: &DoctorReport, area: &str, h: Health| { r.checks .iter() .find(|c| c.area == area && c.health == h) .cloned() }; let r = repo.doctor(true); assert!(find(&r, "identity", Health::Warn) .unwrap() .hint .unwrap() .contains("oxy identity create")); assert!(find(&r, "repository", Health::Ok).is_some()); repo.create_identity("Ada", "ada@example.org", IdentityScope::Repo) .unwrap(); repo.config.set("author.name", "Ada").unwrap(); repo.config.set("author.email", "ada@example.org").unwrap(); repo.config .set("remote.origin.url", &origin.to_string_lossy()) .unwrap(); repo.config .set("remote.down.url", "http://127.0.0.1:9/a/b") .unwrap(); repo.config.set("remote.down.token", "t").unwrap(); repo.config .set("remote.badca.url", "https://example.invalid/a/b") .unwrap(); repo.config .set( "remote.badca.ca", &root.join("missing.pem").to_string_lossy(), ) .unwrap(); repo.save_config().unwrap(); let r = repo.doctor(true); assert!(find(&r, "identity", Health::Ok).is_some(), "{r:?}"); assert!(find(&r, "author", Health::Ok).is_some(), "{r:?}"); assert!(find(&r, "remote origin", Health::Ok) .unwrap() .message .contains("first push binds it")); let down = find(&r, "remote down", Health::Fail).unwrap(); assert!( down.hint.as_deref().unwrap_or("").contains("running"), "{down:?}" ); let badca = find(&r, "remote badca", Health::Fail).unwrap(); assert!( badca.hint.unwrap().contains("remote.badca.ca"), "the remote's own name is in the hint" ); assert_eq!(r.worst(), Health::Fail); // Offline, nothing is contacted. let r = repo.doctor(false); assert!(find(&r, "remote down", Health::Fail).is_none()); assert!(find(&r, "remote down", Health::Ok) .unwrap() .message .contains("not contacted")); } /// Spec §8.1: deleting a line depends on whoever inserted the lines right /// after it. Inserting after `b` and then deleting `b`, one commit after /// the other, must not leave a delete/insert conflict. #[test] fn deleting_a_line_after_an_insertion_below_it_is_not_a_conflict() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "f", b"a\nb\nc\n"); repo.commit("base", &[]).unwrap(); write(root, "f", b"a\nb\nx\nc\n"); let insert = repo.commit("insert x after b", &[]).unwrap(); write(root, "f", b"a\nx\nc\n"); let delete = repo.commit("delete b", &[]).unwrap(); assert!( repo.store() .get_change(&delete) .unwrap() .deps .contains(&insert), "the deletion depends on the insertion below the deleted line" ); let heads = repo.bookmark_heads().unwrap(); assert_eq!(heads, vec![delete]); let snap = repo.load_state(&heads).unwrap().materialize(); assert!(!snap.is_conflicted()); assert!( matches!(&snap.files["f"].content, FileContent::Text { bytes, .. } if bytes == b"a\nx\nc\n") ); } /// Spec §9.3: a binary file reverted to an earlier blob keeps it, and each /// replacement depends on the change that set the blob it replaces. #[test] fn a_binary_file_reverted_to_an_earlier_blob_keeps_it() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "bin", b"\0A"); let v1 = repo.commit("v1", &[]).unwrap(); write(root, "bin", b"\0B"); let v2 = repo.commit("v2", &[]).unwrap(); assert!(repo.store().get_change(&v2).unwrap().deps.contains(&v1)); write(root, "bin", b"\0A"); let v3 = repo.commit("back to A", &[]).unwrap(); assert!(repo.store().get_change(&v3).unwrap().deps.contains(&v2)); assert_eq!(repo.bookmark_heads().unwrap(), vec![v3]); let state = repo.load_state(&[v3]).unwrap(); let snap = state.materialize(); let FileContent::Opaque { blobs } = &snap.files["bin"].content else { panic!("opaque") }; assert_eq!(blobs.len(), 1, "one live blob, not none and not two"); assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\0A"); } /// Spec §23: a binary file changed on two branches and merged in Git to /// one side's version comes back as that version at the merge, and the /// import verifies commit by commit. #[test] fn git_import_resolves_a_binary_merged_in_git_and_verifies() { hermetic(); if std::process::Command::new("git") .arg("--version") .output() .is_err() { eprintln!("git not installed; skipping"); return; } let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let g = root.join("src.git"); fs::create_dir_all(&g).unwrap(); let git = |args: &[&str]| -> String { let out = std::process::Command::new("git") .arg("-C") .arg(&g) .args([ "-c", "user.name=Grace", "-c", "user.email=grace@example.org", "-c", "commit.gpgsign=false", ]) .args(args) .output() .unwrap(); assert!( out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_string() }; git(&["init", "-q", "-b", "main"]); write(&g, "logo.bin", b"\0base"); write(&g, "notes.txt", b"a\nb\nc\n"); git(&["add", "."]); git(&["commit", "-qm", "base"]); git(&["checkout", "-qb", "side"]); write(&g, "logo.bin", b"\0side"); write(&g, "notes.txt", b"a\nb\nx\nc\n"); git(&["commit", "-qam", "side"]); git(&["checkout", "-q", "main"]); write(&g, "logo.bin", b"\0main"); git(&["commit", "-qam", "main"]); // Git cannot merge the binary; the merge takes main's and keeps side's text. let _ = std::process::Command::new("git") .arg("-C") .arg(&g) .args([ "-c", "user.name=G", "-c", "user.email=g@e", "merge", "-q", "side", ]) .output(); write(&g, "logo.bin", b"\0main"); git(&["add", "."]); git(&["commit", "-qm", "merge side"]); // Then a revert of the binary to its first version. write(&g, "logo.bin", b"\0base"); git(&["commit", "-qam", "back to base"]); let repo = Repo::init(&root.join("o")).unwrap(); let rep = repo.import_git(&g, "main").unwrap(); assert_eq!(rep.merges, 1); let v = repo.verify_git_import(&g, "main").unwrap(); assert!(v.mismatches.is_empty(), "{:?}", v.mismatches); assert_eq!(v.commits, 5); let snap = repo .load_state(&repo.bookmark_heads().unwrap()) .unwrap() .materialize(); assert!(!snap.is_conflicted()); let FileContent::Opaque { blobs } = &snap.files["logo.bin"].content else { panic!("opaque") }; assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\0base"); } /// Spec §7.7 on a file system without executable bits or symlinks: the /// recorded bit is kept, and a symlink checked out as a plain file holding /// its target is still that symlink. #[cfg(unix)] #[test] fn windows_file_systems_keep_exec_bits_and_symlinks() { use crate::portable::Platform; use std::os::unix::fs::PermissionsExt; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "run.sh", b"#!/bin/sh\n"); fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap(); std::os::unix::fs::symlink("run.sh", root.join("link")).unwrap(); repo.commit("script and link", &[]).unwrap(); drop(repo); // What checking out on Windows leaves: no exec bit, the link as a file. fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o644)).unwrap(); fs::remove_file(root.join("link")).unwrap(); write(root, "link", b"run.sh"); let win = Repo::open(root).unwrap().with_platform(Platform::windows()); let s = win.snapshot("oxy status").unwrap(); assert!( s.statuses.is_empty(), "nothing changed on Windows: {:?}", statuses(&s) ); // Editing the file is a content change, not a mode change; retargeting the link keeps it a link. write(root, "run.sh", b"#!/bin/sh\necho hi\n"); write(root, "link", b"other"); let h = win.commit("edit on Windows", &[]).unwrap(); let snap = win.load_state(&[h]).unwrap().materialize(); assert!(snap.files["run.sh"].mode.exec, "the exec bit survives"); assert_eq!(snap.files["link"].kind, NodeKind::Symlink); // The same working copy read as Linux sees the lost bit and the plain file. let linux = Repo::open(root).unwrap(); assert!(!linux.snapshot("oxy status").unwrap().statuses.is_empty()); } /// Paths that collide on case-insensitive or Windows file systems are /// named at checkout there, and by doctor everywhere. #[test] fn paths_other_platforms_cannot_hold_are_reported() { use crate::portable::Platform; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "README.md", b"a\n"); write(root, "readme.md", b"b\n"); write(root, "src/aux.c", b"int x;\n"); let h = repo.commit("unportable", &[]).unwrap(); let r = repo.doctor(false); let port = r.checks.iter().find(|c| c.area == "portability").unwrap(); assert_eq!(port.health, Health::Warn); assert!( port.message.contains("differ only in case") && port.message.contains("aux.c"), "{}", port.message ); let mac = Repo::open(root).unwrap().with_platform(Platform::macos()); let rep = mac.checkout(&[h], true, "oxy checkout").unwrap(); assert!( rep.notes .iter() .any(|n| n.contains("README.md and readme.md")), "{:?}", rep.notes ); assert!( !rep.notes.iter().any(|n| n.contains("aux.c")), "macOS allows aux.c" ); } #[test] fn history_and_blame_of_a_loaded_state_match_the_full_walk() { hermetic(); let dir = tempfile::tempdir().unwrap(); let repo = Repo::init(dir.path()).unwrap(); write(dir.path(), "a.txt", b"one\n"); write(dir.path(), "other.txt", b"x\n"); write(dir.path(), "bin.dat", &[0u8, 1, 2, 0, 255]); repo.snapshot("t").unwrap(); let c1 = repo.commit("first", &[]).unwrap(); write(dir.path(), "other.txt", b"y\n"); repo.snapshot("t").unwrap(); repo.commit("other only", &[]).unwrap(); write(dir.path(), "a.txt", b"one\ntwo\n"); repo.snapshot("t").unwrap(); let c3 = repo.commit("second line", &[]).unwrap(); write(dir.path(), "other.txt", b"z\n"); repo.snapshot("t").unwrap(); repo.commit("other again", &[]).unwrap(); let heads = repo.bookmark_heads().unwrap(); let state = repo.load_state(&heads).unwrap(); let full: Vec = repo .log_for_path_in("a.txt", &heads) .unwrap() .iter() .map(|e| e.hash) .collect(); assert_eq!( full, vec![c3, c1], "newest first, only changes touching the path" ); let newest: Vec = repo .log_for_path_of(&state, "a.txt", Some(1)) .unwrap() .iter() .map(|e| e.hash) .collect(); assert_eq!(newest, vec![c3], "stops at the first"); assert_eq!( repo.log_for_path_of(&state, "a.txt", None).unwrap().len(), 2 ); assert!(repo .log_for_path_of(&state, "missing.txt", Some(1)) .is_err()); assert_eq!( repo.log_state(&heads).unwrap().len(), repo.log_of(&state).len() ); let tree = state.tree().view(); let via_state = repo.blame_of(&state, &tree, "a.txt").unwrap(); let via_heads = repo.blame_in("a.txt", &heads).unwrap(); assert_eq!( via_state .iter() .map(|l| (l.hash, l.text.clone())) .collect::>(), via_heads .iter() .map(|l| (l.hash, l.text.clone())) .collect::>() ); assert_eq!( via_state.iter().map(|l| l.hash).collect::>(), vec![c1, c3] ); assert!(repo.blame_of(&state, &tree, "missing.txt").is_err()); }