//! Tests for the repository layer. use super::*; use crate::protocol::SignedPolicy; /// Point `$OXY_HOME` at a throwaway directory once per test process so no /// test reads or writes the developer's real user configuration or keys. fn hermetic() { static ONCE: std::sync::Once = std::sync::Once::new(); static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); ONCE.call_once(|| { let d = tempfile::tempdir().unwrap(); std::env::set_var("OXY_HOME", d.path()); DIR.set(d).ok(); }); } fn write(root: &Path, rel: &str, content: &[u8]) { let p = root.join(rel); fs::create_dir_all(p.parent().unwrap()).unwrap(); fs::write(p, content).unwrap(); } fn statuses(s: &SnapshotResult) -> Vec<(String, ChangeStatus)> { s.statuses.iter().map(|p| (p.path.clone(), p.status)).collect() } #[test] fn init_snapshot_commit_log_undo() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); assert!(Repo::init(root).is_err()); let ops = repo.op_log().unwrap(); assert_eq!(ops.len(), 1); assert_eq!(ops[0].1.command, "oxy init"); // Empty working copy: snapshot records no operation (spec ยง12.3). let s = repo.snapshot("oxy status").unwrap(); assert!(!s.changed); assert_eq!(repo.op_log().unwrap().len(), 1); write(root, "src/main.rs", b"fn main() {}\n"); write(root, "README.md", b"# Hello\n"); write(root, ".oxyignore", b"/target\n"); write(root, "target/junk.o", b"\0\0\0"); let s = repo.snapshot("oxy status").unwrap(); assert!(s.changed); assert_eq!( statuses(&s), vec![ (".oxyignore".into(), ChangeStatus::Added), ("README.md".into(), ChangeStatus::Added), ("src/main.rs".into(), ChangeStatus::Added), ] ); assert_eq!(repo.op_log().unwrap().len(), 2); // Snapshotting again with nothing changed records no op. let s2 = repo.snapshot("oxy status").unwrap(); assert!(!s2.changed); assert_eq!(s2.wc_hash, s.wc_hash); assert_eq!(repo.op_log().unwrap().len(), 2); let h1 = repo.commit("Initial import", &[]).unwrap(); let c1 = repo.store().get_change(&h1).unwrap(); assert_eq!(c1.kind, ChangeKind::Normal); assert!(c1.deps.is_empty(), "first change has nothing to depend on"); assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]); let (wc_hash, wc) = repo.working_copy().unwrap(); assert_eq!(wc.deps, vec![h1]); assert!(wc.edits.is_empty()); assert!(repo.commit("nothing", &[]).is_err()); // Modify one file, remove another, add a binary file. write(root, "src/main.rs", b"fn main() {\n println!(\"hi\");\n}\n"); fs::remove_file(root.join("README.md")).unwrap(); write(root, "logo.png", b"\x89PNG\0\0\x01"); let s = repo.snapshot("oxy status").unwrap(); assert_eq!( statuses(&s), vec![ ("README.md".into(), ChangeStatus::Removed), ("logo.png".into(), ChangeStatus::Added), ("src/main.rs".into(), ChangeStatus::Modified), ] ); assert_ne!(s.wc_hash, wc_hash); // The rewritten working copy keeps its ChangeId and depends on h1. assert_eq!(s.wc.change_id, wc.change_id); assert!(s.wc.deps.contains(&h1)); let h2 = repo.commit("Second", &[]).unwrap(); let c2 = repo.store().get_change(&h2).unwrap(); assert_eq!(c2.deps, vec![h1], "everything it touches was introduced by h1"); assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]); // The materialized head state matches the working copy. let st = repo.load_state(&[h2]).unwrap(); let snap = st.materialize(); assert!(!snap.is_conflicted()); assert_eq!(snap.files.keys().cloned().collect::>(), [".oxyignore", "logo.png", "src", "src/main.rs"]); assert!(matches!(&snap.files["src/main.rs"].content, FileContent::Text { bytes, .. } if bytes == b"fn main() {\n println!(\"hi\");\n}\n")); assert!(snap.files["logo.png"].mode.opaque); if let FileContent::Opaque { blobs } = &snap.files["logo.png"].content { assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\x89PNG\0\0\x01"); } else { panic!("logo should be opaque"); } // Log: newest first, working copy included. let log = repo.log().unwrap(); assert_eq!(log.len(), 3); assert!(log[0].is_working_copy); assert_eq!(log[1].hash, h2); assert!(log[1].is_head); assert_eq!(log[2].hash, h1); let d = repo.describe_change(&h2).unwrap(); assert!(d.contains("D README.md"), "{d}"); assert!(d.contains("+ println!(\"hi\");"), "{d}"); assert!(d.contains("-fn main() {}"), "{d}"); // Resolution by prefix. assert_eq!(repo.resolve_change(&c2.change_id.short(6)).unwrap(), h2); assert_eq!(repo.resolve_change(&format!("#{}", h1.short(8))).unwrap(), h1); assert!(repo.resolve_change("zzzz").is_err()); // Undo the commit: bookmark and working copy go back. repo.undo().unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]); let s = repo.snapshot("oxy status").unwrap(); assert!(!s.changed, "files on disk still match the pre-commit working copy"); assert_eq!(s.statuses.len(), 3); } /// Make `main` conflicted: two commits on the same base that edit the same line. fn conflicted_repo() -> (tempfile::TempDir, Repo, ChangeHash, ChangeHash, ChangeHash) { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "f.txt", b"a\nb\nc\n"); let h1 = repo.commit("base", &[]).unwrap(); write(root, "f.txt", b"a\nB\nc\n"); let h2 = repo.commit("upper", &[]).unwrap(); repo.checkout(&[h1], false, "checkout").unwrap(); write(root, "f.txt", b"a\nX\nc\n"); let h3 = repo.commit("ex", &[]).unwrap(); (dir, repo, h1, h2, h3) } #[test] fn concurrent_edits_conflict_and_can_be_resolved() { hermetic(); let (dir, repo, h1, h2, h3) = conflicted_repo(); let root = dir.path(); let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect(); assert_eq!(heads, [h2, h3].into_iter().collect()); let st = repo.load_state(&[h2, h3]).unwrap(); assert!(st.materialize().is_conflicted()); // Checking out main writes markers. let r = repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap(); assert!(r.conflicted); let marked = fs::read_to_string(root.join("f.txt")).unwrap(); assert!(marked.starts_with("a\n<<<<<<< oxy conflict 1 of 1"), "{marked}"); assert!(marked.contains("B\n") && marked.contains("X\n") && marked.ends_with("end\nc\n"), "{marked}"); assert!(has_conflict_markers(marked.as_bytes())); // Untouched markers: clean working copy; commit has nothing to do. let s = repo.snapshot("status").unwrap(); assert!(s.statuses.is_empty()); assert!(!s.resolves); // Partially edited file that still has markers cannot be committed. write(root, "f.txt", marked.replace("B\n", "BB\n").as_bytes()); let s = repo.snapshot("status").unwrap(); assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::ConflictEdited)]); assert!(repo.commit("no", &[]).is_err()); // Resolve by keeping one side and adding a line elsewhere. write(root, "f.txt", b"a\nX\nc\nd\n"); let s = repo.snapshot("status").unwrap(); assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::Resolved)]); assert!(s.resolves); let h4 = repo.commit("resolve", &[]).unwrap(); let c4 = repo.store().get_change(&h4).unwrap(); assert_eq!(c4.kind, ChangeKind::Resolution); let mut want = vec![h1, h2, h3]; want.sort(); assert_eq!(c4.deps, want, "a resolution depends on exactly the contributors and what it references"); assert_eq!(repo.bookmark_heads().unwrap(), vec![h4]); let snap = repo.load_state(&[h4]).unwrap().materialize(); assert!(!snap.is_conflicted()); assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n")); assert!(!repo.snapshot("status").unwrap().changed); // A peer holding only h2 and h3 and receiving h4 sees the same result, // whatever order the changes arrive in. let mut st = State::new(); for h in [h4, h3, h1, h2] { st.add_unchecked(repo.store().get_change(&h).unwrap()); } st.validate().unwrap(); assert!(matches!(&st.materialize().files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n")); } #[test] fn resolution_may_keep_both_sides_or_neither() { hermetic(); let (dir, repo, _h1, _h2, _h3) = conflicted_repo(); let root = dir.path(); repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap(); // Keep both sides in a chosen order. write(root, "f.txt", b"a\nX\nB\nc\n"); let h = repo.commit("both", &[]).unwrap(); let snap = repo.load_state(&[h]).unwrap().materialize(); assert!(!snap.is_conflicted()); assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nB\nc\n")); // Undo and instead drop the whole region. repo.undo().unwrap(); write(root, "f.txt", b"a\nc\n"); let h = repo.commit("neither", &[]).unwrap(); let snap = repo.load_state(&[h]).unwrap().materialize(); assert!(!snap.is_conflicted()); assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nc\n")); } #[test] fn name_conflict_resolved_by_removing_a_candidate() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "keep.txt", b"k\n"); let h1 = repo.commit("base", &[]).unwrap(); write(root, "new.txt", b"one\n"); repo.commit("one", &[]).unwrap(); repo.checkout(&[h1], false, "checkout").unwrap(); write(root, "new.txt", b"two\n"); repo.commit("two", &[]).unwrap(); let r = repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap(); assert!(r.conflicted); assert!(root.join("new.txt.oxy-conflict-1").exists() && root.join("new.txt.oxy-conflict-2").exists()); assert!(!root.join("new.txt").exists()); // Candidate numbering follows change-hash order, so pick by content: // keep the "two" candidate under the real name and drop the other. let (keep, drop) = if fs::read(root.join("new.txt.oxy-conflict-1")).unwrap() == b"two\n" { (1, 2) } else { (2, 1) }; fs::remove_file(root.join(format!("new.txt.oxy-conflict-{drop}"))).unwrap(); fs::rename(root.join(format!("new.txt.oxy-conflict-{keep}")), root.join("new.txt")).unwrap(); let h = repo.commit("pick two", &[]).unwrap(); assert_eq!(repo.store().get_change(&h).unwrap().kind, ChangeKind::Resolution); // The resolution does not touch keep.txt, so it does not depend on h1; // the bookmark keeps both as heads and their union is the state. let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect(); assert_eq!(heads, [h1, h].into_iter().collect()); let snap = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap().materialize(); assert!(!snap.is_conflicted(), "{:?}", snap.tree.conflicts); assert_eq!(snap.files.keys().cloned().collect::>(), ["keep.txt", "new.txt"]); assert!(matches!(&snap.files["new.txt"].content, FileContent::Text { bytes, .. } if bytes == b"two\n")); } #[test] fn identities_and_signed_commits() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"a\n"); let h0 = repo.commit("unsigned", &[]).unwrap(); assert_eq!(repo.verify_change(&h0).unwrap(), SignatureStatus::Unsigned); assert_eq!(repo.store().get_change(&h0).unwrap().author, ObjectRef::ZERO); let (key, id) = repo.create_identity("Ada", "ada@example.org", IdentityScope::Repo).unwrap(); assert_eq!(id.hash(), key); assert_eq!(repo.config.author_key, Some(key)); // Config survives reopen; the secret key is found again. let reopened = Repo::open(root).unwrap().with_home(root.join("home")); assert_eq!(reopened.config.author_key, Some(key)); assert_eq!(reopened.secret_key().unwrap().unwrap().0, key); // Default core.sign = seal: commits carry the author but no signature. write(root, "a.txt", b"a\nb\n"); let h1 = repo.commit("authored", &[]).unwrap(); assert_eq!(repo.store().get_change(&h1).unwrap().author, key); assert_eq!(repo.verify_change(&h1).unwrap(), SignatureStatus::Unsigned); // Signing later works too. repo.sign_change(&h1).unwrap(); assert!(matches!(repo.verify_change(&h1).unwrap(), SignatureStatus::Valid { key: k, .. } if k == key)); repo.config.set("core.sign", "change").unwrap(); repo.save_config().unwrap(); write(root, "a.txt", b"a\nb\nc\n"); let h2 = repo.commit("signed", &[]).unwrap(); assert_eq!(repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, name: "Ada".into(), email: "ada@example.org".into(), sealed: false }); // A signature by a key that is not the author or a delegate is rejected. let other = SecretKey::generate(); let other_id = other.identity("Mallory", "m@example.org", 0); let other_key = repo.store().put_identity(&other_id).unwrap(); let forged = other.sign(Domain::Change, &other_key, &h0); repo.store().put_signature(&h0, &forged).unwrap(); assert!(matches!(repo.verify_change(&h0).unwrap(), SignatureStatus::Invalid(m) if m.contains("neither the author"))); // 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 }); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n"); assert_eq!(fs::read(root.join("sub/deep/b.txt")).unwrap(), b"b\n"); assert!(!root.join("bin.dat").exists()); let (_, wc) = repo.working_copy().unwrap(); assert_eq!(wc.deps, vec![h1]); assert!(!repo.snapshot("status").unwrap().changed, "checkout leaves a clean working copy"); // The bookmark still points at h2. assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]); // Dirty working copy refuses without --discard. write(root, "a.txt", b"dirty\n"); assert!(repo.checkout(&[h2], false, "oxy checkout").is_err()); let r = repo.checkout(&[h2], true, "oxy checkout --discard").unwrap(); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"two\n"); assert_eq!(fs::read(root.join("bin.dat")).unwrap(), [0, 1, 2, 3]); assert!(!root.join("sub").exists(), "empty directories are pruned"); assert_eq!(r.removed, 1); assert!(!repo.snapshot("status").unwrap().changed); // Refuses to write through a symlinked directory. #[cfg(unix)] { fs::remove_dir_all(root.join("sub")).ok(); std::os::unix::fs::symlink("/tmp", root.join("sub")).unwrap(); assert!(repo.checkout(&[h1], true, "oxy checkout").is_err()); } } #[test] fn large_files_are_chunked_and_read_back() { hermetic(); let dir = tempfile::tempdir().unwrap(); let repo = Repo::init(dir.path()).unwrap(); let mut big = vec![0u8; 700 * 1024]; for (i, b) in big.iter_mut().enumerate() { *b = (i as u32).wrapping_mul(2654435761).to_le_bytes()[0]; } let r = repo.store_blob(&big).unwrap(); let blob = Blob::decode(&repo.store().get_typed(&r, ObjectType::Blob).unwrap()).unwrap(); assert!(blob.chunks.len() >= 3, "{}", blob.chunks.len()); assert_eq!(repo.read_blob(&r).unwrap(), big); assert_eq!(Repo::blob_ref(&big), r); } #[test] fn clone_push_pull_with_seals_and_attestations() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_path = root.join("origin"); let origin = Repo::init(&origin_path).unwrap(); let origin_url = origin_path.to_string_lossy().to_string(); // A clones, creates an identity (default core.sign = seal), commits, pushes. let mut a = Repo::clone(&origin_url, &root.join("a")).unwrap().with_home(root.join("home-a")); assert_eq!(a.id().unwrap(), origin.id().unwrap()); let (key_a, _) = a.create_identity("Ada", "ada@example.org", IdentityScope::Repo).unwrap(); write(&root.join("a"), "f.txt", b"a\n"); let h1 = a.commit("first", &[]).unwrap(); let results = a.push("origin", false).unwrap(); assert!(results[0].ok, "{}", results[0].reason); assert_eq!(origin.bookmark_heads().unwrap(), vec![h1]); // The change was sealed at push and the reference attested. assert!(matches!(origin.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { key, sealed: true, .. } if key == key_a)); assert_eq!(origin.verify_change(&h1).unwrap(), SignatureStatus::Unsigned); let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap(); assert!(matches!(att.attest.body, AttestBody::Ref { serial: 1, .. })); let again = a.push("origin", false).unwrap(); assert!(again[0].ok && again[0].reason.contains("up to date")); let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap(); assert!(matches!(att.attest.body, AttestBody::Ref { serial: 1, .. }), "a no-op push does not attest again"); // B clones and sees the file and the seal. let b = Repo::clone(&origin_url, &root.join("b")).unwrap(); assert_eq!(fs::read(root.join("b/f.txt")).unwrap(), b"a\n"); assert_eq!(b.bookmark_heads().unwrap(), vec![h1]); assert!(matches!(b.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { sealed: true, .. })); // B (no identity) commits and pushes unsigned; the unverified origin accepts. write(&root.join("b"), "f.txt", b"a\nb\n"); let h2 = b.commit("second", &[]).unwrap(); assert!(b.push("origin", false).unwrap()[0].ok); assert_eq!(origin.bookmark_heads().unwrap(), vec![h2]); // A commits an independent change; push is refused until A pulls. write(&root.join("a"), "g.txt", b"g\n"); let h3 = a.commit("third", &[]).unwrap(); assert!(a.push("origin", false).is_err()); let pr = a.pull("origin").unwrap(); assert!(pr.changed && pr.checked_out); let mut want = vec![h2, h3]; want.sort(); assert_eq!(pr.heads, want); assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"a\nb\n"); assert_eq!(fs::read(root.join("a/g.txt")).unwrap(), b"g\n"); let results = a.push("origin", false).unwrap(); assert!(results[0].ok, "{}", results[0].reason); let mut origin_heads = origin.bookmark_heads().unwrap(); origin_heads.sort(); assert_eq!(origin_heads, want); let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap(); assert!(matches!(att.attest.body, AttestBody::Ref { serial: 2, .. })); // B pulls and ends up identical. let pr = b.pull("origin").unwrap(); assert_eq!(pr.heads, want); assert_eq!(fs::read(root.join("b/g.txt")).unwrap(), b"g\n"); assert!(!b.snapshot("status").unwrap().changed); // Concurrent edits of the same line: the union is a reified conflict. write(&root.join("a"), "f.txt", b"a\nA\n"); a.commit("a-edit", &[]).unwrap(); a.push("origin", false).unwrap(); write(&root.join("b"), "f.txt", b"a\nB\n"); b.commit("b-edit", &[]).unwrap(); let pr = b.pull("origin").unwrap(); assert!(pr.changed && pr.checked_out); let text = fs::read_to_string(root.join("b/f.txt")).unwrap(); assert!(text.contains("<<<<<<< oxy conflict"), "{text}"); write(&root.join("b"), "f.txt", b"a\nAB\n"); let hr = b.commit("resolve", &[]).unwrap(); assert_eq!(b.store().get_change(&hr).unwrap().kind, ChangeKind::Resolution); assert!(b.push("origin", false).unwrap()[0].ok); a.pull("origin").unwrap(); assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"a\nAB\n"); // The resolution depends on the conflicting edits, but nothing depends // on "third" (it only added g.txt), so it stays a head beside it. let mut want = vec![hr, h3]; want.sort(); assert_eq!(a.bookmark_heads().unwrap(), want); assert!(!a.load_state(&want).unwrap().materialize().is_conflicted()); } #[test] fn receive_push_rejects_bad_updates() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = Repo::init(&root.join("origin")).unwrap(); let url = root.join("origin").to_string_lossy().to_string(); let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home")); let (key, _) = a.create_identity("Ada", "a@b", IdentityScope::Repo).unwrap(); write(&root.join("a"), "f.txt", b"x\n"); let h1 = a.commit("one", &[]).unwrap(); assert!(a.push("origin", false).unwrap()[0].ok); let empty = origin.store().put_state(&StateObject::new(vec![])).unwrap(); let s1 = origin.store().put_state(&StateObject::new(vec![h1])).unwrap(); // Missing head. let ghost = origin.store().put_state(&StateObject::new(vec![ObjectRef([9; 32])])).unwrap(); let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: ghost, force: false, attestation: None }], None).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).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).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).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).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).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)).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)).unwrap(); assert!(!r[0].ok && r[0].reason.contains("serial"), "{:?}", r); // A revokes B; after B pulls the policy, B's pushes are refused again. a.pull("origin").unwrap(); a.policy_revoke(&key_b).unwrap(); let r = a.push("origin", false).unwrap(); assert!(r.iter().any(|x| x.name == "policy" && x.ok), "{r:?}"); b.pull("origin").unwrap(); write(&root.join("b"), "b.txt", b"b3\n"); b.commit("b3", &[]).unwrap(); let r = b.push("origin", false).unwrap(); assert!(!r[0].ok, "{:?}", r); // A clone by a third party receives and verifies everything. let c = Repo::clone(&url, &root.join("c")).unwrap().with_home(root.join("home-c")); assert_eq!(c.current_policy().unwrap().unwrap().1.policy.serial, 3); assert!(c.current_policy().unwrap().unwrap().1.policy.roots.contains(&key_a)); assert!(!c.snapshot("status").unwrap().changed); } #[test] fn gc_packs_objects_and_history_still_works() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); repo.create_identity("Ada", "a@b", IdentityScope::User).unwrap(); repo.set_user_config("core.sign", "change").unwrap(); write(root, "a.txt", b"one\n"); let h1 = repo.commit("one", &[]).unwrap(); write(root, "a.txt", b"two\n"); write(root, "big.bin", &vec![0u8; 100_000]); let h2 = repo.commit("two", &[]).unwrap(); // Several snapshots leave superseded working-copy versions behind. for i in 0..5 { write(root, "a.txt", format!("two\n{i}\n").as_bytes()); repo.snapshot("status").unwrap(); } write(root, "a.txt", b"two\n"); repo.snapshot("status").unwrap(); let before = repo.store().list().unwrap(); assert!(repo.store().loose_count().unwrap() > 0); // With the default retention every operation's view is a root: nothing prunes. let r = repo.gc(true).unwrap(); assert_eq!(r.packed.objects, before.len()); assert_eq!(r.pruned.objects, 0); assert_eq!(repo.store().loose_count().unwrap(), 0); assert_eq!(repo.store().list().unwrap(), before); // With zero retention only the current view is a root: superseded // working copies go, everything reachable stays. repo.config.set("core.op-retention", "0").unwrap(); repo.save_config().unwrap(); let repo = Repo::open(root).unwrap().with_home(root.join("home")); let r = repo.gc(true).unwrap(); assert!(r.pruned.objects >= 5, "{r:?}"); let after = repo.store().list().unwrap(); assert!(after.len() < before.len()); assert!(after.contains(&h1) && after.contains(&h2)); for (_, op) in repo.op_log().unwrap() { assert!(!op.command.is_empty(), "operation objects are all kept"); } // Everything still works from the pruned, packed store. assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]); assert!(matches!(repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { .. })); assert_eq!(repo.log().unwrap().len(), 3); repo.checkout(&[h1], false, "checkout").unwrap(); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n"); repo.checkout(&[h2], false, "checkout").unwrap(); assert_eq!(fs::read(root.join("big.bin")).unwrap().len(), 100_000); assert!(!repo.snapshot("status").unwrap().changed); // Grace period defers pruning of young garbage. write(root, "a.txt", b"three\n"); repo.commit("three", &[]).unwrap(); let r = repo.gc(false).unwrap(); assert_eq!(r.pruned.objects, 0); assert!(r.pruned.deferred > 0); let r = repo.gc(true).unwrap(); assert!(r.pruned.objects > 0); assert_eq!(repo.log().unwrap().len(), 4); } #[test] fn describe_amend_abandon_heads() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); repo.create_identity("Ada", "a@b", IdentityScope::User).unwrap(); repo.set_user_config("core.sign", "change").unwrap(); write(root, "a.txt", b"one\n"); let h1 = repo.commit("first", &[]).unwrap(); write(root, "a.txt", b"one\ntwo\n"); let h2 = repo.commit("secnod", &[]).unwrap(); let id2 = repo.store().get_change(&h2).unwrap().change_id; // describe: same ChangeId, new hash, bookmark and working copy follow. let h2b = repo.describe(&h2, "second").unwrap(); assert_ne!(h2b, h2); let c = repo.store().get_change(&h2b).unwrap(); assert_eq!((c.change_id, c.message.as_str(), c.deps.clone()), (id2, "second", vec![h1])); assert_eq!(repo.bookmark_heads().unwrap(), vec![h2b]); assert!(matches!(repo.verify_change(&h2b).unwrap(), SignatureStatus::Valid { .. })); let s = repo.snapshot("status").unwrap(); assert!(s.statuses.is_empty(), "{:?}", s.statuses); assert_eq!(repo.resolve_change(&id2.short(8)).unwrap(), h2b, "the old object is no longer reachable from the log"); // Describing a non-head rewrites its dependents (h2b โ†’ h2c') and the working copy. let h1b = repo.describe(&h1, "first, described").unwrap(); assert_ne!(h1b, h1); let heads = repo.bookmark_heads().unwrap(); assert_eq!(heads.len(), 1); let top = repo.store().get_change(&heads[0]).unwrap(); assert_eq!((top.change_id, top.deps.clone()), (id2, vec![h1b])); assert!(matches!(repo.verify_change(&heads[0]).unwrap(), SignatureStatus::Valid { .. }), "rewritten own change is re-signed"); let (_, wc) = repo.working_copy().unwrap(); assert_eq!(wc.deps, vec![heads[0]], "the working copy follows the rewrite"); assert!(!repo.snapshot("status").unwrap().changed); let snap = repo.load_state(&heads).unwrap().materialize(); assert!(matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n")); // amend: fold an edit into the head; the result is one change from h1. write(root, "a.txt", b"one\ntwo\nthree\n"); write(root, "b.txt", b"b\n"); let h2c = repo.amend(None).unwrap(); let c = repo.store().get_change(&h2c).unwrap(); assert_eq!(c.change_id, id2); assert_eq!(c.message, "second"); assert_eq!(repo.bookmark_heads().unwrap(), vec![h2c]); assert_eq!(repo.log().unwrap().len(), 3, "working copy, amended head, first"); let snap = repo.load_state(&[h2c]).unwrap().materialize(); assert!(matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\nthree\n")); assert!(snap.files.contains_key("b.txt")); assert!(!repo.snapshot("status").unwrap().changed); assert!(repo.amend(None).is_err(), "clean working copy and no message"); let h2d = repo.amend(Some("second, amended")).unwrap(); assert_eq!(repo.store().get_change(&h2d).unwrap().message, "second, amended"); // abandon: the head goes, its content stays as uncommitted work. repo.abandon(&h2d).unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![h1b], "the described first change is the head again"); let s = repo.snapshot("status").unwrap(); let paths: Vec<_> = s.statuses.iter().map(|p| (p.path.as_str(), p.status)).collect(); assert_eq!(paths, vec![("a.txt", ChangeStatus::Modified), ("b.txt", ChangeStatus::Added)]); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\ntwo\nthree\n"); let h3 = repo.commit("recommitted", &[]).unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![h3]); // undo brings the abandoned head back. repo.undo().unwrap(); repo.undo().unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![h2d]); // Abandoning a non-head whose dependent references its atoms is refused; // one that is only an explicit dependency can go. let base = repo.bookmark_heads().unwrap()[0]; let base_change = repo.store().get_change(&base).unwrap(); let h1_now = base_change.deps[0]; assert!(repo.abandon(&h1_now).unwrap_err().to_string().contains("not implemented")); write(root, "c.txt", b"c\n"); let hc = repo.commit("c", &[]).unwrap(); write(root, "d.txt", b"d\n"); let hd = repo.commit("d", &[hc]).unwrap(); assert_eq!(repo.store().get_change(&hd).unwrap().deps, vec![hc]); repo.abandon(&hc).unwrap(); let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect(); assert!(!heads.contains(&hc)); let d_now = heads.iter().find(|h| repo.store().get_change(h).unwrap().message == "d").copied().unwrap(); assert!(repo.store().get_change(&d_now).unwrap().deps.is_empty(), "d inherited c's (empty) dependencies"); let s = repo.snapshot("status").unwrap(); assert_eq!(s.statuses.iter().map(|p| p.path.as_str()).collect::>(), vec!["c.txt"], "c's content is now uncommitted work"); } #[test] fn git_export_mirrors_history() { hermetic(); if std::process::Command::new("git").arg("--version").output().is_err() { eprintln!("git not installed; skipping"); return; } let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(&root.join("r")).unwrap().with_home(root.join("home")); repo.create_identity("Ada Lovelace", "ada@example.org", IdentityScope::User).unwrap(); let r = root.join("r"); write(&r, "README.md", b"hello\n"); write(&r, "src/main.rs", b"fn main() {}\n"); let h1 = repo.commit("first", &[]).unwrap(); write(&r, "src/main.rs", b"fn main() { run(); }\n"); write(&r, "logo.png", b"\x89PNG\0\0"); let h2 = repo.commit("second", &[]).unwrap(); let git_dir = root.join("mirror.git"); let rep = repo.git_export(&git_dir).unwrap(); assert_eq!((rep.exported, rep.skipped, rep.heads), (2, 0, 1)); let git = |args: &[&str]| -> String { let out = std::process::Command::new("git").arg("-C").arg(&git_dir).args(args).output().unwrap(); assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); String::from_utf8_lossy(&out.stdout).trim().to_string() }; assert_eq!(git(&["log", "--format=%s", "main"]), "second\nfirst"); assert_eq!(git(&["show", "main:src/main.rs"]), "fn main() { run(); }"); assert_eq!(git(&["show", "main:README.md"]), "hello"); let body = git(&["log", "-1", "--format=%B", "main"]); assert!(body.contains(&format!("Oxy-Hash: {}", h2.to_hex())), "{body}"); assert!(body.contains("Change-Id: "), "{body}"); assert_eq!(git(&["log", "-1", "--format=%an <%ae>", "main"]), "Ada Lovelace "); assert_eq!(git(&["ls-tree", "--name-only", "main"]), "README.md\nlogo.png\nsrc"); // Re-export is incremental. let rep = repo.git_export(&git_dir).unwrap(); assert_eq!((rep.exported, rep.skipped), (0, 2)); // An independent change makes two heads; the next state is one more // commit on the linear branch whose tree is the union. repo.checkout(&[h1], false, "checkout").unwrap(); write(&r, "other.txt", b"o\n"); let h3 = repo.commit("third", &[]).unwrap(); let rep = repo.git_export(&git_dir).unwrap(); assert_eq!((rep.exported, rep.heads), (1, 2)); assert_eq!(git(&["log", "--format=%s", "main"]), "third\nsecond\nfirst"); let parents = git(&["rev-list", "--parents", "-1", "main"]); assert_eq!(parents.split(' ').count(), 2, "one parent: {parents}"); assert_eq!(git(&["show", "main:other.txt"]), "o"); assert_eq!(git(&["show", "main:src/main.rs"]), "fn main() { run(); }"); assert_eq!(git(&["ls-tree", "--name-only", "main"]), "README.md\nlogo.png\nother.txt\nsrc"); let body = git(&["log", "-1", "--format=%B", "main"]); assert!(body.contains(&format!("Oxy-Hash: {}", h3.to_hex())) && body.contains("Oxy-State: "), "{body}"); // A rewrite is a new state whose one new change is the rewritten one. repo.describe(&h3, "third, described").unwrap(); let rep = repo.git_export(&git_dir).unwrap(); assert_eq!(rep.exported, 1); assert_eq!(git(&["log", "-1", "--format=%s", "main"]), "third, described"); assert_eq!(git(&["log", "--format=%s", "main"]).lines().count(), 4); let _ = h2; } #[test] fn git_import_reproduces_trees() { hermetic(); if std::process::Command::new("git").arg("--version").output().is_err() { eprintln!("git not installed; skipping"); return; } let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let g = root.join("src.git"); fs::create_dir_all(&g).unwrap(); let git = |args: &[&str]| -> String { let out = std::process::Command::new("git") .arg("-C") .arg(&g) .args(["-c", "user.name=Grace", "-c", "user.email=grace@example.org", "-c", "commit.gpgsign=false"]) .args(args) .output() .unwrap(); assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr)); String::from_utf8_lossy(&out.stdout).trim().to_string() }; git(&["init", "-q", "-b", "main"]); write(&g, "README.md", b"# Hi\n"); write(&g, "src/lib.rs", b"pub fn a() {}\n"); git(&["add", "."]); git(&["commit", "-q", "-m", "initial"]); write(&g, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\n"); write(&g, "run.sh", b"#!/bin/sh\n"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(g.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap(); } write(&g, "logo.png", b"\x89PNG\0\0\x01"); git(&["add", "."]); git(&["commit", "-q", "-m", "second\n\nChange-Id: 0123456789abcdef0123456789abcdef"]); // A branch that edits README, merged back with a conflict-free merge. git(&["checkout", "-q", "-b", "topic", "HEAD~1"]); write(&g, "README.md", b"# Hi\n\nMore.\n"); git(&["commit", "-q", "-am", "readme"]); git(&["checkout", "-q", "main"]); git(&["merge", "-q", "--no-ff", "-m", "merge topic", "topic"]); // A branch from the initial commit inserting at the same place as // "second": concurrent in Oxygen too, so the merge commit is a resolution. let initial = git(&["rev-list", "--max-parents=0", "HEAD"]); git(&["checkout", "-q", "-b", "clash", &initial]); write(&g, "src/lib.rs", b"pub fn a() {}\npub fn c() {}\n"); git(&["commit", "-q", "-am", "clash"]); git(&["checkout", "-q", "main"]); let _ = std::process::Command::new("git").arg("-C").arg(&g).args(["merge", "-q", "clash"]).output(); write(&g, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\npub fn c() {}\n"); git(&["add", "."]); git(&["commit", "-q", "-m", "resolve clash"]); git(&["rm", "-q", "logo.png"]); git(&["commit", "-q", "-m", "drop logo"]); let expected_count: usize = git(&["rev-list", "--count", "main"]).parse().unwrap(); let dest = root.join("oxy"); let repo = Repo::init(&dest).unwrap().with_home(root.join("home")); let rep = repo.import_git(&g, "main").unwrap(); assert_eq!(rep.commits, expected_count); assert_eq!(rep.merges, 2); // The working copy equals the Git tree at main. assert_eq!(fs::read(dest.join("src/lib.rs")).unwrap(), b"pub fn a() {}\npub fn b() {}\npub fn c() {}\n"); 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); // Messages carry Git trailers; the Change-Id trailer was honoured. let log = repo.log().unwrap(); let second = log.iter().find(|e| e.change.message.starts_with("second")).unwrap(); assert_eq!(second.change.change_id, ChangeId::from_hex("0123456789abcdef0123456789abcdef").unwrap()); assert!(second.change.message.contains("Git-Author: Grace ")); let resolve = log.iter().find(|e| e.change.message.starts_with("resolve clash")).unwrap(); assert_eq!(resolve.change.kind, ChangeKind::Resolution, "a conflicting Git merge becomes a resolution"); // Re-importing into another fresh repository gives the same ChangeIds. let repo2 = Repo::init(&root.join("oxy2")).unwrap().with_home(root.join("home")); repo2.import_git(&g, "main").unwrap(); let ids: BTreeSet = repo.log().unwrap().iter().filter(|e| !e.is_working_copy).map(|e| e.change.change_id).collect(); let ids2: BTreeSet = repo2.log().unwrap().iter().filter(|e| !e.is_working_copy).map(|e| e.change.change_id).collect(); assert_eq!(ids, ids2); // Round trip back out to Git reproduces the tip tree. let mirror = root.join("mirror.git"); repo.git_export(&mirror).unwrap(); let out = std::process::Command::new("git").arg("-C").arg(&mirror).args(["show", "main:src/lib.rs"]).output().unwrap(); assert_eq!(String::from_utf8_lossy(&out.stdout), "pub fn a() {}\npub fn b() {}\npub fn c() {}\n"); } #[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; }