//! 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.lines() .any(|l| l.starts_with("M ") && l.ends_with("main.rs")), "a modified file is in the summary too: {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![], lacks: 1, } ); 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 serial_of = |att: &SignedAttest| match att.attest.body { AttestBody::Ref { serial, .. } => serial, _ => unreachable!(), }; let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap(); // A bookmark new to the remote starts at the clock (past any deleted // namesake's serial); later updates count up from there. let first = serial_of(&att); assert!(first > 1_000_000_000_000, "{first}"); 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_eq!(serial_of(&att), first, "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_eq!(serial_of(&att), first + 1); // 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 mut 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); // Restoring the default retention makes the old views roots again; the // working copies they name were pruned, which gc tolerates. repo.config.set("core.op-retention", "90").unwrap(); repo.save_config().unwrap(); let repo = Repo::open(root).unwrap().with_home(root.join("home")); let r = repo.gc(false).unwrap(); assert_eq!(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"]); // Expected to stop at the conflict, so its status is ignored; the // identity is passed as for every other git call here, or a machine // without one refuses to start the merge at all. let _ = std::process::Command::new("git") .arg("-C") .arg(&g) .args([ "-c", "user.name=Grace", "-c", "user.email=grace@example.org", "merge", "-q", "clash", ]) .output(); assert!( g.join(".git/MERGE_HEAD").exists(), "the clash merge stopped at its conflict" ); 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"]); // A branch that edits run.sh, merged keeping main's tree (`-s ours`): // run.sh is the same as in the first parent's tree, but the merged // change touched it, so the importer must compare it, not skip it. git(&["checkout", "-q", "-b", "discarded"]); write(&g, "run.sh", b"#!/bin/sh\necho discarded\n"); git(&["commit", "-q", "-am", "discarded edit"]); git(&["checkout", "-q", "main"]); git(&[ "merge", "-q", "-s", "ours", "-m", "merge discarded, keeping ours", "discarded", ]); 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, 3); assert_eq!( fs::read(dest.join("run.sh")).unwrap(), b"#!/bin/sh\n", "the ours merge kept main's run.sh" ); let v = repo.verify_git_import(&g, "main").unwrap(); assert_eq!(v.commits, expected_count, "every commit's tree reproduced"); assert!(v.mismatches.is_empty(), "{:?}", v.mismatches); // 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(); // New nodes' identifiers derive from the commit (spec §23), so the // re-import is not just the same ChangeIds but the same changes. assert_eq!( repo2.bookmark_heads().unwrap(), repo.bookmark_heads().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 working-copy lock: re-entrant in a thread; a lock whose process // is gone is taken over, and a live process's is not, however old. let lock = repo.lock_snapshot().unwrap(); let again = repo.lock_working_copy().unwrap(); drop(again); drop(lock); let lock_path = main_dir .join(".oxy/workspaces") .join(repo.workspace().to_hex()) .join("lock"); let old = std::time::SystemTime::now() - std::time::Duration::from_secs(120); let age = |p: &Path| fs::File::open(p).unwrap().set_modified(old).unwrap(); fs::write(&lock_path, std::process::id().to_string()).unwrap(); age(&lock_path); assert!( !super::workspace::lock_is_stale(&lock_path), "a live process keeps its lock" ); fs::write(&lock_path, "4294967295").unwrap(); age(&lock_path); 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 src/old.rs -> lib/new.rs")); // 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 brings the nested working copy back // to the older pin, but never moves its bookmark backwards (oxygen#38). let r = app.checkout(&[a1], false, "checkout").unwrap(); assert!( r.notes.len() == 1 && r.notes[0].contains("keeps 1 newer change"), "{:?}", r.notes ); assert_eq!(fs::read(app_dir.join("libs/lib/lib.txt")).unwrap(), b"v1\n"); let nested = Repo::open(&app_dir.join("libs/lib")).unwrap(); assert_eq!(nested.bookmark_heads().unwrap(), vec![l2]); assert_eq!(nested.working_copy().unwrap().1.deps, 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()); } /// A text file whose stat matches the manifest and whose recorded hash is /// the base's content is taken from the base without reading it; anything /// else is read, so no edit is missed (spec §12.3). #[cfg(unix)] #[test] fn stat_cache_takes_unchanged_text_from_the_base_and_misses_no_edit() { 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")); write(root, "a.txt", b"one\ntwo\n"); write(root, "b.txt", b"bee\n"); repo.commit("base", &[]).unwrap(); let age = |p: &str| { let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30); fs::File::options() .write(true) .open(root.join(p)) .unwrap() .set_modified(old) .unwrap(); }; age("a.txt"); age("b.txt"); assert!( !repo.snapshot("status").unwrap().changed, "records the aged stat" ); // Clean and equal to the base: not read at all. fs::set_permissions(root.join("a.txt"), 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("a.txt"), fs::Permissions::from_mode(0o644)).unwrap(); // An uncommitted edit that is older than the manifest: stat-clean, but // its hash is not the base's, so it is read and stays visible. write(root, "b.txt", b"bee edited\n"); age("b.txt"); let first = repo.snapshot("status").unwrap(); assert_eq!( statuses(&first), vec![("b.txt".to_string(), ChangeStatus::Modified)] ); let again = repo.snapshot("status").unwrap(); assert_eq!( statuses(&again), vec![("b.txt".to_string(), ChangeStatus::Modified)], "still seen once stat-clean" ); assert!(!again.changed, "and the working copy hashes the same"); fs::set_permissions(root.join("b.txt"), fs::Permissions::from_mode(0o000)).unwrap(); assert!( repo.snapshot("status").is_err(), "an edited file must be read, never taken from the base" ); fs::set_permissions(root.join("b.txt"), fs::Permissions::from_mode(0o644)).unwrap(); // A same-size edit made just now is racy and read. write(root, "a.txt", b"one\nTWO\n"); assert_eq!( statuses(&repo.snapshot("status").unwrap()), vec![ ("a.txt".to_string(), ChangeStatus::Modified), ("b.txt".to_string(), ChangeStatus::Modified) ] ); // Committing makes the edits the base: both are clean and served from it again. repo.commit("edits", &[]).unwrap(); age("a.txt"); age("b.txt"); assert!(repo.snapshot("status").unwrap().statuses.is_empty()); fs::set_permissions(root.join("a.txt"), fs::Permissions::from_mode(0o000)).unwrap(); fs::set_permissions(root.join("b.txt"), fs::Permissions::from_mode(0o000)).unwrap(); let snap = repo.snapshot("status").unwrap(); assert!(!snap.changed && snap.statuses.is_empty()); fs::set_permissions(root.join("a.txt"), fs::Permissions::from_mode(0o644)).unwrap(); fs::set_permissions(root.join("b.txt"), fs::Permissions::from_mode(0o644)).unwrap(); } /// Version 2 checkpoints (spec §11): a large file's graph is a blob of its /// own, kept by gc, checked by fsck, shared by the next checkpoint when the /// file is unchanged, and loading through the checkpoint equals a full load. #[test] fn checkpoints_keep_large_graphs_as_blobs_of_their_own() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); let long: String = (0..300) .map(|i| format!("line {i} of a long file\n")) .collect(); write(root, "long.txt", long.as_bytes()); write(root, "short.txt", b"short\n"); repo.commit("base", &[]).unwrap(); let (r1, _, _) = repo.checkpoint().unwrap(); let cp1 = crate::objects::Checkpoint::decode( &repo.store().get_typed(&r1, ObjectType::Checkpoint).unwrap(), ) .unwrap(); let graphs1 = repo.checkpoint_graph_blobs(&cp1).unwrap(); assert_eq!(graphs1.len(), 1, "only the long file's graph is a blob"); // gc keeps the graph blob; fsck finds the checkpoint sound. repo.gc(true).unwrap(); assert!(repo.store().has(&graphs1[0])); let report = repo.fsck().unwrap(); assert_eq!(report.errors(), 0, "{:?}", report.problems); // Loading through the checkpoint equals a full load. write(root, "short.txt", b"short, edited\n"); repo.commit("edit the short file", &[]).unwrap(); let heads = repo.bookmark_heads().unwrap(); let fast = repo.load_state(&heads).unwrap(); let full = repo.load_state_full(&heads).unwrap(); assert_eq!(fast.checkpoint_payload(), full.checkpoint_payload()); // The next checkpoint shares the unchanged long file's graph. let (r2, _, _) = repo.checkpoint().unwrap(); let cp2 = crate::objects::Checkpoint::decode( &repo.store().get_typed(&r2, ObjectType::Checkpoint).unwrap(), ) .unwrap(); assert_eq!(repo.checkpoint_graph_blobs(&cp2).unwrap(), graphs1); // A shallow clone's bundle carries the graph blob with the checkpoint. let bundle = repo.checkpoint_bundle_for(&heads).unwrap(); assert!(bundle.objects.iter().any(|(t, body)| *t == ObjectType::Blob && crate::hash::hash_object(ObjectType::Blob, body) == graphs1[0])); // A version 1 checkpoint written by an older oxy still loads. let state = repo.load_state_full(&heads).unwrap(); let old = repo.store_blob(&state.checkpoint_payload()).unwrap(); let cp_old = crate::objects::Checkpoint { heads: cp2.heads.clone(), count: cp2.count, payload: old, }; let r_old = repo .store() .put(ObjectType::Checkpoint, &cp_old.encode()) .unwrap(); repo.install_checkpoint("main", &r_old).unwrap(); assert_eq!( repo.load_state(&heads).unwrap().checkpoint_payload(), full.checkpoint_payload() ); assert_eq!(repo.fsck().unwrap().errors(), 0); // A lost graph blob fails the load loudly rather than dropping the // file, and fsck reports it (a fresh repository, objects still loose). let dir2 = tempfile::tempdir().unwrap(); let root2 = dir2.path(); let repo2 = Repo::init(root2).unwrap().with_home(root2.join("home")); write(root2, "long.txt", long.as_bytes()); repo2.commit("base", &[]).unwrap(); let (r, _, _) = repo2.checkpoint().unwrap(); let cp = crate::objects::Checkpoint::decode( &repo2.store().get_typed(&r, ObjectType::Checkpoint).unwrap(), ) .unwrap(); let graph = repo2.checkpoint_graph_blobs(&cp).unwrap()[0]; fs::remove_file(repo2.store().path_for(&graph)).unwrap(); assert!(repo2.load_state(&repo2.bookmark_heads().unwrap()).is_err()); assert!(repo2.fsck().unwrap().errors() > 0, "fsck reports it"); } /// A new repository has not stored the user's identity yet (that happens /// at the first signed change); looking it up falls back to the key /// directory, and a file there that does not hash to the key is refused. #[test] fn identity_is_found_in_the_key_directory_for_a_new_repository() { hermetic(); let dir = tempfile::tempdir().unwrap(); let home = dir.path().join("home"); let mut first = Repo::init(&dir.path().join("a")) .unwrap() .with_home(home.clone()); let (key, _) = first .create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); let fresh = Repo::init(&dir.path().join("b")) .unwrap() .with_home(home.clone()); assert!(!fresh.store().has(&key), "nothing stored yet"); let id = fresh.identity(&key).unwrap(); assert_eq!((id.name.as_str(), id.hash()), ("Ada", key)); // A key file that is not this identity is an error, not a wrong answer. let keys = fresh.keys_dir.clone(); let (other, _) = first .create_identity("Bob", "bob@example.org", IdentityScope::User) .unwrap(); fs::copy( keys.join(format!("{}.identity", other.to_hex())), keys.join(format!("{}.identity", key.to_hex())), ) .unwrap(); assert!(fresh .identity(&key) .unwrap_err() .to_string() .contains("does not hold identity")); } /// Status snapshots (spec §12.3): a text file whose stat matches the /// manifest, and whose base graph key is the one the manifest recorded it /// equal to, is neither read nor rendered; any edit, and any change to the /// file's graph in the base, brings it back. #[cfg(unix)] #[test] fn status_snapshots_skip_files_unchanged_since_the_manifest() { 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")); for (p, c) in [("a.txt", "one\n"), ("b.txt", "bee\n"), ("d/c.txt", "sea\n")] { write(root, p, c.as_bytes()); } repo.commit("base", &[]).unwrap(); let age = |p: &str| { let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30); fs::File::options() .write(true) .open(root.join(p)) .unwrap() .set_modified(old) .unwrap(); }; let lock = |p: &str, mode: u32| { fs::set_permissions(root.join(p), fs::Permissions::from_mode(mode)).unwrap() }; for p in ["a.txt", "b.txt", "d/c.txt"] { age(p); } assert!(repo.snapshot("status").unwrap().statuses.is_empty()); let (_, _, m) = repo.read_manifest().unwrap().unwrap(); assert!( m.entries.values().all(|e| e.graph.is_some()), "keys recorded" ); // Unreadable files prove they are not read; the status snapshot does // not render them either, and its base lacks their graphs. lock("a.txt", 0o000); lock("b.txt", 0o000); let snap = repo.snapshot_status("status").unwrap(); assert!(!snap.changed && snap.statuses.is_empty()); assert_eq!( snap.base_snapshot .unrendered .iter() .cloned() .collect::>(), vec!["a.txt", "b.txt", "d/c.txt"] ); assert!(!snap.base.is_full()); assert!(!snap.base_snapshot.is_conflicted()); // A full snapshot does not read them either, but renders everything. let full = repo.snapshot("status").unwrap(); assert!(full.statuses.is_empty() && full.base.is_full()); assert!(full.base_snapshot.unrendered.is_empty()); assert!(matches!(&full.base_snapshot.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\n")); // An edit is seen; the file is rendered, the others still are not. write(root, "d/c.txt", b"sea edited\n"); let snap = repo.snapshot_status("status").unwrap(); assert_eq!( statuses(&snap), vec![("d/c.txt".to_string(), ChangeStatus::Modified)] ); assert!(!snap.base_snapshot.unrendered.contains("d/c.txt")); // So is a mode change of an unread file. lock("b.txt", 0o100); assert_eq!( statuses(&repo.snapshot_status("status").unwrap()), vec![ ("b.txt".to_string(), ChangeStatus::ModeChanged), ("d/c.txt".to_string(), ChangeStatus::Modified) ] ); lock("b.txt", 0o000); // Committing c.txt changes its graph in the base, not a.txt's: a.txt // stays unread, c.txt is read once and then skipped too. repo.commit_selection("edit c", &[], &Selection::Paths(vec!["d/c.txt".into()])) .unwrap(); age("d/c.txt"); let snap = repo.snapshot_status("status").unwrap(); assert!(snap.statuses.is_empty(), "{:?}", snap.statuses); assert!(snap.base_snapshot.unrendered.contains("a.txt")); lock("d/c.txt", 0o000); let snap = repo.snapshot_status("status").unwrap(); assert!(snap.statuses.is_empty()); assert!(snap.base_snapshot.unrendered.contains("d/c.txt")); for p in ["a.txt", "b.txt", "d/c.txt"] { lock(p, 0o644); } } /// Attributes decide how a file compares with the base, so a changed /// `.oxyattributes` makes every text file be compared again. #[cfg(unix)] #[test] fn a_changed_attributes_file_disables_skipping_unchanged_files() { 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"crlf\r\n"); repo.commit("base", &[]).unwrap(); let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30); fs::File::options() .write(true) .open(root.join("a.txt")) .unwrap() .set_modified(old) .unwrap(); assert!(repo.snapshot_status("status").unwrap().statuses.is_empty()); assert!(repo .snapshot_status("status") .unwrap() .base_snapshot .unrendered .contains("a.txt")); // With eol=lf, a.txt as stored no longer equals the base. write(root, ".oxyattributes", b"*.txt eol=lf\n"); let snap = repo.snapshot_status("status").unwrap(); assert_eq!( statuses(&snap), vec![ (".oxyattributes".to_string(), ChangeStatus::Added), ("a.txt".to_string(), ChangeStatus::Modified) ] ); } /// Bookmarks with `/` in their names: pushing records their /// attestations, and checkpoints are kept for them, beside `feature` /// itself. #[test] fn bookmarks_with_slashes_push_and_checkpoint() { 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")); // With an identity, pushes carry reference attestations: the case // that failed, since the attestation is a file named after the bookmark. repo.create_identity("Ada", "ada@example.org", IdentityScope::Repo) .unwrap(); 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); repo.bookmark_create("feature", None).unwrap(); repo.bookmark_create("feature/x", None).unwrap(); repo.bookmark_switch("feature/x", false).unwrap(); write(&r, "b.txt", b"b\n"); let h_b = repo.commit("b", &[]).unwrap(); for name in ["feature", "feature/x"] { let res = repo.push_bookmark("origin", name, false).unwrap(); assert!(res[0].ok, "{name}: {res:?}"); } assert_eq!(origin.resolve_heads("feature").unwrap(), vec![h_a]); assert_eq!( origin.resolve_heads("feature/x").unwrap(), repo.resolve_heads("feature/x").unwrap() ); assert!(origin.resolve_heads("feature/x").unwrap().contains(&h_b)); assert!(origin.latest_ref_attest("feature/x").unwrap().is_some()); assert!(origin.latest_ref_attest("feature").unwrap().is_some()); // A checkpoint of feature/x is found again when loading its state. let (cp, _, _) = repo.checkpoint().unwrap(); assert_eq!(repo.latest_checkpoint("feature/x").unwrap().unwrap().0, cp); assert!(repo.latest_checkpoint("feature").unwrap().is_none()); let heads = repo.resolve_heads("feature/x").unwrap(); assert!(repo.load_state_checkpointed(&heads).unwrap().is_some()); } /// Deleting a bookmark on a remote (spec §16.4): attested like any update, /// never the remote's own bookmark, and a bookmark created again later is /// accepted although the deletion's attestation stays the latest. #[test] fn bookmarks_are_deleted_on_a_remote() { 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")); repo.create_identity("Ada", "ada@example.org", IdentityScope::Repo) .unwrap(); let r = root.join("r"); write(&r, "a.txt", b"a\n"); repo.commit("a", &[]).unwrap(); assert!(repo.push("origin", false).unwrap()[0].ok); repo.bookmark_create("feature/x", None).unwrap(); assert!(repo.push_bookmark("origin", "feature/x", false).unwrap()[0].ok); assert!(origin.resolve_heads("feature/x").is_ok()); let res = repo.push_delete("origin", "feature/x").unwrap(); assert!(res[0].ok, "{res:?}"); assert!(!origin.view().unwrap().bookmarks.contains_key("feature/x")); let (_, att) = origin.latest_ref_attest("feature/x").unwrap().unwrap(); assert!( matches!(att.attest.body, AttestBody::Ref { new_state, .. } if new_state.is_zero()), "the deletion is attested" ); assert!( repo.view().unwrap().bookmarks.contains_key("feature/x"), "the local bookmark stays" ); assert!(!repo .view() .unwrap() .remotes .get("origin") .is_some_and(|m| m.contains_key("feature/x"))); // Pushing it again creates it again, past the deletion's serial. let res = repo.push_bookmark("origin", "feature/x", false).unwrap(); assert!(res[0].ok, "{res:?}"); assert!(origin.view().unwrap().bookmarks.contains_key("feature/x")); // Nothing to delete, and never the remote's own bookmark. assert!(repo.push_delete("origin", "nope").is_err()); let res = repo.push_delete("origin", "main").unwrap(); assert!( !res[0].ok && res[0].reason.contains("default bookmark"), "{res:?}" ); assert!(origin.view().unwrap().bookmarks.contains_key("main")); } /// `bookmark switch --carry` (spec §10.1): uncommitted work goes along /// when the target has the same versions of the files it touches, and is /// refused, untouched, when it has others. #[test] fn switching_bookmarks_carries_uncommitted_work() { 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"); write(root, "b.txt", b"b\n"); write(root, "gone.txt", b"gone\n"); repo.commit("base", &[]).unwrap(); repo.bookmark_create("feature", None).unwrap(); // Started on main, belongs on feature: edit, add, remove. write(root, "a.txt", b"a\nmore\n"); write(root, "new.txt", b"new\n"); fs::remove_file(root.join("gone.txt")).unwrap(); let (_, carried) = repo.bookmark_switch_carry("feature").unwrap(); assert_eq!(carried, vec!["a.txt", "gone.txt", "new.txt"]); assert_eq!(repo.config.bookmark, "feature"); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"a\nmore\n"); assert_eq!(fs::read(root.join("new.txt")).unwrap(), b"new\n"); assert!(!root.join("gone.txt").exists()); let snap = repo.snapshot("status").unwrap(); assert_eq!( statuses(&snap), vec![ ("a.txt".to_string(), ChangeStatus::Modified), ("gone.txt".to_string(), ChangeStatus::Removed), ("new.txt".to_string(), ChangeStatus::Added) ] ); repo.commit("the work", &[]).unwrap(); assert!(repo.resolve_heads("main").unwrap().len() == 1); // main moves b.txt on; work on feature touching b.txt cannot follow. repo.bookmark_switch("main", false).unwrap(); write(root, "b.txt", b"b on main\n"); repo.commit("b on main", &[]).unwrap(); repo.bookmark_switch("feature", false).unwrap(); write(root, "b.txt", b"b edited\n"); let err = repo.bookmark_switch_carry("main").unwrap_err().to_string(); assert!(err.contains("b.txt") && err.contains("--discard"), "{err}"); assert_eq!(repo.config.bookmark, "feature", "nothing happened"); assert_eq!(fs::read(root.join("b.txt")).unwrap(), b"b edited\n"); } /// Merging and checking out (feedback 3): a working copy without changes /// follows a merge whichever state it sat on; a refused checkout names /// what is in the way; `checkout --carry` takes the work along. #[test] fn merges_move_clean_working_copies_and_checkouts_say_what_blocks_them() { 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 h1 = repo.commit("a", &[]).unwrap(); write(root, "b.txt", b"b\n"); repo.commit("b", &[]).unwrap(); repo.bookmark_create("feature", None).unwrap(); repo.bookmark_switch("feature", false).unwrap(); write(root, "c.txt", b"c\n"); repo.commit("c on feature", &[]).unwrap(); repo.bookmark_switch("main", false).unwrap(); // The working copy sits on an older state than main's heads. repo.checkout(&[h1], false, "checkout").unwrap(); assert!(!root.join("b.txt").exists()); let m = repo.merge("feature").unwrap(); assert!(m.checked_out, "a clean working copy follows the merge"); for f in ["a.txt", "b.txt", "c.txt"] { assert!(root.join(f).exists(), "{f}"); } // A refusal says which paths are in the way and what to do. fs::remove_file(root.join("b.txt")).unwrap(); write(root, "a.txt", b"a edited\n"); let err = repo .checkout(&[h1], false, "checkout") .unwrap_err() .to_string(); assert!( err.contains("modified a.txt") && err.contains("removed b.txt") && err.contains("--carry"), "{err}" ); // --carry takes them along: h1 has a.txt as the base has it, and no b.txt // is carried as absent (h1 has none either, so that is refused). let err = repo .checkout_carry(&[h1], "the first change", "checkout") .unwrap_err() .to_string(); assert!( err.contains("b.txt") && err.contains("the first change"), "{err}" ); write(root, "b.txt", b"b\n"); let (_, carried) = repo .checkout_carry(&[h1], "the first change", "checkout") .unwrap(); assert_eq!(carried, vec!["a.txt"]); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"a edited\n"); assert!(!root.join("b.txt").exists(), "h1 has no b.txt"); } /// Doctor and subrepos before the first commit (feedback 5 and 8): a /// user identity not stored in a new repository yet is fine while its /// secret key is there; an added subrepo is listed as not committed yet. #[test] fn a_new_repository_is_healthy_before_its_first_commit() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let home = root.join("home"); let mut first = Repo::init(&root.join("first")) .unwrap() .with_home(home.clone()); let (key, _) = first .create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); let repo = Repo::init(&root.join("second")) .unwrap() .with_home(home.clone()); // Only the secret key: no identity object anywhere this repository sees. let keys = repo.keys_dir.clone(); let _ = fs::remove_file(keys.join(format!("{}.identity", key.to_hex()))); assert!(keys.join(format!("{}.secret", key.to_hex())).is_file()); let r = repo.doctor(true); let id = r.checks.iter().find(|c| c.area == "identity").unwrap(); assert_eq!(id.health, Health::Ok, "{}", id.message); assert!(id.message.contains("first signed change"), "{}", id.message); // A subrepo added and not committed yet is listed, pinned where it is. let lib = root.join("lib"); let l = Repo::init(&lib).unwrap().with_home(home.clone()); write(&lib, "x.txt", b"x\n"); l.commit("x", &[]).unwrap(); let second = root.join("second"); write(&second, "a.txt", b"a\n"); repo.submodule_add(&lib.to_string_lossy(), "deps/lib", None) .unwrap(); let subs = repo.subrepos().unwrap(); assert_eq!(subs.len(), 1); assert_eq!( (subs[0].path.as_str(), subs[0].committed), ("deps/lib", false) ); assert_eq!(subs[0].actual.as_ref(), Some(&subs[0].pin)); repo.commit("add lib", &[]).unwrap(); let subs = repo.subrepos().unwrap(); assert!(subs[0].committed, "committed now"); } /// Commit, diff, hunks and discard use the partial snapshot: a file /// unchanged since the manifest is neither read nor rendered, and showing /// a change renders only what it edits, the same as from a full base. #[cfg(unix)] #[test] fn commits_and_diffs_leave_unchanged_files_alone() { 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")); write(root, "big.txt", b"lots\nof\nlines\n"); write(root, "a.txt", b"one\ntwo\n"); repo.commit("base", &[]).unwrap(); let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30); for f in ["big.txt", "a.txt"] { fs::File::options() .write(true) .open(root.join(f)) .unwrap() .set_modified(old) .unwrap(); } assert!(repo.snapshot_status("status").unwrap().statuses.is_empty()); // big.txt cannot be read at all from here on. fs::set_permissions(root.join("big.txt"), fs::Permissions::from_mode(0o000)).unwrap(); write(root, "a.txt", b"one\nTWO\nthree\n"); let diff = repo.diff_files(None).unwrap(); assert_eq!( diff.iter().map(|f| f.path.as_str()).collect::>(), ["a.txt"] ); let (_, hunks) = repo.hunks().unwrap(); assert!(!hunks.is_empty() && hunks.iter().all(|h| h.path == "a.txt")); let h = repo.commit("edit a", &[]).unwrap(); // Showing the change needs only a.txt's graph, and renders as a full base does. let c = repo.store().get_change(&h).unwrap(); let full = repo.load_state(&c.deps).unwrap(); let want = render_change(&c, &full, &full.materialize()); assert_eq!(repo.describe_change(&h).unwrap(), want); let (base, snap) = repo.change_base(&c).unwrap(); assert!(!base.is_full() && snap.unrendered.contains("big.txt")); // Discarding works the same way. write(root, "a.txt", b"scratch\n"); assert_eq!(repo.discard(&["a.txt".into()]).unwrap(), 1); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\nTWO\nthree\n"); fs::set_permissions(root.join("big.txt"), fs::Permissions::from_mode(0o644)).unwrap(); } /// A replace conflict (spec §9.3) is settled by a change that writes one /// candidate, or new bytes, at the path when the caller names it; a plain /// snapshot of a checkout never settles it on its own. #[test] fn replace_conflicts_are_settled_when_asked() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); let blob = |n: u8| { let mut v = vec![0u8; 64]; v[1] = n; v }; write(root, "img.bin", &blob(1)); repo.commit("add img", &[]).unwrap(); repo.bookmark_create("other", None).unwrap(); write(root, "img.bin", &blob(2)); repo.commit("img two", &[]).unwrap(); repo.bookmark_switch("other", false).unwrap(); write(root, "img.bin", &blob(3)); repo.commit("img three", &[]).unwrap(); repo.bookmark_switch("main", false).unwrap(); repo.merge("other").unwrap(); let heads = repo.bookmark_heads().unwrap(); let merged = repo.load_state(&heads).unwrap().materialize(); assert!( matches!(&merged.files["img.bin"].content, FileContent::Opaque { blobs } if blobs.len() == 2), "a replace conflict" ); // A snapshot of the checkout records nothing. assert!(repo .snapshot("status") .unwrap() .statuses .iter() .all(|s| s.path != "img.bin")); // Keeping candidate 3 settles it. let author = repo.author(); let edits = [FileEdit { path: "img.bin".into(), content: Some(blob(3)), }]; let c = repo .build_change(&heads, &edits, "keep three", author) .unwrap(); assert_eq!(c.kind, ChangeKind::Resolution); let mut st = repo.load_state(&heads).unwrap(); for d in &c.deps { assert!(st.contains(d)); } assert!( heads.iter().all(|h| c.deps.contains(h)), "depends on both contributors" ); st.add(c).unwrap(); let after = st.materialize(); assert!(!after.is_conflicted()); assert_eq!( after.files["img.bin"].content, FileContent::Opaque { blobs: vec![Repo::blob_ref(&blob(3))] } ); // New bytes settle it too. let edits = [FileEdit { path: "img.bin".into(), content: Some(blob(9)), }]; let c = repo.build_change(&heads, &edits, "new", author).unwrap(); let mut st = repo.load_state(&heads).unwrap(); st.add(c).unwrap(); assert_eq!( st.materialize().files["img.bin"].content, FileContent::Opaque { blobs: vec![Repo::blob_ref(&blob(9))] } ); } /// The history of a range of lines (spec §27): the changes that wrote /// or deleted its atoms, or deleted atoms from between them; edits outside /// the range and changes the range's lines survived are not in it. #[test] fn line_ranges_have_exact_histories() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "f.rs", b"use x;\nfn a() {\n one();\n two();\n}\n"); let base = repo.commit("add f", &[]).unwrap(); write(root, "f.rs", b"use y;\nfn a() {\n one();\n two();\n}\n"); let outside = repo.commit("change the import", &[]).unwrap(); write(root, "f.rs", b"use y;\nfn a() {\n ONE();\n two();\n}\n"); let inside = repo.commit("edit a line of a", &[]).unwrap(); write(root, "f.rs", b"use y;\nfn a() {\n ONE();\n}\n"); let deleted = repo.commit("delete a line of a", &[]).unwrap(); write(root, "f.rs", b"use y;\nfn a() {\n ONE();\n}\nfn b() {}\n"); let after = repo.commit("add b after a", &[]).unwrap(); let heads = repo.bookmark_heads().unwrap(); // Lines 2..4 are `fn a() {`, `ONE();`, `}` now. let hist: Vec = repo .line_history(&heads, "f.rs", 2, 4) .unwrap() .iter() .map(|e| e.hash) .collect(); for (h, what) in [ (base, "wrote it"), (inside, "edited a line"), (deleted, "deleted a line from inside"), ] { assert!(hist.contains(&h), "{what}"); } for (h, what) in [(outside, "an edit above"), (after, "an addition below")] { assert!(!hist.contains(&h), "{what}"); } assert_eq!(hist[0], deleted, "newest first"); // One line: only what touched it. let one: Vec = repo .line_history(&heads, "f.rs", 1, 1) .unwrap() .iter() .map(|e| e.hash) .collect(); assert!(one.contains(&outside) && !one.contains(&inside)); assert!(repo.line_history(&heads, "f.rs", 3, 9).is_err()); assert!(repo.line_history(&heads, "f.rs", 0, 1).is_err()); assert!(repo.line_history(&heads, "nope.rs", 1, 1).is_err()); } #[test] fn receipts_are_kept_with_their_state_and_verified() { use crate::receipt::{Receipt, SignedReceipt}; 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"); repo.commit("A", &[]).unwrap(); let heads = repo.bookmark_heads().unwrap(); let state = repo .store() .put_state(&StateObject::new(heads.clone())) .unwrap(); let out = repo.store_blob(b"built\n").unwrap(); let receipt = Receipt { job_name: "build".into(), pipeline_hash: ObjectRef([1; 32]), scope_tree_hash: ObjectRef([2; 32]), inputs: vec![], environment: ObjectRef([3; 32]), outputs: vec![out], exit_code: 0, duration_ms: 42, }; let sk = SecretKey::generate(); let runner = sk.identity("earth-1", "runner@earth", 1); let sr = SignedReceipt::sign( receipt.clone(), repo.id().unwrap(), state, "demo#1", &runner, &sk, ); let r = repo.add_receipt(&sr).unwrap(); let found = repo.receipts(&heads).unwrap(); assert_eq!(found.len(), 1); assert_eq!(found[0].attest, r); assert_eq!(found[0].signed, sr); assert!(found[0].problem.is_none() && !found[0].revoked); assert!(repo.receipts(&[ObjectRef([5; 32])]).unwrap().is_empty()); // Another repository's receipt, or a forged one, is refused. let elsewhere = SignedReceipt::sign(receipt.clone(), [0; 16], state, "x", &runner, &sk); assert!(repo .add_receipt(&elsewhere) .unwrap_err() .to_string() .contains("another repository")); let mut forged = sr.clone(); forged.receipt.exit_code = 1; assert!(repo .add_receipt(&forged) .unwrap_err() .to_string() .contains("does not verify")); // gc keeps the receipt, its runner and its output while the state lives. repo.gc(true).unwrap(); assert_eq!(repo.receipts(&heads).unwrap().len(), 1); assert_eq!(repo.read_blob(&out).unwrap(), b"built\n"); assert!(repo.fsck().unwrap().problems.is_empty()); } #[test] fn status_leaves_an_unchanged_manifest_alone() { 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"); write(root, "b.txt", b"2\n"); repo.commit("A", &[]).unwrap(); // Files last touched well outside the racy window. let old = std::time::SystemTime::now() - std::time::Duration::from_secs(60); for f in ["a.txt", "b.txt"] { fs::File::options() .write(true) .open(root.join(f)) .unwrap() .set_modified(old) .unwrap(); } repo.snapshot_status("status").unwrap(); let path = repo.manifest_path(); let first = fs::read(&path).unwrap(); repo.snapshot_status("status").unwrap(); assert_eq!( fs::read(&path).unwrap(), first, "an unchanged tree does not rewrite the manifest" ); // A change rewrites it. write(root, "a.txt", b"1\n2\n"); repo.snapshot_status("status").unwrap(); assert_ne!(fs::read(&path).unwrap(), first); } #[test] fn large_text_files_travel_and_survive_gc_as_text_blobs() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_path = root.join("origin"); Repo::init(&origin_path).unwrap(); let origin_url = origin_path.to_string_lossy().to_string(); let a = Repo::clone(&origin_url, &root.join("a")).unwrap(); let big: String = (0..5000).map(|i| format!("line {i}\n")).collect(); write(&root.join("a"), "big.txt", big.as_bytes()); write(&root.join("a"), "small.txt", b"small\n"); let h1 = a.commit("large", &[]).unwrap(); let (_, body) = a.store().get(&h1).unwrap(); assert!(body.len() < 2048, "the change is {} bytes", body.len()); assert!(a.push("origin", false).unwrap()[0].ok); // A clone reads the text from the blobs that came with the change. let b = Repo::clone(&origin_url, &root.join("b")).unwrap(); assert_eq!(fs::read(root.join("b/big.txt")).unwrap(), big.as_bytes()); // An edit in the middle of the stored insert is an ordinary change. let edited = big.replace("line 2500\n", "line 2500, edited\n"); write(&root.join("b"), "big.txt", edited.as_bytes()); let h2 = b.commit("edit", &[]).unwrap(); assert!(b.store().get_change(&h2).unwrap().edits.len() <= 2); assert!(b.push("origin", false).unwrap()[0].ok); assert!(a.pull("origin").unwrap().checked_out); assert_eq!(fs::read(root.join("a/big.txt")).unwrap(), edited.as_bytes()); assert_eq!( a.fsck().unwrap().errors(), 0, "{:?}", a.fsck().unwrap().problems ); // With zero retention gc keeps the text blobs of every kept change. let mut a = Repo::open(&root.join("a")).unwrap(); a.config.set("core.op-retention", "0").unwrap(); a.save_config().unwrap(); let a = Repo::open(&root.join("a")).unwrap(); a.gc(true).unwrap(); let a = Repo::open(&root.join("a")).unwrap(); assert_eq!(a.store().get_change(&h1).unwrap().message, "large"); assert_eq!( a.fsck().unwrap().errors(), 0, "{:?}", a.fsck().unwrap().problems ); // And a clone of the packed repository reads them from its packs. let c = Repo::clone(&root.join("a").to_string_lossy(), &root.join("c")).unwrap(); assert_eq!(fs::read(root.join("c/big.txt")).unwrap(), edited.as_bytes()); drop(c); } #[test] fn operations_that_both_replace_a_working_copy_are_not_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"); repo.commit("b", &[]).unwrap(); let (x, op) = repo.head_op().unwrap(); let ws = *op.view.workspaces.keys().next().unwrap(); let mut one = op.view.clone(); one.workspaces.insert(ws, a); let mut two = op.view.clone(); two.workspaces.insert(ws, ObjectRef([7; 32])); let first = repo.record_op(&[x], "one", one).unwrap(); let e = repo.record_op(&[x], "two", two).unwrap_err(); assert!( e.to_string() .contains("changed this working copy at the same time"), "{e}" ); assert_eq!( repo.head_op().unwrap().0, first, "the second is not recorded" ); } /// Metalgear, 2026-09-29 (oxygen#36): a status run while a bookmark switch /// rewrote the working copy recorded the half-written files as edits, and /// its view replaced the switch's. #[test] fn a_snapshot_during_a_switch_never_records_the_switch_as_edits() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path().to_path_buf(); let mut repo = Repo::init(&root).unwrap(); write(&root, "keep.txt", b"keep\n"); repo.commit("base", &[]).unwrap(); repo.bookmark_create("small", None).unwrap(); for i in 0..300 { write( &root, &format!("many/f{i}.txt"), format!("file {i}\n").as_bytes(), ); } repo.commit("many", &[]).unwrap(); let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let watcher = { let (root, stop) = (root.clone(), stop.clone()); std::thread::spawn(move || { let r = Repo::open(&root).unwrap(); let mut ran = 0; while !stop.load(std::sync::atomic::Ordering::Relaxed) { if r.snapshot_status("oxy status").is_ok() { ran += 1; } } ran }) }; for i in 0..20 { let to = if i % 2 == 0 { "small" } else { "main" }; repo.bookmark_switch(to, false).unwrap(); } stop.store(true, std::sync::atomic::Ordering::Relaxed); assert!(watcher.join().unwrap() > 0, "the watcher snapshotted"); // Back on main, clean, with every file where main has it. let repo = Repo::open(&root).unwrap(); let s = repo.snapshot_status("oxy status").unwrap(); assert!(s.statuses.is_empty(), "{:?}", statuses(&s)); assert!(s.wc.edits.is_empty() && s.wc.tree_ops.is_empty()); assert_eq!(s.wc.deps, repo.bookmark_heads().unwrap()); assert!(root.join("many/f299.txt").exists()); } /// Metalgear, 2026-09-29 (oxygen#38): a parent's `bookmark switch --carry` /// put a subrepo that was ahead of its pin back at the pin and moved the /// subrepo's own bookmark backwards. #[test] fn a_parent_switch_with_carry_leaves_a_subrepo_ahead_of_its_pin_alone() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); 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(); write(&root.join("lib"), "lib.txt", b"v1\n"); lib.commit("lib v1", &[]).unwrap(); assert!(lib.push("origin", false).unwrap()[0].ok); let app_dir = root.join("app"); let mut app = Repo::init(&app_dir).unwrap(); write(&app_dir, "main.txt", b"app\n"); app.submodule_add(&lib_url, "libs/lib", None).unwrap(); app.commit("app with lib", &[]).unwrap(); app.bookmark_create("feature", None).unwrap(); // New work in the subrepo, not yet pinned by the parent. let nested_dir = app_dir.join("libs/lib"); let nested = Repo::open(&nested_dir).unwrap(); write(&nested_dir, "new.txt", b"new\n"); let l2 = nested.commit("lib: new file", &[]).unwrap(); drop(nested); let (_, carried) = app.bookmark_switch_carry("feature").unwrap(); assert!(carried.contains(&"libs/lib".to_string()), "{carried:?}"); let nested = Repo::open(&nested_dir).unwrap(); assert!( nested.bookmark_heads().unwrap().contains(&l2), "its bookmark kept its work" ); assert!(nested_dir.join("new.txt").exists(), "and its files"); let s = app.snapshot("status").unwrap(); assert_eq!( statuses(&s), vec![("libs/lib".to_string(), ChangeStatus::Modified)] ); } /// oxygen#29 (metalgear, rstar-api): after a proposal landed on the hub, /// moving `main` to a change this clone had pushed on another bookmark, /// the first `pull` said "Already up to date" while `main` was behind. #[test] fn pull_takes_a_remote_move_to_changes_already_here() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_path = root.join("origin"); let origin = Repo::init(&origin_path).unwrap(); let url = origin_path.to_string_lossy().to_string(); let mut b = Repo::clone(&url, &root.join("b")).unwrap(); write(&root.join("b"), "base.txt", b"base\n"); b.commit("base", &[]).unwrap(); assert!(b.push("origin", false).unwrap()[0].ok); // A feature bookmark with one change, pushed. b.bookmark_create("feat", None).unwrap(); b.bookmark_switch("feat", false).unwrap(); write(&root.join("b"), "x.txt", b"x\n"); let x = b.commit("x", &[]).unwrap(); assert!(b.push("origin", false).unwrap()[0].ok); b.bookmark_switch("main", false).unwrap(); // The remote lands it: its main moves to the feature's state. let feat = origin .store() .get_state(&origin.view().unwrap().bookmarks["feat"]) .unwrap() .heads; origin.bookmark_set("main", &feat).unwrap(); let r = b.pull("origin").unwrap(); assert!(r.changed, "{r:?}"); assert!(r.heads.contains(&x) || b.load_state(&r.heads).unwrap().contains(&x)); let main = b .bookmarks() .unwrap() .into_iter() .find(|m| m.name == "main") .unwrap(); assert_eq!(main.behind, Some(0), "{main:?}"); } /// oxygen#48: a remote's bookmarks are names here (`origin/NAME`); switching /// to one only the remote has makes a local bookmark from it; a pull moves /// every local bookmark the remote has too; a clone checks out the remote's /// default bookmark. #[test] fn remote_bookmarks_are_named_switched_to_pulled_and_cloned() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_path = root.join("origin"); let mut origin = Repo::init(&origin_path).unwrap(); let url = origin_path.to_string_lossy().to_string(); let mut a = Repo::clone(&url, &root.join("a")).unwrap(); write(&root.join("a"), "base.txt", b"base\n"); a.commit("base", &[]).unwrap(); assert!(a.push("origin", false).unwrap()[0].ok); a.bookmark_create("develop", None).unwrap(); a.bookmark_switch("develop", false).unwrap(); write(&root.join("a"), "base.txt", b"base\nd1\n"); let d1 = a.commit("d1", &[]).unwrap(); assert!(a.push("origin", false).unwrap()[0].ok); // A clone has main only; origin/develop is a name all the same. let c_root = root.join("c"); let mut c = Repo::clone(&url, &c_root).unwrap(); assert_eq!(c.config.bookmark, "main"); assert!(!c.view().unwrap().bookmarks.contains_key("develop")); assert_eq!(c.resolve_heads("origin/develop").unwrap(), vec![d1]); assert_eq!(c.resolve_change("origin/develop").unwrap(), d1); assert_eq!(c.remote_only_bookmarks().unwrap().len(), 1); // Switching makes the local bookmark from origin's. c.bookmark_switch("develop", false).unwrap(); assert_eq!(c.config.bookmark, "develop"); assert_eq!(c.bookmark_heads().unwrap(), vec![d1]); assert_eq!(fs::read(c_root.join("base.txt")).unwrap(), b"base\nd1\n"); assert!(c.remote_only_bookmarks().unwrap().is_empty()); assert!(c.bookmark_switch("nowhere", false).is_err()); // Both bookmarks move on the remote; one pull takes both. write(&root.join("a"), "base.txt", b"base\nd1\nd2\n"); let d2 = a.commit("d2", &[]).unwrap(); assert!(a.push("origin", false).unwrap()[0].ok); a.bookmark_switch("main", false).unwrap(); write(&root.join("a"), "m.txt", b"m\n"); let m = a.commit("m", &[]).unwrap(); assert!(a.push("origin", false).unwrap()[0].ok); let r = c.pull("origin").unwrap(); assert!(r.changed && r.checked_out, "{r:?}"); assert_eq!(r.heads, vec![d2]); assert_eq!(r.others, vec!["main".to_string()]); assert!(c.resolve_heads("main").unwrap().contains(&m)); assert_eq!( fs::read(c_root.join("base.txt")).unwrap(), b"base\nd1\nd2\n" ); let again = c.pull("origin").unwrap(); assert!(!again.changed && again.others.is_empty(), "{again:?}"); // A clone checks out the remote's default bookmark. origin.config.set("workspace.bookmark", "develop").unwrap(); origin.save_config().unwrap(); let e = Repo::clone(&url, &root.join("e")).unwrap(); assert_eq!(e.config.bookmark, "develop"); assert_eq!(e.bookmark_heads().unwrap(), vec![d2]); let reopened = Repo::open(&root.join("e")).unwrap(); assert_eq!(reopened.config.bookmark, "develop"); // REMOTE/NAME names the remote's bookmark to switch to. let mut e = reopened; e.bookmark_switch("origin/main", false).unwrap(); assert_eq!(e.config.bookmark, "main"); assert!(e.bookmark_heads().unwrap().contains(&m)); } /// oxygen#51, #52: --at-op reads an earlier view and refuses changes; /// op restore goes back to an operation, files included, and undo takes /// the restore back whole; op revert undoes one operation alone; each /// operation's changes are described from the views. #[test] fn operations_can_be_read_restored_and_reverted() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"a\n"); let first = repo.commit("first", &[]).unwrap(); write(root, "a.txt", b"a\nb\n"); let second = repo.commit("second", &[]).unwrap(); let at_second = repo.head_op().unwrap().0.to_hex(); repo.bookmark_create("keep", None).unwrap(); let created = repo.head_op().unwrap().0.to_hex(); write(root, "n.txt", b"n\n"); let third = repo.commit("third", &[]).unwrap(); // What each operation changed. let ops = repo.op_log().unwrap(); let changes = repo.view_changes(&ops[1].1.view, &ops[0].1.view).unwrap(); assert!( changes .iter() .any(|c| matches!(c, ViewChange::BookmarkMoved { name, .. } if name == "main")), "{changes:?}" ); assert!(changes .iter() .any(|c| matches!(c, ViewChange::WorkingCopy { .. }))); // Reading at an earlier operation. let old = Repo::open(root).unwrap().at_op(&at_second[..10]).unwrap(); assert_eq!(old.bookmark_heads().unwrap(), vec![second]); assert!(!old.view().unwrap().bookmarks.contains_key("keep")); write(root, "z.txt", b"z\n"); assert!(old.commit("refused", &[]).is_err(), "--at-op is read-only"); fs::remove_file(root.join("z.txt")).unwrap(); assert!(repo.resolve_op("zz").is_err()); assert_eq!(repo.resolve_op("@").unwrap().0, repo.head_op().unwrap().0); // Restore goes back, files and all; undo takes it back whole. repo.op_restore(&at_second, false).unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![second]); assert!(!root.join("n.txt").exists()); repo.undo().unwrap(); let mut heads = repo.bookmark_heads().unwrap(); heads.sort(); let mut want = vec![second, third]; want.sort(); assert_eq!(heads, want); assert!( root.join("n.txt").exists(), "the files came back with the undo" ); // Revert the bookmark's creation alone: the commit after it stays. let r = repo.op_revert(&created).unwrap(); assert_eq!(r.reverted, vec!["keep".to_string()]); assert!(!repo.view().unwrap().bookmarks.contains_key("keep")); assert!(repo .load_state(&repo.bookmark_heads().unwrap()) .unwrap() .contains(&third)); assert!(repo.op_revert(&created).is_err(), "nothing left to revert"); let _ = first; } /// oxygen#35: a history-only state keeps no inserted text. It reads a /// stored insert's text blob only when it builds that file's graph, and /// builds the same graph a full load does. #[test] fn history_only_states_read_text_only_for_the_graphs_they_build() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); let big: String = (0..3000).map(|i| format!("big line {i}\n")).collect(); let other: String = (0..3000).map(|i| format!("other line {i}\n")).collect(); write(root, "big.txt", big.as_bytes()); write(root, "other.txt", other.as_bytes()); let h = repo.commit("two large files", &[]).unwrap(); write(root, "big.txt", format!("{big}one more\n").as_bytes()); let h2 = repo.commit("append", &[]).unwrap(); let full = repo.load_state_full(&[h2]).unwrap().materialize(); let text = |snap: &crate::state::Snapshot, p: &str| match &snap.files[p].content { FileContent::Text { bytes, .. } => bytes.clone(), other => panic!("{other:?}"), }; // Built on demand, every graph renders as the full load's. let mut hist = repo.load_state_history(&[h2]).unwrap(); let big_node = full.files["big.txt"].node; assert!(hist.graph_key(&big_node).is_some(), "not built yet"); repo.load_all_graphs(&mut hist).unwrap(); let rendered = hist.materialize(); for p in ["big.txt", "other.txt"] { assert_eq!(text(&rendered, p), text(&full, p), "{p}"); } // Without other.txt's text blob, history still loads and big.txt still // builds: nothing read the other text. let (_, texts) = repo.store().get_change_lazy(&h).unwrap(); assert_eq!(texts.len(), 2); let content = |r: &ObjectRef| -> Vec { let blob = crate::objects::Blob::decode(&repo.store().get(r).unwrap().1).unwrap(); blob.chunks .iter() .flat_map(|c| repo.store().get(c).unwrap().1) .collect() }; let other_text = texts .iter() .filter_map(|t| match t { crate::objects::TextRef::Blob(r, _, _) => Some(*r), _ => None, }) .find(|r| content(r).windows(12).any(|w| w == b"other line 1")) .unwrap(); fs::remove_file(repo.store().path_for(&other_text)).unwrap(); let mut hist = repo.load_state_history(&[h2]).unwrap(); repo.load_graphs_of(&mut hist, &[big_node]).unwrap(); assert!(repo .load_graphs_of(&mut hist, &[full.files["other.txt"].node]) .is_err()); } /// oxygen#35: the first status after a commit read and rendered every /// file (79 s and more than 14 GB on the Linux tree), because the /// manifest had no graph keys for files the commit added. #[test] fn a_commit_leaves_a_manifest_the_next_status_trusts() { use std::os::unix::fs::PermissionsExt; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); let files = ["a.txt", "b.txt", "d/c.txt"]; for p in files { write(root, p, format!("{p}\n").as_bytes()); let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30); fs::File::options() .write(true) .open(root.join(p)) .unwrap() .set_modified(old) .unwrap(); } repo.commit("added", &[]).unwrap(); let (_, _, m) = repo.read_manifest().unwrap().unwrap(); assert!( m.entries.values().all(|e| e.graph.is_some()), "keys recorded at commit" ); // Unreadable files prove the next status reads none of them. for p in files { fs::set_permissions(root.join(p), fs::Permissions::from_mode(0o000)).unwrap(); } let snap = repo.snapshot_status("status").unwrap(); for p in files { fs::set_permissions(root.join(p), fs::Permissions::from_mode(0o644)).unwrap(); } assert!(!snap.changed && snap.statuses.is_empty()); assert_eq!(snap.base_snapshot.unrendered.len(), files.len()); } /// oxygen#35: a checkout reads, renders and writes only the files whose /// graphs differ, and renders the rest a batch at a time. #[test] fn checkout_touches_only_what_differs_and_renders_in_batches() { use std::os::unix::fs::PermissionsExt; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = root.join("origin"); let mut repo = Repo::init(&origin).unwrap(); // More files than one batch holds. let n = 2100; let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30); for i in 0..n { let p = format!("f/{i:04}.txt"); write(&origin, &p, format!("file {i}\n").as_bytes()); fs::File::options() .write(true) .open(origin.join(&p)) .unwrap() .set_modified(old) .unwrap(); } repo.commit("many", &[]).unwrap(); repo.bookmark_create("feature", None).unwrap(); repo.bookmark_switch("feature", false).unwrap(); write(&origin, "f/0007.txt", b"changed\n"); repo.commit("one change", &[]).unwrap(); // Switching back to main must not read the unchanged files. for i in 0..n { if i != 7 { let p = origin.join(format!("f/{i:04}.txt")); fs::set_permissions(&p, fs::Permissions::from_mode(0o000)).unwrap(); } } let r = repo.bookmark_switch("main", false).unwrap(); for i in 0..n { let p = origin.join(format!("f/{i:04}.txt")); fs::set_permissions(&p, fs::Permissions::from_mode(0o644)).unwrap(); } assert_eq!((r.written, r.removed), (1, 0), "{r:?}"); assert_eq!(fs::read(origin.join("f/0007.txt")).unwrap(), b"file 7\n"); assert!(repo.snapshot_status("status").unwrap().statuses.is_empty()); // A clone renders every file, across batches. let c = root.join("clone"); Repo::clone(&origin.to_string_lossy(), &c).unwrap(); for i in [0, 2047, 2048, n - 1] { assert_eq!( fs::read(c.join(format!("f/{i:04}.txt"))).unwrap(), format!("file {i}\n").as_bytes() ); } let cloned = Repo::open(&c).unwrap(); assert!(cloned .snapshot_status("status") .unwrap() .statuses .is_empty()); } /// A file written just before the manifest (inside the racy window) is /// checked by its hash: trusted when equal, caught when edited in place /// with the same size and time. #[test] fn a_racy_manifest_entry_is_checked_by_hash() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"one\n"); repo.commit("fresh", &[]).unwrap(); assert!(repo.clean_by_manifest().unwrap(), "fresh but equal"); let t = fs::metadata(root.join("a.txt")) .unwrap() .modified() .unwrap(); write(root, "a.txt", b"two\n"); fs::File::options() .write(true) .open(root.join("a.txt")) .unwrap() .set_modified(t) .unwrap(); assert!( !repo.clean_by_manifest().unwrap(), "same size and time, other bytes" ); assert_eq!( statuses(&repo.snapshot_status("status").unwrap()), vec![("a.txt".to_string(), ChangeStatus::Modified)] ); } /// A fetch on one machine moves changes as stored, text blobs among the /// objects; a change whose text blob did not come is refused, not stored. #[test] fn a_raw_bundle_without_a_changes_text_is_refused() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let a = Repo::init(&root.join("a")).unwrap(); let big: String = (0..3000).map(|i| format!("line {i}\n")).collect(); write(&root.join("a"), "big.txt", big.as_bytes()); let h = a.commit("big", &[]).unwrap(); let bundle = a.bundle_raw_for(&[h], None).unwrap(); assert_eq!(bundle.raw_changes.len(), 1); assert!(bundle.changes.is_empty()); let b = Repo::init(&root.join("b")).unwrap(); let mut without = bundle.clone(); without.objects.retain(|(ty, _)| *ty != ObjectType::Blob); let e = b.import_bundle(&without, false).unwrap_err(); assert!(e.to_string().contains("came without its text blob"), "{e}"); assert!(!b.store().has(&h)); assert_eq!(b.import_bundle(&bundle, false).unwrap(), vec![h]); assert_eq!(b.store().get_change(&h).unwrap().message, "big"); } /// oxygen#30 (vscode): a directory deleted in one line of history and made /// again was a second node for one Git path, so a file moved into it on /// another line, where the first node still stood, ended up in two places. /// An import now brings the directory back rather than make a new one. #[test] fn git_import_brings_a_directory_back_rather_than_making_a_second() { 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, "d/a.txt", b"a\n"); write(&g, "c/x.txt", b"x\n"); git(&["add", "."]); git(&["commit", "-qm", "base"]); // One line: move x into d while d still stands. git(&["checkout", "-qb", "side"]); git(&["mv", "c/x.txt", "d/x.txt"]); git(&["commit", "-qm", "x into d"]); // The other: d goes, comes back, and x moves into it too. git(&["checkout", "-q", "main"]); git(&["rm", "-q", "d/a.txt"]); git(&["commit", "-qm", "d goes"]); write(&g, "d/b.txt", b"b\n"); git(&["add", "."]); git(&["commit", "-qm", "d comes back"]); git(&["mv", "c/x.txt", "d/x.txt"]); git(&["commit", "-qm", "x into d again"]); git(&["merge", "-q", "--no-edit", "side"]); let repo = Repo::init(&root.join("o")).unwrap(); repo.import_git(&g, "main").unwrap(); let v = repo.verify_git_import(&g, "main").unwrap(); assert!(v.mismatches.is_empty(), "{:?}", v.mismatches); let st = repo .load_state_history(&repo.bookmark_heads().unwrap()) .unwrap(); let view = st.tree().view(); assert!(view.conflicts.is_empty(), "{:?}", view.conflicts); // The directory is the one the first commit made. // Commits made in one second tie on time: find the first by its message. let first = repo .log() .unwrap() .into_iter() .find(|e| e.change.message.starts_with("base")) .unwrap(); let base = repo .load_state_history(&[first.hash]) .unwrap() .tree() .view(); assert_eq!(view.entries["d"].node, base.entries["d"].node); } /// oxygen#30, the rest: a placement left under a directory another line /// removed is out of sight, so no diff reaches it; the merge that settles /// where the file is retires it. #[test] fn git_import_merge_retires_a_placement_under_a_removed_directory() { 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 run = |args: &[&str]| { 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() }; let git = |args: &[&str]| { let out = run(args); assert!( out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr) ); }; git(&["init", "-q", "-b", "main"]); write(&g, "c/x.txt", b"x\n"); write(&g, "keep.txt", b"k\n"); git(&["add", "."]); git(&["commit", "-qm", "base"]); // One line renames the file within c; the other moves it out of c, // which goes. git(&["checkout", "-qb", "side"]); git(&["mv", "c/x.txt", "c/y.txt"]); git(&["commit", "-qm", "rename within c"]); git(&["checkout", "-q", "main"]); fs::create_dir_all(g.join("n")).unwrap(); git(&["mv", "c/x.txt", "n/x.txt"]); git(&["commit", "-qm", "move out of c"]); // Git cannot merge the two renames; the merge keeps n/x.txt. let _ = run(&["merge", "-q", "side"]); let _ = run(&[ "rm", "-q", "--cached", "--ignore-unmatch", "c/y.txt", "c/x.txt", ]); let _ = std::fs::remove_dir_all(g.join("c")); write(&g, "n/x.txt", b"x\n"); git(&["add", "-A"]); git(&["commit", "-qm", "merge: keep n/x.txt"]); let repo = Repo::init(&root.join("o")).unwrap(); repo.import_git(&g, "main").unwrap(); let v = repo.verify_git_import(&g, "main").unwrap(); assert!(v.mismatches.is_empty(), "{:?}", v.mismatches); let st = repo .load_state_history(&repo.bookmark_heads().unwrap()) .unwrap(); let view = st.tree().view(); assert!(view.conflicts.is_empty(), "{:?}", view.conflicts); assert!(view.entries.contains_key("n/x.txt")); } /// oxygen#30, orphans: a file added on one line into a directory the other /// line removed is placed where the tree cannot show it; Git's merge keeps /// the file, and the import's merge leaves exactly one of it. #[test] fn git_import_merge_leaves_no_orphan_behind() { 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]| { 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) ); }; git(&["init", "-q", "-b", "main"]); write(&g, "d/f.txt", b"f\n"); write(&g, "keep.txt", b"k\n"); git(&["add", "."]); git(&["commit", "-qm", "base"]); git(&["checkout", "-qb", "side"]); write(&g, "d/new.txt", b"new\n"); git(&["add", "."]); git(&["commit", "-qm", "add into d"]); git(&["checkout", "-q", "main"]); git(&["rm", "-q", "d/f.txt"]); git(&["commit", "-qm", "d goes"]); git(&["merge", "-q", "--no-edit", "side"]); let repo = Repo::init(&root.join("o")).unwrap(); repo.import_git(&g, "main").unwrap(); let v = repo.verify_git_import(&g, "main").unwrap(); assert!(v.mismatches.is_empty(), "{:?}", v.mismatches); let st = repo .load_state_history(&repo.bookmark_heads().unwrap()) .unwrap(); let view = st.tree().view(); assert!(view.conflicts.is_empty(), "{:?}", view.conflicts); assert!(view.entries.contains_key("d/new.txt")); } /// fsck checks a change's text as the blobs it is stored in, without /// decoding it: a missing text blob is an error named for the change. #[test] fn fsck_names_a_missing_text_blob() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); let big: String = (0..3000).map(|i| format!("line {i}\n")).collect(); write(root, "big.txt", big.as_bytes()); let h = repo.commit("big", &[]).unwrap(); assert_eq!(repo.fsck().unwrap().errors(), 0); let (_, texts) = repo.store().get_change_lazy(&h).unwrap(); let Some(crate::objects::TextRef::Blob(b, _, _)) = texts.first().copied() else { panic!("a stored text") }; fs::remove_file(repo.store().path_for(&b)).unwrap(); let rep = repo.fsck().unwrap(); assert!(rep.errors() > 0); assert!( rep.problems.iter().any(|p| p.what.contains(&h.short(12))), "{:?}", rep.problems ); } /// oxygen#32: a subrepo on the same hub as one of the parent's remotes is /// reached with that remote's token and CA (a private subrepo then clones /// for anyone who can read the parent); another hub gets none of them. #[test] fn a_subrepo_on_the_parents_hub_borrows_its_credentials() { hermetic(); let dir = tempfile::tempdir().unwrap(); let mut repo = Repo::init(dir.path()).unwrap(); repo.config .set( "remote.origin.url", "https://Hub.example:7343/doomsdayone/metalgear", ) .unwrap(); repo.config.set("remote.origin.token", "secret").unwrap(); repo.config .set("remote.origin.ca", "/tmp/hub-ca.pem") .unwrap(); let same = repo.credentials_for("https://hub.example:7343/doomsdayone/rstar-api"); assert_eq!(same.token.as_deref(), Some("secret")); assert_eq!(same.ca.as_deref(), Some(Path::new("/tmp/hub-ca.pem"))); for other in [ "https://elsewhere.example:7343/doomsdayone/rstar-api", "https://hub.example/doomsdayone/rstar-api", "http://hub.example:7343/doomsdayone/rstar-api", ] { let o = repo.credentials_for(other); assert_ne!(o.token.as_deref(), Some("secret"), "{other}"); } } /// oxygen#47: a remote with no token of its own uses the user's credential /// store for its hub, and a nested checkout finds its parent's remote /// token there instead of keeping a copy. #[test] fn remotes_use_the_credential_store_and_the_enclosing_checkout() { hermetic(); let home = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap(); let mut parent = Repo::init(dir.path()).unwrap().with_home(home.path()); parent .config .set("remote.origin.url", "https://hub.example/o/parent") .unwrap(); parent.save_config().unwrap(); assert_eq!(parent.remote_options("origin").token, None); crate::credentials::Credentials::at(home.path()) .save("https://hub.example", "stored", Some("andy"), None) .unwrap(); assert_eq!( parent.remote_options("origin").token.as_deref(), Some("stored") ); let empty_home = tempfile::tempdir().unwrap(); let nested = dir.path().join("deps").join("child"); let mut child = Repo::init(&nested).unwrap().with_home(empty_home.path()); child .config .set("remote.origin.url", "https://hub.example/o/child") .unwrap(); child.save_config().unwrap(); assert_eq!(child.remote_options("origin").token, None); parent .config .set("remote.origin.token", "checkout") .unwrap(); parent.save_config().unwrap(); assert_eq!( child.remote_options("origin").token.as_deref(), Some("checkout") ); // Another hub's checkout token is not borrowed. child .config .set("remote.origin.url", "https://other.example/o/child") .unwrap(); assert_eq!(child.remote_options("origin").token, None); } #[test] fn the_metadata_cache_survives_a_torn_tail() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"1\n"); let h = repo.commit("first", &[]).unwrap(); let meta = repo.store().get_change_meta(&h).unwrap(); let cache = repo.store().dir().join("metas"); let len = fs::metadata(&cache).unwrap().len(); assert!(len > 0, "a change read is cached"); // A torn record at the end: the cache reads up to it and cuts it off. fs::OpenOptions::new() .append(true) .open(&cache) .unwrap() .write_all(&[7; 20]) .unwrap(); let again = Repo::open(root).unwrap(); assert_eq!(again.store().get_change_meta(&h).unwrap(), meta); assert_eq!(fs::metadata(&cache).unwrap().len(), len); } #[test] fn a_manifest_that_does_not_check_reads_as_none() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"1\n"); repo.commit("first", &[]).unwrap(); let path = repo.manifest_path(); let mut bytes = fs::read(&path).unwrap(); assert!(Repo::open(root).unwrap().read_manifest().unwrap().is_some()); let last = bytes.len() - 1; bytes[last] ^= 1; fs::write(&path, &bytes).unwrap(); assert!(Repo::open(root).unwrap().read_manifest().unwrap().is_none()); // The next status reads the files and writes it again. let again = Repo::open(root).unwrap(); assert!(!again.snapshot("status").unwrap().changed); assert!(again.read_manifest().unwrap().is_some()); } #[test] fn a_commit_leaves_a_manifest_that_proves_the_working_copy_clean() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"1\n"); write(root, "b.txt", b"2\n"); // b.txt outside the racy window, so snapshots take it as unchanged. let old = std::time::SystemTime::now() - std::time::Duration::from_secs(60); fs::File::options() .write(true) .open(root.join("b.txt")) .unwrap() .set_modified(old) .unwrap(); repo.commit("first", &[]).unwrap(); assert!(repo.clean_by_manifest().unwrap(), "after the first commit"); repo.snapshot_status("status").unwrap(); write(root, "a.txt", b"1\n2\n"); repo.commit("second", &[]).unwrap(); assert!( repo.clean_by_manifest().unwrap(), "after a commit of one file" ); // Edited, seen by a status, edited again, then committed: the status // left the first edit's stat, which the commit must not keep. write(root, "a.txt", b"1\n2\n3\n"); repo.snapshot_status("status").unwrap(); write(root, "a.txt", b"1\n2\n3\n4\n5\n"); repo.commit("third", &[]).unwrap(); assert!( repo.clean_by_manifest().unwrap(), "after an edit a status saw" ); } #[test] fn a_conflicted_rebase_can_be_resolved_in_the_working_copy() { 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"1\n2\n"); let a = repo.commit("A", &[]).unwrap(); repo.bookmark_create("develop", Some(&[a])).unwrap(); write(root, "a.txt", b"1\nX\n"); let c = repo.commit("C: 2 to X", &[]).unwrap(); repo.bookmark_switch("develop", false).unwrap(); write(root, "a.txt", b"1\nY\n"); let d = repo.commit("D: 2 to Y", &[]).unwrap(); repo.bookmark_switch("main", false).unwrap(); let err = repo.rebase(&c, &[d]).unwrap_err(); assert!(matches!(err, RepoError::Conflicted(_)), "{err}"); let r = repo.rebase_into_working_copy(&c, &[d]).unwrap(); assert!(r.changed && r.checked_out); let snap = repo.snapshot_status("status").unwrap(); assert!( snap.base_snapshot.is_conflicted(), "the conflict is in the working copy" ); let text = fs::read_to_string(root.join("a.txt")).unwrap(); assert!(text.contains('X') && text.contains('Y'), "{text}"); write(root, "a.txt", b"1\nXY\n"); repo.commit("Resolve", &[]).unwrap(); let st = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap(); assert!(st.contains(&c) && st.contains(&d)); let files = st.materialize().files; assert!(!files["a.txt"].is_conflicted()); assert!( matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"1\nXY\n") ); // A change the current bookmark does not hold is refused. repo.bookmark_switch("develop", false).unwrap(); write(root, "b.txt", b"b\n"); let e = repo.commit("E", &[]).unwrap(); repo.bookmark_switch("main", false).unwrap(); let err = repo.rebase_into_working_copy(&e, &[a]).unwrap_err(); assert!( err.to_string().contains("not on the current bookmark"), "{err}" ); } #[test] fn an_ignore_rule_drops_an_added_file_but_not_a_tracked_one() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"a\n"); write(root, "kept.log", b"tracked\n"); repo.commit("first", &[]).unwrap(); write(root, "build.log", b"made\n"); let snap = repo.snapshot_status("status").unwrap(); assert!(snap.statuses.iter().any(|s| s.path == "build.log")); // A rule written afterwards leaves the added file out again, while the // committed one stays tracked. write(root, ".gitignore", b"*.log\n"); let snap = repo.snapshot_status("status").unwrap(); let paths: Vec<&str> = snap.statuses.iter().map(|s| s.path.as_str()).collect(); assert_eq!(paths, vec![".gitignore"], "{paths:?}"); write(root, "kept.log", b"tracked, edited\n"); let snap = repo.snapshot_status("status").unwrap(); assert!(snap.statuses.iter().any(|s| s.path == "kept.log")); } #[test] fn shed_graphs_come_back_the_same() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"1\n2\n3\n"); write(root, "b.txt", b"x\n"); repo.commit("first", &[]).unwrap(); write(root, "a.txt", b"1\ntwo\n3\n4\n"); repo.commit("second", &[]).unwrap(); let heads = repo.bookmark_heads().unwrap(); let mut st = repo.load_state_history(&heads).unwrap(); let view = st.tree().view(); let nodes: Vec = ["a.txt", "b.txt"] .iter() .map(|p| view.entries[*p].node) .collect(); repo.load_graphs_of(&mut st, &nodes).unwrap(); let before: Vec> = nodes.iter().map(|n| st.render_file(n).0).collect(); assert_eq!(st.built_count(), 2); let shed = st.shed_graphs(|b| repo.store_blob(b)).unwrap(); assert_eq!((shed, st.built_count()), (2, 0)); // A later change applies on top of the shed history. write(root, "a.txt", b"1\ntwo\n3\n4\n5\n"); let h3 = repo.commit("third", &[]).unwrap(); let (skeleton, texts) = repo.store().get_change_lazy(&h3).unwrap(); st.add_known(h3, skeleton, texts); repo.load_graphs_of(&mut st, &nodes).unwrap(); let after: Vec> = nodes.iter().map(|n| st.render_file(n).0).collect(); assert_eq!(after[1], before[1]); assert_eq!(after[0], b"1\ntwo\n3\n4\n5\n"); }