//! 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, revoked: 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, vec![]).unwrap(); assert!(!r[0].ok && r[0].reason.contains("missing"), "{}", r[0].reason); // Stale old_state. let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: None }], None, vec![]).unwrap(); assert!(!r[0].ok && r[0].reason.contains("stale"), "{}", r[0].reason); // Non-fast-forward without force is refused; with force it passes. let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: false, attestation: None }], None, vec![]).unwrap(); assert!(!r[0].ok && r[0].reason.contains("fast-forward"), "{}", r[0].reason); let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: true, attestation: None }], None, vec![]).unwrap(); assert!(r[0].ok); assert_eq!(origin.bookmark_heads().unwrap(), vec![]); // Attestation with a stale serial is a rollback and is refused. let sa = a.sign_attest(&Attest { repo: a.id().unwrap(), hlc: Hlc::now(), body: AttestBody::Ref { name: "main".into(), old_state: empty, new_state: s1, serial: 1 } }).unwrap(); let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa.clone()) }], None, vec![]).unwrap(); assert!(!r[0].ok && r[0].reason.contains("serial"), "{}", r[0].reason); // An attestation for a different update is refused too. let sa2 = a.sign_attest(&Attest { repo: a.id().unwrap(), hlc: Hlc::now(), body: AttestBody::Ref { name: "main".into(), old_state: s1, new_state: empty, serial: 5 } }).unwrap(); let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa2) }], None, vec![]).unwrap(); assert!(!r[0].ok && r[0].reason.contains("describe"), "{}", r[0].reason); let _ = key; } #[test] fn user_config_supplies_defaults_and_clones_sign() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let home = root.join("home"); let origin = Repo::init(&root.join("origin")).unwrap(); let url = root.join("origin").to_string_lossy().to_string(); // Identity at user scope: the repository file stays free of author keys. let mut a = Repo::init(&root.join("a")).unwrap().with_home(&home); let (key, _) = a.create_identity("Ada", "ada@example.org", IdentityScope::User).unwrap(); assert!(!a.config.repo_layer().contains_key("author.key")); assert_eq!(a.config.author_key, Some(key)); assert!(home.join("config.toml").exists()); a.set_user_config("core.sign", "change").unwrap(); assert!(a.config.sign_changes); // Repository settings override user settings and are the only ones saved. a.config.set("core.sign", "seal").unwrap(); a.save_config().unwrap(); let again = Repo::open(&root.join("a")).unwrap().with_home(&home); assert!(!again.config.sign_changes); assert_eq!(again.config.author_key, Some(key)); assert_eq!(read_config_file(&root.join("a/.oxy/config")).unwrap().get("core.sign").map(String::as_str), Some("seal")); // A fresh clone made by this user signs with the user-level identity, // even though the clone's store has never seen the identity object. let b = Repo::clone(&url, &root.join("b")).unwrap().with_home(&home); assert!(b.config.sign_changes); write(&root.join("b"), "f.txt", b"f\n"); let h = b.commit("signed from a clone", &[]).unwrap(); assert!(matches!(b.verify_change(&h).unwrap(), SignatureStatus::Valid { key: k, .. } if k == key)); assert!(b.push("origin", false).unwrap()[0].ok); assert!(matches!(origin.verify_change(&h).unwrap(), SignatureStatus::Valid { .. })); } #[test] fn format1_repositories_are_migrated_on_open() { hermetic(); use crate::oplog::{Format1Op, Format1View}; let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"a\n"); let h1 = repo.commit("one", &[]).unwrap(); let (wc_hash, _) = repo.working_copy().unwrap(); // Rewrite the op log the way format 1 stored it: two ops, head lists. let mut v1 = Format1View::default(); v1.bookmarks.insert("main".into(), vec![]); v1.workspaces.insert(repo.workspace(), wc_hash); let init = Format1Op { parents: vec![], hlc: Hlc::default(), command: "oxy init".into(), view: v1.clone() }; let init_ref = repo.store().put(ObjectType::Op, &init.encode()).unwrap(); v1.bookmarks.insert("main".into(), vec![h1]); let commit = Format1Op { parents: vec![init_ref], hlc: Hlc::default(), command: "oxy commit".into(), view: v1 }; let commit_ref = repo.store().put(ObjectType::Op, &commit.encode()).unwrap(); write_atomic(&root.join(".oxy/ops/head"), commit_ref.to_hex().as_bytes()).unwrap(); write_atomic(&root.join(".oxy/format"), FORMAT1_LINE.as_bytes()).unwrap(); drop(repo); let repo = Repo::open(root).unwrap(); assert_eq!(fs::read_to_string(root.join(".oxy/format")).unwrap(), FORMAT_LINE); assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]); let ops = repo.op_log().unwrap(); assert_eq!(ops.iter().map(|(_, o)| o.command.as_str()).collect::>(), ["oxy commit", "oxy init"]); assert_eq!(repo.heads_of(&ops[1].1.view, "main").unwrap(), vec![]); assert!(!repo.snapshot("status").unwrap().changed); // Reopening again is a no-op. assert_eq!(Repo::open(root).unwrap().op_log().unwrap().len(), 2); } #[test] fn undo_skips_snapshot_operations() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"a\n"); let h1 = repo.commit("a", &[]).unwrap(); // A later command snapshots new edits on top of the commit. write(root, "b.txt", b"b\n"); let s = repo.snapshot("oxy status").unwrap(); assert!(s.changed); assert!(repo.head_op().unwrap().1.is_snapshot()); assert_eq!(repo.last_command().unwrap().command, "oxy commit -m \"a\""); // Undo must undo the commit, not the snapshot. repo.undo().unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![]); let s = repo.snapshot("oxy status").unwrap(); let paths: Vec<_> = s.statuses.iter().map(|p| p.path.as_str()).collect(); assert_eq!(paths, ["a.txt", "b.txt"], "both files are back in the working copy"); assert!(repo.store().get_change(&h1).unwrap().deps.is_empty()); // Only snapshots remain above `init`, so there is nothing left to undo. assert_eq!(repo.last_command().unwrap().command, "oxy init"); assert!(repo.undo().is_err()); } #[test] fn independent_changes_commute_and_make_multiple_heads() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"a\n"); let h1 = repo.commit("a", &[]).unwrap(); write(root, "b.txt", b"b\n"); let h2 = repo.commit("b", &[]).unwrap(); let c2 = repo.store().get_change(&h2).unwrap(); assert!(c2.deps.is_empty(), "b.txt touches nothing from h1"); let mut heads = repo.bookmark_heads().unwrap(); heads.sort(); let mut want = vec![h1, h2]; want.sort(); assert_eq!(heads, want); // Editing a.txt depends on h1 only. write(root, "a.txt", b"a\na2\n"); let h3 = repo.commit("a2", &[]).unwrap(); assert_eq!(repo.store().get_change(&h3).unwrap().deps, vec![h1]); let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect(); assert_eq!(heads, [h2, h3].into_iter().collect()); // Explicit dependency. write(root, "c.txt", b"c\n"); let h4 = repo.commit("c", &[h2]).unwrap(); assert_eq!(repo.store().get_change(&h4).unwrap().deps, vec![h2]); } #[test] fn mode_change_and_text_binary_switch() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "run.sh", b"#!/bin/sh\n"); repo.commit("add script", &[]).unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap(); let s = repo.snapshot("status").unwrap(); assert_eq!(statuses(&s), vec![("run.sh".into(), ChangeStatus::ModeChanged)]); let h = repo.commit("exec", &[]).unwrap(); let snap = repo.load_state(&[h]).unwrap().materialize(); assert!(snap.files["run.sh"].mode.exec); } // Text โ†’ binary โ†’ text. write(root, "run.sh", b"\0binary"); let h = repo.commit("binary", &[]).unwrap(); let snap = repo.load_state(&[h]).unwrap().materialize(); assert!(snap.files["run.sh"].mode.opaque); write(root, "run.sh", b"text again\n"); let h = repo.commit("text", &[]).unwrap(); let snap = repo.load_state(&[h]).unwrap().materialize(); assert!(!snap.files["run.sh"].mode.opaque); assert!(matches!(&snap.files["run.sh"].content, FileContent::Text { bytes, .. } if bytes == b"text again\n")); assert!(!snap.is_conflicted()); } #[test] fn trust_policy_governs_pushes_and_fetches() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = Repo::init(&root.join("origin")).unwrap(); let url = root.join("origin").to_string_lossy().to_string(); // A is the root. Before the policy, an unsigned push from B is fine. let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home-a")); let (key_a, _) = a.create_identity("Ada", "ada@example.org", IdentityScope::User).unwrap(); let mut b = Repo::clone(&url, &root.join("b")).unwrap().with_home(root.join("home-b")); write(&root.join("b"), "b.txt", b"b\n"); b.commit("unsigned before policy", &[]).unwrap(); assert!(b.push("origin", false).unwrap()[0].ok); // A creates the policy and pushes it; origin now enforces it. write(&root.join("a"), "a.txt", b"a\n"); a.commit("a", &[]).unwrap(); let sp = a.policy_init().unwrap(); assert_eq!(sp.policy.serial, 1); a.pull("origin").unwrap(); let results = a.push("origin", false).unwrap(); assert!(results.iter().all(|r| r.ok), "{results:?}"); assert_eq!(origin.current_policy().unwrap().unwrap().1.policy.serial, 1); // B pulls the policy. Without an identity B cannot push at all. b.pull("origin").unwrap(); assert!(b.current_policy().unwrap().is_some(), "the policy arrived with the fetch"); assert!(b.push("origin", false).is_err()); // With an identity the policy does not list, the push is rejected by origin. let (key_b, id_b) = b.create_identity("Bob", "bob@example.org", IdentityScope::User).unwrap(); write(&root.join("b"), "b.txt", b"b2\n"); let h_b2 = b.commit("b2 as Bob", &[]).unwrap(); assert_eq!(b.store().get_change(&h_b2).unwrap().author, key_b); let r = b.push("origin", false).unwrap(); assert!(!r[0].ok && r[0].reason.contains("may not update"), "{:?}", r); // A grants B (identity handed over as a file) and publishes the policy. let sp = a.policy_grant(id_b.clone(), None).unwrap(); assert_eq!(sp.policy.serial, 2); let r = a.push("origin", false).unwrap(); assert!(r.iter().any(|x| x.name == "policy" && x.ok), "{r:?}"); // B must fetch the new policy before pushing. assert!(b.push("origin", false).unwrap_or_else(|e| vec![RefResult { name: "main".into(), ok: false, reason: e.to_string() }]).iter().all(|x| !x.ok)); b.pull("origin").unwrap(); assert_eq!(b.current_policy().unwrap().unwrap().1.policy.serial, 2); let r = b.push("origin", false).unwrap(); assert!(r[0].ok, "{:?}", r); // Origin verified B's change under the policy. let heads = origin.bookmark_heads().unwrap(); for h in &heads { let c = origin.store().get_change(h).unwrap(); if c.author == key_b { origin.change_allowed(h, &origin.current_policy().unwrap().unwrap().1.policy, "main").unwrap(); } } // A policy signed by a non-root (B) is refused, as is a replayed serial. let mut forged = b.current_policy().unwrap().unwrap().1.policy.clone(); forged.serial = 3; forged.roots = vec![key_b]; let fh = forged.hash(); let (_, sk_b) = b.secret_key().unwrap().unwrap(); let forged = SignedPolicy { policy: forged, sigs: vec![sk_b.sign(Domain::Policy, &key_b, &fh)] }; let r = origin.receive_push(Bundle::default(), vec![], Some(forged), vec![]).unwrap(); assert!(!r[0].ok && r[0].reason.contains("root"), "{:?}", r); let stale = a.current_policy().unwrap().unwrap().1; let mut old = stale.clone(); old.policy.serial = 1; let r = origin.receive_push(Bundle::default(), vec![], Some(old), vec![]).unwrap(); assert!(!r[0].ok && r[0].reason.contains("serial"), "{:?}", r); // A revokes B; after B pulls the policy, B's pushes are refused again. a.pull("origin").unwrap(); a.policy_revoke(&key_b).unwrap(); let r = a.push("origin", false).unwrap(); assert!(r.iter().any(|x| x.name == "policy" && x.ok), "{r:?}"); b.pull("origin").unwrap(); write(&root.join("b"), "b.txt", b"b3\n"); b.commit("b3", &[]).unwrap(); let r = b.push("origin", false).unwrap(); assert!(!r[0].ok, "{:?}", r); // A clone by a third party receives and verifies everything. let c = Repo::clone(&url, &root.join("c")).unwrap().with_home(root.join("home-c")); assert_eq!(c.current_policy().unwrap().unwrap().1.policy.serial, 3); assert!(c.current_policy().unwrap().unwrap().1.policy.roots.contains(&key_a)); assert!(!c.snapshot("status").unwrap().changed); } #[test] fn gc_packs_objects_and_history_still_works() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); repo.create_identity("Ada", "a@b", IdentityScope::User).unwrap(); repo.set_user_config("core.sign", "change").unwrap(); write(root, "a.txt", b"one\n"); let h1 = repo.commit("one", &[]).unwrap(); write(root, "a.txt", b"two\n"); write(root, "big.bin", &vec![0u8; 100_000]); let h2 = repo.commit("two", &[]).unwrap(); // Several snapshots leave superseded working-copy versions behind. for i in 0..5 { write(root, "a.txt", format!("two\n{i}\n").as_bytes()); repo.snapshot("status").unwrap(); } write(root, "a.txt", b"two\n"); repo.snapshot("status").unwrap(); let before = repo.store().list().unwrap(); assert!(repo.store().loose_count().unwrap() > 0); // With the default retention every operation's view is a root: nothing prunes. let r = repo.gc(true).unwrap(); assert_eq!(r.packed.objects, before.len()); assert_eq!(r.pruned.objects, 0); assert_eq!(repo.store().loose_count().unwrap(), 0); assert_eq!(repo.store().list().unwrap(), before); // With zero retention only the current view is a root: superseded // working copies go, everything reachable stays. repo.config.set("core.op-retention", "0").unwrap(); repo.save_config().unwrap(); let repo = Repo::open(root).unwrap().with_home(root.join("home")); let r = repo.gc(true).unwrap(); assert!(r.pruned.objects >= 5, "{r:?}"); let after = repo.store().list().unwrap(); assert!(after.len() < before.len()); assert!(after.contains(&h1) && after.contains(&h2)); for (_, op) in repo.op_log().unwrap() { assert!(!op.command.is_empty(), "operation objects are all kept"); } // Everything still works from the pruned, packed store. assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]); assert!(matches!(repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { .. })); assert_eq!(repo.log().unwrap().len(), 3); repo.checkout(&[h1], false, "checkout").unwrap(); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n"); repo.checkout(&[h2], false, "checkout").unwrap(); assert_eq!(fs::read(root.join("big.bin")).unwrap().len(), 100_000); assert!(!repo.snapshot("status").unwrap().changed); // Grace period defers pruning of young garbage. write(root, "a.txt", b"three\n"); repo.commit("three", &[]).unwrap(); let r = repo.gc(false).unwrap(); assert_eq!(r.pruned.objects, 0); assert!(r.pruned.deferred > 0); let r = repo.gc(true).unwrap(); assert!(r.pruned.objects > 0); assert_eq!(repo.log().unwrap().len(), 4); } #[test] fn describe_amend_abandon_heads() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); repo.create_identity("Ada", "a@b", IdentityScope::User).unwrap(); repo.set_user_config("core.sign", "change").unwrap(); write(root, "a.txt", b"one\n"); let h1 = repo.commit("first", &[]).unwrap(); write(root, "a.txt", b"one\ntwo\n"); let h2 = repo.commit("secnod", &[]).unwrap(); let id2 = repo.store().get_change(&h2).unwrap().change_id; // describe: same ChangeId, new hash, bookmark and working copy follow. let h2b = repo.describe(&h2, "second").unwrap(); assert_ne!(h2b, h2); let c = repo.store().get_change(&h2b).unwrap(); assert_eq!((c.change_id, c.message.as_str(), c.deps.clone()), (id2, "second", vec![h1])); assert_eq!(repo.bookmark_heads().unwrap(), vec![h2b]); assert!(matches!(repo.verify_change(&h2b).unwrap(), SignatureStatus::Valid { .. })); let s = repo.snapshot("status").unwrap(); assert!(s.statuses.is_empty(), "{:?}", s.statuses); assert_eq!(repo.resolve_change(&id2.short(8)).unwrap(), h2b, "the old object is no longer reachable from the log"); // Describing a non-head rewrites its dependents (h2b โ†’ h2c') and the working copy. let h1b = repo.describe(&h1, "first, described").unwrap(); assert_ne!(h1b, h1); let heads = repo.bookmark_heads().unwrap(); assert_eq!(heads.len(), 1); let top = repo.store().get_change(&heads[0]).unwrap(); assert_eq!((top.change_id, top.deps.clone()), (id2, vec![h1b])); assert!(matches!(repo.verify_change(&heads[0]).unwrap(), SignatureStatus::Valid { .. }), "rewritten own change is re-signed"); let (_, wc) = repo.working_copy().unwrap(); assert_eq!(wc.deps, vec![heads[0]], "the working copy follows the rewrite"); assert!(!repo.snapshot("status").unwrap().changed); let snap = repo.load_state(&heads).unwrap().materialize(); assert!(matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n")); // amend: fold an edit into the head; the result is one change from h1. write(root, "a.txt", b"one\ntwo\nthree\n"); write(root, "b.txt", b"b\n"); let h2c = repo.amend(None).unwrap(); let c = repo.store().get_change(&h2c).unwrap(); assert_eq!(c.change_id, id2); assert_eq!(c.message, "second"); assert_eq!(repo.bookmark_heads().unwrap(), vec![h2c]); assert_eq!(repo.log().unwrap().len(), 3, "working copy, amended head, first"); let snap = repo.load_state(&[h2c]).unwrap().materialize(); assert!(matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\nthree\n")); assert!(snap.files.contains_key("b.txt")); assert!(!repo.snapshot("status").unwrap().changed); assert!(repo.amend(None).is_err(), "clean working copy and no message"); let h2d = repo.amend(Some("second, amended")).unwrap(); assert_eq!(repo.store().get_change(&h2d).unwrap().message, "second, amended"); // abandon: the head goes, its content stays as uncommitted work. repo.abandon(&h2d).unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![h1b], "the described first change is the head again"); let s = repo.snapshot("status").unwrap(); let paths: Vec<_> = s.statuses.iter().map(|p| (p.path.as_str(), p.status)).collect(); assert_eq!(paths, vec![("a.txt", ChangeStatus::Modified), ("b.txt", ChangeStatus::Added)]); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\ntwo\nthree\n"); let h3 = repo.commit("recommitted", &[]).unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![h3]); // undo brings the abandoned head back. repo.undo().unwrap(); repo.undo().unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![h2d]); // Abandoning a non-head whose dependent references its atoms reifies // the references (here: a.txt vanishes with it, so the dependent's edit // of a.txt is dropped with a note) instead of refusing; undo restores it. let base = repo.bookmark_heads().unwrap()[0]; let base_change = repo.store().get_change(&base).unwrap(); let h1_now = base_change.deps[0]; let notes = repo.abandon(&h1_now).unwrap(); assert!(!notes.is_empty(), "the dependent's edit of a.txt was reified"); let heads = repo.bookmark_heads().unwrap(); let after = repo.load_state(&heads).unwrap().materialize(); assert!(!after.files.contains_key("a.txt") && after.files.contains_key("b.txt"), "{:?}", after.files.keys()); repo.undo().unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![base]); write(root, "c.txt", b"c\n"); let hc = repo.commit("c", &[]).unwrap(); write(root, "d.txt", b"d\n"); let hd = repo.commit("d", &[hc]).unwrap(); assert_eq!(repo.store().get_change(&hd).unwrap().deps, vec![hc]); repo.abandon(&hc).unwrap(); let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect(); assert!(!heads.contains(&hc)); let d_now = heads.iter().find(|h| repo.store().get_change(h).unwrap().message == "d").copied().unwrap(); assert!(repo.store().get_change(&d_now).unwrap().deps.is_empty(), "d inherited c's (empty) dependencies"); let s = repo.snapshot("status").unwrap(); assert_eq!(s.statuses.iter().map(|p| p.path.as_str()).collect::>(), vec!["c.txt"], "c's content is now uncommitted work"); } #[test] fn git_export_mirrors_history() { hermetic(); if std::process::Command::new("git").arg("--version").output().is_err() { eprintln!("git not installed; skipping"); return; } let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(&root.join("r")).unwrap().with_home(root.join("home")); repo.create_identity("Ada Lovelace", "ada@example.org", IdentityScope::User).unwrap(); let r = root.join("r"); write(&r, "README.md", b"hello\n"); write(&r, "src/main.rs", b"fn main() {}\n"); let h1 = repo.commit("first", &[]).unwrap(); write(&r, "src/main.rs", b"fn main() { run(); }\n"); write(&r, "logo.png", b"\x89PNG\0\0"); let h2 = repo.commit("second", &[]).unwrap(); let git_dir = root.join("mirror.git"); let rep = repo.git_export(&git_dir).unwrap(); assert_eq!((rep.exported, rep.skipped, rep.heads), (2, 0, 1)); let git = |args: &[&str]| -> String { let out = std::process::Command::new("git").arg("-C").arg(&git_dir).args(args).output().unwrap(); assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); String::from_utf8_lossy(&out.stdout).trim().to_string() }; assert_eq!(git(&["log", "--format=%s", "main"]), "second\nfirst"); assert_eq!(git(&["show", "main:src/main.rs"]), "fn main() { run(); }"); assert_eq!(git(&["show", "main:README.md"]), "hello"); let body = git(&["log", "-1", "--format=%B", "main"]); assert!(body.contains(&format!("Oxy-Hash: {}", h2.to_hex())), "{body}"); assert!(body.contains("Change-Id: "), "{body}"); assert_eq!(git(&["log", "-1", "--format=%an <%ae>", "main"]), "Ada Lovelace "); assert_eq!(git(&["ls-tree", "--name-only", "main"]), "README.md\nlogo.png\nsrc"); // Re-export is incremental. let rep = repo.git_export(&git_dir).unwrap(); assert_eq!((rep.exported, rep.skipped), (0, 2)); // An independent change makes two heads; the next state is one more // commit on the linear branch whose tree is the union. repo.checkout(&[h1], false, "checkout").unwrap(); write(&r, "other.txt", b"o\n"); let h3 = repo.commit("third", &[]).unwrap(); let rep = repo.git_export(&git_dir).unwrap(); assert_eq!((rep.exported, rep.heads), (1, 2)); assert_eq!(git(&["log", "--format=%s", "main"]), "third\nsecond\nfirst"); let parents = git(&["rev-list", "--parents", "-1", "main"]); assert_eq!(parents.split(' ').count(), 2, "one parent: {parents}"); assert_eq!(git(&["show", "main:other.txt"]), "o"); assert_eq!(git(&["show", "main:src/main.rs"]), "fn main() { run(); }"); assert_eq!(git(&["ls-tree", "--name-only", "main"]), "README.md\nlogo.png\nother.txt\nsrc"); let body = git(&["log", "-1", "--format=%B", "main"]); assert!(body.contains(&format!("Oxy-Hash: {}", h3.to_hex())) && body.contains("Oxy-State: "), "{body}"); // A rewrite is a new state whose one new change is the rewritten one. repo.describe(&h3, "third, described").unwrap(); let rep = repo.git_export(&git_dir).unwrap(); assert_eq!(rep.exported, 1); assert_eq!(git(&["log", "-1", "--format=%s", "main"]), "third, described"); assert_eq!(git(&["log", "--format=%s", "main"]).lines().count(), 4); let _ = h2; } #[test] fn git_import_reproduces_trees() { hermetic(); if std::process::Command::new("git").arg("--version").output().is_err() { eprintln!("git not installed; skipping"); return; } let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let g = root.join("src.git"); fs::create_dir_all(&g).unwrap(); let git = |args: &[&str]| -> String { let out = std::process::Command::new("git") .arg("-C") .arg(&g) .args(["-c", "user.name=Grace", "-c", "user.email=grace@example.org", "-c", "commit.gpgsign=false"]) .args(args) .output() .unwrap(); assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr)); String::from_utf8_lossy(&out.stdout).trim().to_string() }; git(&["init", "-q", "-b", "main"]); write(&g, "README.md", b"# Hi\n"); write(&g, "src/lib.rs", b"pub fn a() {}\n"); git(&["add", "."]); git(&["commit", "-q", "-m", "initial"]); write(&g, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\n"); write(&g, "run.sh", b"#!/bin/sh\n"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(g.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap(); } write(&g, "logo.png", b"\x89PNG\0\0\x01"); git(&["add", "."]); git(&["commit", "-q", "-m", "second\n\nChange-Id: 0123456789abcdef0123456789abcdef"]); // A branch that edits README, merged back with a conflict-free merge. git(&["checkout", "-q", "-b", "topic", "HEAD~1"]); write(&g, "README.md", b"# Hi\n\nMore.\n"); git(&["commit", "-q", "-am", "readme"]); git(&["checkout", "-q", "main"]); git(&["merge", "-q", "--no-ff", "-m", "merge topic", "topic"]); // A branch from the initial commit inserting at the same place as // "second": concurrent in Oxygen too, so the merge commit is a resolution. let initial = git(&["rev-list", "--max-parents=0", "HEAD"]); git(&["checkout", "-q", "-b", "clash", &initial]); write(&g, "src/lib.rs", b"pub fn a() {}\npub fn c() {}\n"); git(&["commit", "-q", "-am", "clash"]); git(&["checkout", "-q", "main"]); let _ = std::process::Command::new("git").arg("-C").arg(&g).args(["merge", "-q", "clash"]).output(); write(&g, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\npub fn c() {}\n"); git(&["add", "."]); git(&["commit", "-q", "-m", "resolve clash"]); git(&["rm", "-q", "logo.png"]); git(&["commit", "-q", "-m", "drop logo"]); // A rename with a small edit is imported as a move of the same node. git(&["mv", "src/lib.rs", "src/lib2.rs"]); write(&g, "src/lib2.rs", b"pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n"); git(&["add", "."]); git(&["commit", "-q", "-m", "rename lib"]); let expected_count: usize = git(&["rev-list", "--count", "main"]).parse().unwrap(); let dest = root.join("oxy"); let repo = Repo::init(&dest).unwrap().with_home(root.join("home")); let rep = repo.import_git(&g, "main").unwrap(); assert_eq!(rep.commits, expected_count); assert_eq!(rep.merges, 2); // The working copy equals the Git tree at main. assert_eq!(fs::read(dest.join("src/lib2.rs")).unwrap(), b"pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n"); assert!(!dest.join("src/lib.rs").exists()); assert_eq!(fs::read(dest.join("README.md")).unwrap(), b"# Hi\n\nMore.\n"); assert!(!dest.join("logo.png").exists()); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; assert!(fs::metadata(dest.join("run.sh")).unwrap().permissions().mode() & 0o111 != 0); } assert!(!repo.snapshot("status").unwrap().changed, "clean after import"); let heads = repo.bookmark_heads().unwrap(); let st = repo.load_state(&heads).unwrap(); assert!(!st.materialize().is_conflicted()); assert_eq!(st.len(), expected_count); let tip = repo.log().unwrap().into_iter().find(|e| e.change.message.starts_with("rename lib")).unwrap(); let c = repo.store().get_change(&tip.hash).unwrap(); assert!(c.tree_ops.iter().any(|op| matches!(op, TreeOp::Move { name, .. } if name == "lib2.rs")), "{:?}", c.tree_ops); assert!(!c.tree_ops.iter().any(|op| matches!(op, TreeOp::AddNode { .. }))); // Messages carry Git trailers; the Change-Id trailer was honoured. let log = repo.log().unwrap(); let second = log.iter().find(|e| e.change.message.starts_with("second")).unwrap(); assert_eq!(second.change.change_id, ChangeId::from_hex("0123456789abcdef0123456789abcdef").unwrap()); assert!(second.change.message.contains("Git-Author: Grace ")); let resolve = log.iter().find(|e| e.change.message.starts_with("resolve clash")).unwrap(); assert_eq!(resolve.change.kind, ChangeKind::Resolution, "a conflicting Git merge becomes a resolution"); // Re-importing into another fresh repository gives the same ChangeIds. let repo2 = Repo::init(&root.join("oxy2")).unwrap().with_home(root.join("home")); repo2.import_git(&g, "main").unwrap(); let ids: BTreeSet = repo.log().unwrap().iter().filter(|e| !e.is_working_copy).map(|e| e.change.change_id).collect(); let ids2: BTreeSet = repo2.log().unwrap().iter().filter(|e| !e.is_working_copy).map(|e| e.change.change_id).collect(); assert_eq!(ids, ids2); // Round trip back out to Git reproduces the tip tree. let mirror = root.join("mirror.git"); repo.git_export(&mirror).unwrap(); let out = std::process::Command::new("git").arg("-C").arg(&mirror).args(["show", "main:src/lib2.rs"]).output().unwrap(); assert_eq!(String::from_utf8_lossy(&out.stdout), "pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n"); let out = std::process::Command::new("git").arg("-C").arg(&mirror).args(["show", "main:src/lib.rs"]).output().unwrap(); assert!(!out.status.success(), "the old path is gone from the export"); } #[test] fn checkpoints_accelerate_loading_and_stay_correct() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); repo.config.set("core.checkpoint-interval", "5").unwrap(); repo.save_config().unwrap(); let repo = Repo::open(root).unwrap().with_home(root.join("home")); let mut hashes = Vec::new(); for i in 0..12 { write(root, "a.txt", format!("{}\n", (0..=i).map(|k| k.to_string()).collect::>().join("\n")).as_bytes()); write(root, &format!("f{i}.txt"), b"x\n"); hashes.push(repo.commit(&format!("c{i}"), &[]).unwrap()); } let (_, cp) = repo.latest_checkpoint("main").unwrap().expect("automatic checkpoint"); assert!(cp.count >= 5 && cp.count <= 12, "{}", cp.count); // Loading through the checkpoint equals loading everything. let heads = repo.bookmark_heads().unwrap(); let fast = repo.load_state(&heads).unwrap(); let full = repo.load_state_full(&heads).unwrap(); assert_eq!(fast.len(), full.len()); assert_eq!(fast.heads(), full.heads()); let (a, b) = (fast.materialize(), full.materialize()); assert_eq!(a.files.keys().collect::>(), b.files.keys().collect::>()); for (p, f) in &a.files { assert_eq!(f.content, b.files[p].content, "{p}"); } assert!(repo.load_state_checkpointed(&heads).unwrap().is_some()); // A head set that does not contain the checkpoint's heads falls back. assert!(repo.load_state_checkpointed(&[hashes[0]]).unwrap().is_none()); assert_eq!(repo.load_state(&[hashes[0]]).unwrap().len(), 1); // Everything else keeps working on top of the checkpoint. assert!(!repo.snapshot("status").unwrap().changed); let h = repo.describe(&hashes[3], "c3 described").unwrap(); assert_eq!(repo.store().get_change(&h).unwrap().message, "c3 described"); let (r, n, bytes) = repo.checkpoint().unwrap(); assert!(n >= 12 && bytes > 0); assert_eq!(repo.latest_checkpoint("main").unwrap().unwrap().0, r); write(root, "a.txt", b"after\n"); let h_after = repo.commit("after", &[]).unwrap(); let st = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap(); assert!(st.contains(&h_after)); assert!(matches!(&st.materialize().files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"after\n")); assert_eq!(repo.log().unwrap().len(), 14); // Checkpoints survive gc. repo.gc(true).unwrap(); assert!(repo.latest_checkpoint("main").unwrap().is_some()); assert!(repo.load_state_checkpointed(&repo.bookmark_heads().unwrap()).unwrap().is_some()); } #[test] fn quick_status_for_prompts() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = Repo::init(&root.join("origin")).unwrap(); let url = root.join("origin").to_string_lossy().to_string(); let a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home")); let ops_before = a.op_log().unwrap().len(); let info = a.quick_status().unwrap(); assert_eq!((info.bookmark.as_str(), info.heads, info.dirty, info.conflicted, info.ahead, info.behind), ("main", 0, false, false, Some(0), Some(0))); write(&root.join("a"), "f.txt", b"f\n"); assert!(a.quick_status().unwrap().dirty, "a new file is dirty"); assert_eq!(a.op_log().unwrap().len(), ops_before, "quick_status records nothing"); // A snapshotted but uncommitted change is still dirty. a.snapshot("status").unwrap(); assert!(a.quick_status().unwrap().dirty); a.commit("f", &[]).unwrap(); let info = a.quick_status().unwrap(); assert!(!info.dirty, "clean right after a commit"); assert_eq!((info.ahead, info.behind), (Some(1), Some(0))); assert_eq!(info.format("%b %c%d%x%A%D"), format!("main {}+1", info.change_id)); assert_eq!(info.format("[%h heads] %a/%B %%"), "[1 heads] 1/0 %"); // Touching a file without changing it is not dirty; changing content of // the same size is. let p = root.join("a/f.txt"); let content = fs::read(&p).unwrap(); fs::write(&p, &content).unwrap(); assert!(!a.quick_status().unwrap().dirty); fs::write(&p, b"g\n").unwrap(); assert!(a.quick_status().unwrap().dirty); fs::write(&p, b"f\n").unwrap(); // Push, then a second clone commits and pushes: we are behind. assert!(a.push("origin", false).unwrap()[0].ok); assert_eq!(a.quick_status().unwrap().ahead, Some(0)); let b = Repo::clone(&url, &root.join("b")).unwrap().with_home(root.join("home-b")); write(&root.join("b"), "g.txt", b"g\n"); b.commit("g", &[]).unwrap(); b.push("origin", false).unwrap(); a.fetch("origin").unwrap(); let info = a.quick_status().unwrap(); assert_eq!((info.ahead, info.behind), (Some(0), Some(1))); assert_eq!(info.format("%A%D"), "-1"); let _ = origin; } #[test] fn lazy_blobs_are_fetched_on_first_read() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_dir = root.join("origin"); let origin = Repo::init(&origin_dir).unwrap().with_home(root.join("home")); let url = origin_dir.to_string_lossy().to_string(); let big: Vec = (0..200_000u32).map(|i| (i % 251) as u8).collect(); // has NUL bytes: opaque write(&origin_dir, "big.bin", &big); write(&origin_dir, "small.bin", &[0u8, 1, 2]); write(&origin_dir, "t.txt", b"t\n"); let h = origin.commit("blobs", &[]).unwrap(); let big_ref = Repo::blob_ref(&big); // Fetch only (no checkout) with a filter: the big blob stays absent. let a = Repo::init_with_id(&root.join("a"), origin.id().unwrap()).unwrap().with_home(root.join("home")); let a = { let mut a = a; a.config.set("remote.origin.url", &url).unwrap(); a.config.set("filter.max-blob", "100K").unwrap(); a.save_config().unwrap(); a }; assert_eq!(a.config.max_blob, Some(100 * 1024)); a.fetch("origin").unwrap(); assert!(a.store().has(&h)); assert!(!a.store().has(&big_ref), "withheld"); assert!(a.store().has(&Repo::blob_ref(&[0u8, 1, 2])), "small blob came along"); assert_eq!(a.lazy_remote(&big_ref).as_deref(), Some("origin")); // status and commit do not need it: adopt the state, edit a text file, commit. let (op_ref, op) = a.head_op().unwrap(); let mut view = op.view.clone(); view.bookmarks.insert("main".into(), a.store().put_state(&StateObject::new(vec![h])).unwrap()); a.record_op(&[op_ref], "adopt", view).unwrap(); // Checkout reads the blob lazily, which fetches it. a.checkout(&[h], true, "checkout").unwrap(); assert!(a.store().has(&big_ref), "fetched on first read"); assert_eq!(fs::read(root.join("a/big.bin")).unwrap(), big); assert!(a.lazy_remote(&big_ref).is_none(), "no longer registered"); write(&root.join("a"), "t.txt", b"t2\n"); a.commit("text edit", &[]).unwrap(); // clone --max-blob records the filter; the checkout fetches lazily. let b = Repo::clone_with(&url, &root.join("b"), Some(1024), &[]).unwrap(); assert_eq!(b.config.max_blob, Some(1024)); assert_eq!(fs::read(root.join("b/big.bin")).unwrap(), big); assert_eq!(parse_size("2M").unwrap(), 2 << 20); assert!(parse_size("lots").is_err()); } #[test] fn sparse_checkout_materializes_only_chosen_paths() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_dir = root.join("origin"); let origin = Repo::init(&origin_dir).unwrap().with_home(root.join("home")); write(&origin_dir, "src/main.rs", b"fn main() {}\n"); write(&origin_dir, "src/lib.rs", b"pub fn a() {}\n"); write(&origin_dir, "docs/guide.md", b"# guide\n"); let logo: Vec = (0..50_000u32).map(|i| (i % 7) as u8).collect(); write(&origin_dir, "docs/logo.png", &logo); write(&origin_dir, "README.md", b"# top\n"); let h1 = origin.commit("tree", &[]).unwrap(); let url = origin_dir.to_string_lossy().to_string(); let a = Repo::clone_with(&url, &root.join("a"), None, &["src".into()]).unwrap().with_home(root.join("home")); assert_eq!(a.config.sparse_paths, vec!["src"]); assert_eq!(a.config.max_blob, Some(0), "sparse implies lazy blobs"); assert!(root.join("a/src/main.rs").exists()); assert!(!root.join("a/docs").exists() && !root.join("a/README.md").exists()); assert!(!a.store().has(&Repo::blob_ref(&logo)), "out-of-scope blob never fetched"); assert!(!a.snapshot("status").unwrap().changed, "out-of-scope files are not removals"); assert_eq!(a.quick_status().unwrap().dirty, false); // Edits in scope commit normally and keep the rest of the tree intact. write(&root.join("a"), "src/lib.rs", b"pub fn a() {}\npub fn b() {}\n"); let h2 = a.commit("edit lib", &[]).unwrap(); let st = a.load_state(&[h2]).unwrap().materialize(); assert!(st.files.contains_key("docs/guide.md"), "the state still has everything"); assert!(a.push("origin", false).unwrap()[0].ok); // Files created outside the scope are ignored by status. write(&root.join("a"), "docs/new.md", b"ignored\n"); assert!(a.snapshot("status").unwrap().statuses.is_empty()); // Widening the filter checks out the rest; narrowing leaves stray files alone. let mut a = a; a.config.set("filter.paths", "src,docs").unwrap(); a.save_config().unwrap(); let r = a.checkout(&a.bookmark_heads().unwrap(), true, "widen").unwrap(); assert!(r.written >= 2); assert_eq!(fs::read(root.join("a/docs/logo.png")).unwrap(), logo, "now fetched lazily"); assert!(!root.join("a/README.md").exists()); // Origin sees the change made under a sparse checkout with the full tree intact. origin.pull("origin").err(); let full = origin.load_state(&origin.bookmark_heads().unwrap()).unwrap().materialize(); assert!(matches!(&full.files["src/lib.rs"].content, FileContent::Text { bytes, .. } if bytes == b"pub fn a() {}\npub fn b() {}\n")); assert!(full.files.contains_key("README.md")); let _ = h1; } #[test] fn shallow_clone_and_deepen() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_dir = root.join("origin"); let origin = Repo::init(&origin_dir).unwrap().with_home(root.join("home")); let mut hashes = Vec::new(); for i in 0..8 { write(&origin_dir, "a.txt", format!("{}\n", (0..=i).map(|k| k.to_string()).collect::>().join("\n")).as_bytes()); hashes.push(origin.commit(&format!("c{i}"), &[]).unwrap()); } let url = origin_dir.to_string_lossy().to_string(); let a = Repo::clone_shallow(&url, &root.join("a"), 3, None, &[]).unwrap().with_home(root.join("home")); assert!(a.is_shallow()); assert_eq!(fs::read_to_string(root.join("a/a.txt")).unwrap(), "0\n1\n2\n3\n4\n5\n6\n7\n"); assert!(a.store().has(&hashes[7]) && a.store().has(&hashes[5]), "three levels present"); assert!(!a.store().has(&hashes[4]), "beyond the depth is absent"); assert_eq!(a.boundary(), [hashes[4]].into_iter().collect()); assert_eq!(a.log().unwrap().len(), 9, "the log shows every change from the checkpoint's metadata"); assert!(!a.snapshot("status").unwrap().changed); assert_eq!(a.quick_status().unwrap().heads, 1); // Work continues on top of the shallow history and pushes back. write(&root.join("a"), "a.txt", b"0\n1\n2\n3\n4\n5\n6\n7\n8\n"); let h_new = a.commit("c8", &[]).unwrap(); assert!(a.push("origin", false).unwrap()[0].ok); assert_eq!(origin.bookmark_heads().unwrap(), vec![h_new]); // Fetching new work from origin into the shallow clone works too. write(&origin_dir, "b.txt", b"b\n"); origin.pull("origin").err(); let (_, wc) = origin.working_copy().unwrap(); let _ = wc; origin.checkout(&origin.bookmark_heads().unwrap(), true, "co").unwrap(); write(&origin_dir, "b.txt", b"b\n"); let h_b = origin.commit("b", &[]).unwrap(); let pr = a.pull("origin").unwrap(); assert!(pr.changed); assert!(a.store().has(&h_b)); assert!(root.join("a/b.txt").exists()); // Deepening fetches older history until the boundary is gone. // Every commit also depends on the change that created the file, so two // levels below the boundary hold h4, h3, and the root. assert!(a.deepen("origin", 2).unwrap() >= 2); assert!(a.store().has(&hashes[3])); assert!(a.is_shallow()); a.deepen("origin", 100).unwrap(); assert!(!a.is_shallow(), "boundary reached the root"); assert!(a.store().has(&hashes[0])); assert_eq!(a.load_state_full(&a.bookmark_heads().unwrap()).unwrap().len(), 10); } #[test] fn tags_are_immutable_named_states_and_travel() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = Repo::init(&root.join("origin")).unwrap(); let url = root.join("origin").to_string_lossy().to_string(); let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home-a")); let (key_a, _) = a.create_identity("Ada", "ada@example.org", IdentityScope::User).unwrap(); write(&root.join("a"), "f.txt", b"1\n"); let h1 = a.commit("one", &[]).unwrap(); write(&root.join("a"), "f.txt", b"1\n2\n"); let h2 = a.commit("two", &[]).unwrap(); let t = a.tag_create("v1", &[h1], "first release").unwrap(); assert_eq!((t.heads.clone(), t.signer.as_deref()), (vec![h1], Some("Ada"))); assert!(a.tag_create("v1", &[h2], "").is_err(), "immutable"); assert!(a.tag_create("bad/name", &[h2], "").is_err()); assert_eq!(a.tag_heads("v1").unwrap(), Some(vec![h1])); assert_eq!(a.resolve_heads("v1").unwrap(), vec![h1]); assert_eq!(a.resolve_heads("main").unwrap(), vec![h2]); let (_, sa) = a.tag_attest("v1").unwrap().unwrap(); assert_eq!(sa.sigs[0].key, key_a); // Checkout by tag. a.checkout(&a.resolve_heads("v1").unwrap(), false, "co").unwrap(); assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"1\n"); a.checkout(&a.bookmark_heads().unwrap(), false, "co").unwrap(); // Push carries the tag; a second attestation for the name is refused. let r = a.push("origin", false).unwrap(); assert!(r.iter().any(|x| x.name == "tags/v1" && x.ok), "{r:?}"); assert_eq!(origin.tag_heads("v1").unwrap(), Some(vec![h1])); let mut forged = a.tag_refs().unwrap().into_iter().find(|t| t.name == "v1").unwrap(); forged.state = origin.store().put_state(&StateObject::new(vec![h2])).unwrap(); forged.heads = vec![h2]; forged.attestation = None; let r = origin.receive_push(Bundle::default(), vec![], None, vec![forged]).unwrap(); assert!(!r[0].ok && r[0].reason.contains("immutable"), "{r:?}"); // A clone gets the tag; a later tag arrives on fetch. let b = Repo::clone(&url, &root.join("b")).unwrap().with_home(root.join("home-b")); assert_eq!(b.tag_heads("v1").unwrap(), Some(vec![h1])); assert!(b.tags().unwrap()[0].signer.as_deref() == Some("Ada")); a.tag_create("v2", &[h2], "second").unwrap(); a.push("origin", false).unwrap(); let rep = b.fetch("origin").unwrap(); assert_eq!(rep.new_tags, 1); assert_eq!(b.tag_heads("v2").unwrap(), Some(vec![h2])); // Under a policy, an unattested tag is refused and an attested one by the root passes. a.policy_init().unwrap(); a.push("origin", false).unwrap(); let unattested = crate::protocol::TagRef { name: "v3".into(), state: origin.store().put_state(&StateObject::new(vec![h2])).unwrap(), heads: vec![h2], attestation: None }; let r = origin.receive_push(Bundle::default(), vec![], None, vec![unattested]).unwrap(); assert!(!r[0].ok && r[0].reason.contains("attestation"), "{r:?}"); a.tag_create("v3", &[h2], "").unwrap(); let r = a.push("origin", false).unwrap(); assert!(r.iter().any(|x| x.name == "tags/v3" && x.ok), "{r:?}"); assert!(a.gc(true).is_ok()); assert_eq!(a.tags().unwrap().len(), 3, "tags survive gc"); } #[test] fn multiple_workspaces_share_a_repository() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let main_dir = root.join("main"); let repo = Repo::init(&main_dir).unwrap().with_home(root.join("home")); write(&main_dir, "a.txt", b"a\n"); let h1 = repo.commit("a", &[]).unwrap(); assert_eq!(repo.workspaces().unwrap().len(), 1); // A second workspace elsewhere, checked out on the bookmark. let ws_dir = root.join("ws2"); let ws = repo.workspace_add(&ws_dir, None).unwrap(); assert!(ws_dir.join(".oxy").is_file()); assert_eq!(fs::read(ws_dir.join("a.txt")).unwrap(), b"a\n"); assert_ne!(ws.workspace(), repo.workspace()); let list = repo.workspaces().unwrap(); assert_eq!(list.len(), 2); assert!(list.iter().any(|w| w.id == ws.workspace() && w.path == ws_dir.canonicalize().unwrap() && !w.current)); assert!(!ws.snapshot("status").unwrap().changed); // Work in the second workspace: its own working copy, shared bookmark. write(&ws_dir, "b.txt", b"b\n"); let s = ws.snapshot("status").unwrap(); assert_eq!(s.statuses.len(), 1); assert!(!repo.snapshot("status").unwrap().changed, "the first workspace is untouched"); let h2 = ws.commit("b from ws2", &[]).unwrap(); let mut heads = repo.bookmark_heads().unwrap(); heads.sort(); let mut want = vec![h1, h2]; want.sort(); assert_eq!(heads, want, "both workspaces see the bookmark move"); assert!(!main_dir.join("b.txt").exists()); repo.checkout(&repo.bookmark_heads().unwrap(), false, "co").unwrap(); assert!(main_dir.join("b.txt").exists()); // Reopening from inside the second workspace finds the shared repository. let reopened = Repo::open(&ws_dir).unwrap(); assert_eq!(reopened.workspace(), ws.workspace()); assert_eq!(reopened.id().unwrap(), repo.id().unwrap()); assert_eq!(reopened.quick_status().unwrap().heads, 2); // Workspaces cannot nest; forget drops one from the view but keeps files. assert!(repo.workspace_add(&main_dir.join("inner"), None).is_err()); assert!(repo.workspace_forget(&repo.workspace()).is_err()); repo.workspace_forget(&ws.workspace()).unwrap(); assert_eq!(repo.workspaces().unwrap().len(), 1); assert!(ws_dir.join("b.txt").exists()); assert!(ws.snapshot("status").is_err(), "a forgotten workspace is no longer in the view"); // The snapshot lock: a fresh lock blocks a second, a stale one is taken over. let lock = repo.lock_snapshot().unwrap(); drop(lock); let lock_path = main_dir.join(".oxy/workspaces").join(repo.workspace().to_hex()).join("lock"); fs::write(&lock_path, "1").unwrap(); let old = std::time::SystemTime::now() - std::time::Duration::from_secs(120); fs::File::open(&lock_path).unwrap().set_modified(old).unwrap(); assert!(repo.lock_snapshot().is_ok(), "stale lock taken over"); } #[test] fn attributes_binary_eol_nocompress_lockable() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, ".oxyattributes", b"*.dat binary nocompress\n*.txt eol=crlf\nassets/** lockable\n"); write(root, "plain.dat", b"no nul bytes here\n"); write(root, "notes.txt", b"one\r\ntwo\r\n"); write(root, "assets/model.bin", b"\0model"); let h = repo.commit("attrs", &[]).unwrap(); let snap = repo.load_state(&[h]).unwrap().materialize(); assert!(snap.files["plain.dat"].mode.opaque, "binary attribute forces opaque without NUL bytes"); assert!(matches!(&snap.files["notes.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n"), "stored with LF"); assert!(!repo.snapshot("status").unwrap().changed, "CRLF on disk equals LF in the state"); // nocompress chunks pack raw. let r = repo.gc(true).unwrap(); assert!(r.packed.objects > 0); // Checkout writes CRLF for eol=crlf and makes lockable files read-only. fs::remove_file(root.join("notes.txt")).unwrap(); repo.checkout(&[h], true, "co").unwrap(); assert_eq!(fs::read(root.join("notes.txt")).unwrap(), b"one\r\ntwo\r\n"); assert!(fs::metadata(root.join("assets/model.bin")).unwrap().permissions().readonly()); assert!(repo.lock_path("plain.dat").is_err(), "not lockable"); repo.lock_path("assets/model.bin").unwrap(); assert!(!fs::metadata(root.join("assets/model.bin")).unwrap().permissions().readonly()); assert!(repo.is_locked("assets/model.bin")); write(root, "assets/model.bin", b"\0model2"); repo.commit("edit model", &[]).unwrap(); repo.unlock_path("assets/model.bin").unwrap(); assert!(fs::metadata(root.join("assets/model.bin")).unwrap().permissions().readonly()); // A later checkout can still overwrite a read-only lockable file. repo.checkout(&[h], true, "co").unwrap(); assert_eq!(fs::read(root.join("assets/model.bin")).unwrap(), b"\0model"); } #[cfg(unix)] #[test] fn hooks_can_veto_observe_and_add_dependencies() { use std::os::unix::fs::PermissionsExt; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); let hooks = root.join(".oxy/hooks"); fs::create_dir_all(&hooks).unwrap(); let hook = |name: &str, body: &str| { let p = hooks.join(name); fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap(); fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap(); }; hook("pre-commit", "cat > \"$OXY_ROOT/.last-pre-commit\"; grep -q WIP \"$OXY_ROOT/.last-pre-commit\" && { echo 'no WIP commits' >&2; exit 1; }; exit 0"); hook("post-commit", "cat > \"$OXY_ROOT/.last-post-commit\""); hook("post-checkout", "cat > \"$OXY_ROOT/.last-post-checkout\""); hook("pre-push", "echo pushing blocked >&2; exit 3"); write(root, ".oxyignore", b".last-*\n"); write(root, "a.txt", b"a\n"); let err = repo.commit("WIP: nope", &[]).unwrap_err().to_string(); assert!(err.contains("pre-commit refused: no WIP commits"), "{err}"); let h1 = repo.commit("first", &[]).unwrap(); let pre = fs::read_to_string(root.join(".last-pre-commit")).unwrap(); assert!(pre.contains("\"event\":\"pre-commit\"") && pre.contains("\"path\":\"a.txt\"") && pre.contains("\"message\":\"first\""), "{pre}"); let post = fs::read_to_string(root.join(".last-post-commit")).unwrap(); assert!(post.contains(&h1.to_hex()), "{post}"); repo.checkout(&[h1], false, "co").unwrap(); assert!(fs::read_to_string(root.join(".last-post-checkout")).unwrap().contains("post-checkout")); repo.config.set("remote.origin.url", "/nowhere").unwrap(); repo.save_config().unwrap(); let err = repo.push("origin", false).unwrap_err().to_string(); assert!(err.contains("pre-push refused: pushing blocked"), "{err}"); // semantic-deps: only when enabled; adds an explicit dependency. write(root, "b.txt", b"b\n"); hook("semantic-deps", &format!("cat > \"$OXY_ROOT/.last-semantic\"; echo '{{\"deps\": [\"{}\"]}}'", h1.to_hex())); let h2 = repo.commit("b without hook", &[]).unwrap(); assert!(repo.store().get_change(&h2).unwrap().deps.is_empty(), "hook disabled by default"); repo.config.set("hooks.semantic-deps", "true").unwrap(); repo.save_config().unwrap(); write(root, "c.txt", b"c\n"); let h3 = repo.commit("c with hook", &[]).unwrap(); assert_eq!(repo.store().get_change(&h3).unwrap().deps, vec![h1]); let sem = fs::read_to_string(root.join(".last-semantic")).unwrap(); assert!(sem.contains("\"touched_paths\":[\"c.txt\"]") && sem.contains("candidate_deps"), "{sem}"); assert_eq!(json_str("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\""); } #[test] fn amend_into_older_change_and_split() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); repo.create_identity("Ada", "a@b", IdentityScope::User).unwrap(); repo.set_user_config("core.sign", "change").unwrap(); write(root, "a.txt", b"one\ntwo\n"); let h0 = repo.commit("base", &[]).unwrap(); write(root, "a.txt", b"one\nTWO\n"); let h1 = repo.commit("edit two", &[]).unwrap(); write(root, "a.txt", b"one\nTWO\nthree\n"); let h2 = repo.commit("add three", &[]).unwrap(); let id1 = repo.store().get_change(&h1).unwrap().change_id; // Fixing the line h1 introduced folds into h1; h2 is rewritten on top. write(root, "a.txt", b"one\nTWO!\nthree\n"); let h1b = repo.amend_into(&h1, None).unwrap(); assert_ne!(h1b, h1); let c1b = repo.store().get_change(&h1b).unwrap(); assert_eq!(c1b.change_id, id1); assert_eq!(c1b.deps, vec![h0]); assert!(matches!(repo.verify_change(&h1b).unwrap(), SignatureStatus::Valid { .. })); let heads = repo.bookmark_heads().unwrap(); assert_eq!(heads.len(), 1); let top = repo.store().get_change(&heads[0]).unwrap(); assert_eq!(top.message, "add three"); assert_eq!(top.deps.iter().copied().collect::>(), [h0, h1b].into_iter().collect(), "depends on the file's creator and the rewritten line"); let snap = repo.load_state(&heads).unwrap().materialize(); assert!(matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\nTWO!\nthree\n")); assert!(!repo.snapshot("status").unwrap().changed, "the working copy is clean after folding"); // Only h1b's own state shows the fix without "three". let only = repo.load_state(&[h1b]).unwrap().materialize(); assert!(matches!(&only.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\nTWO!\n")); assert_eq!(repo.log().unwrap().len(), 4); // An edit to a line introduced by h2 cannot fold into h1. write(root, "a.txt", b"one\nTWO!\nthree!\n"); let err = repo.amend_into(&h1b, None).unwrap_err().to_string(); assert!(err.contains("introduced after"), "{err}"); assert!(repo.snapshot("status").unwrap().statuses.len() == 1, "nothing was changed by the refusal"); write(root, "a.txt", b"one\nTWO!\nthree\n"); // Split the head that touches two files. write(root, "b.txt", b"b\n"); write(root, "c.txt", b"c\n"); let h3 = repo.commit("b and c", &[]).unwrap(); let id3 = repo.store().get_change(&h3).unwrap().change_id; let (p1, p2) = repo.split(&h3, &["b.txt".into()]).unwrap(); let c_1 = repo.store().get_change(&p1).unwrap(); let c_2 = repo.store().get_change(&p2).unwrap(); assert!(c_1.message.contains("part 1: b.txt")); assert_eq!(c_2.change_id, id3); let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect(); assert!(heads.contains(&p1) && heads.contains(&p2), "independent parts are both heads"); let snap = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap().materialize(); assert!(snap.files.contains_key("b.txt") && snap.files.contains_key("c.txt")); let only1 = repo.load_state(&[p1]).unwrap().materialize(); assert!(only1.files.contains_key("b.txt") && !only1.files.contains_key("c.txt")); assert!(!repo.snapshot("status").unwrap().changed); assert!(repo.split(&p2, &["nope.txt".into()]).is_err()); let _ = h2; } #[test] fn identity_lifecycle() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = Repo::init(&root.join("origin")).unwrap(); let url = root.join("origin").to_string_lossy().to_string(); let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home-a")); a.set_user_config("core.sign", "change").unwrap(); let (key_a, _) = a.create_identity("Ada", "ada@example.org", IdentityScope::User).unwrap(); write(&root.join("a"), "a.txt", b"a\n"); let h1 = a.commit("one", &[]).unwrap(); assert!(a.policy_init().unwrap().applied); assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); // Expiry: an identity that had expired when the change was made does not authenticate it. let mut e = Repo::init(&root.join("e")).unwrap().with_home(root.join("home-e")); e.set_user_config("core.sign", "change").unwrap(); e.create_identity_with("Eve", "eve@example.org", IdentityScope::User, Some(Hlc::now().wall_ms - 1)).unwrap(); write(&root.join("e"), "e.txt", b"e\n"); let he = e.commit("late", &[]).unwrap(); assert!(matches!(e.verify_change(&he).unwrap(), SignatureStatus::Invalid(m) if m.contains("expired"))); // Renewal re-issues the identity (same keys, new KeyId) with a later expiry. let r = e.renew_identity(Some(Hlc::now().wall_ms + 86_400_000)).unwrap(); assert_eq!(e.identity(&r.new).unwrap().previous, Some(r.old)); write(&root.join("e"), "e.txt", b"e2\n"); let he2 = e.commit("in time", &[]).unwrap(); assert!(matches!(e.verify_change(&he2).unwrap(), SignatureStatus::Valid { key, .. } if key == r.new)); // Rotation: a fresh key; the old change still verifies; the policy follows. let r = a.rotate_identity().unwrap(); assert_eq!(r.old, key_a); assert_eq!(a.config.author_key, Some(r.new)); assert_eq!(a.identity(&r.new).unwrap().previous, Some(key_a)); assert!(r.policy.as_ref().unwrap().applied); let (_, sp) = a.current_policy().unwrap().unwrap(); assert_eq!((sp.policy.serial, sp.policy.roots.clone()), (2, vec![r.new])); assert!(!sp.policy.revoked.contains(&key_a) && sp.policy.authorizes(&r.new, "main"), "superseded, not revoked"); assert!(sp.policy.succeeds(&r.new, &key_a) && !sp.policy.succeeds(&key_a, &r.new)); assert!(matches!(a.verify_change(&h1).unwrap(), SignatureStatus::Valid { key, revoked: false, .. } if key == key_a)); write(&root.join("a"), "a.txt", b"a2\n"); let h2 = a.commit("two", &[]).unwrap(); assert!(matches!(a.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, revoked: false, .. } if key == r.new)); // Origin accepts the rotated policy (signed by the old root) and the new key's change. let res = a.push("origin", false).unwrap(); assert!(res.iter().all(|x| x.ok), "{res:?}"); assert_eq!(origin.current_policy().unwrap().unwrap().1.policy.serial, 2); let key_a = r.new; // Delegation: Bob may sign on Ada's behalf. Delegates live in the // identity object, so Ada's KeyId changes while her keys stay the same. let mut b = Repo::clone(&url, &root.join("b")).unwrap().with_home(root.join("home-b")); let (key_b, id_b) = b.create_identity("Bob", "bob@example.org", IdentityScope::User).unwrap(); let r = a.delegate_add(id_b.clone()).unwrap(); assert_eq!(r.old, key_a); let key_a2 = r.new; assert_eq!(a.identity(&key_a2).unwrap().delegates, vec![key_b]); assert_eq!(a.identity(&key_a2).unwrap().ed25519_pk, a.identity(&key_a).unwrap().ed25519_pk); assert!(a.delegate_add(id_b.clone()).is_err(), "already a delegate"); assert!(a.push("origin", false).unwrap().iter().all(|x| x.ok)); write(&root.join("a"), "a.txt", b"a3\n"); let h3 = a.commit("three", &[]).unwrap(); assert!(matches!(a.verify_change(&h3).unwrap(), SignatureStatus::Valid { key, .. } if key == key_a2), "the same secret signs under the new KeyId"); // A change authored by Ada but signed only by Bob verifies through the delegation. a.set_user_config("core.sign", "seal").unwrap(); write(&root.join("a"), "a.txt", b"a4\n"); let h4 = a.commit("four", &[]).unwrap(); assert_eq!(a.verify_change(&h4).unwrap(), SignatureStatus::Unsigned); let (_, sk_b) = b.secret_key().unwrap().unwrap(); a.store().put_signature(&h4, &sk_b.sign(Domain::Change, &key_b, &h4)).unwrap(); assert!(matches!(a.verify_change(&h4).unwrap(), SignatureStatus::Valid { key, .. } if key == key_b)); a.set_user_config("core.sign", "change").unwrap(); // Withdrawing the delegation re-issues the identity again. let r = a.delegate_remove(&key_b).unwrap(); assert!(a.identity(&r.new).unwrap().delegates.is_empty()); assert!(a.delegate_remove(&key_b).is_err()); let key_a3 = r.new; let res = a.push("origin", false).unwrap(); assert!(res.iter().all(|x| x.ok), "{res:?}"); // Multi-signer policy: Bob becomes a root and the threshold rises to 2, // both applied under the old threshold of 1. A grant then needs Bob too. assert!(a.policy_root_add(id_b.clone()).unwrap().applied); assert!(a.policy_root_add(id_b.clone()).is_err()); assert!(a.policy_set_threshold(3).is_err()); assert!(a.policy_set_threshold(2).unwrap().applied); assert!(a.push("origin", false).unwrap().iter().all(|x| x.ok)); b.pull("origin").unwrap(); assert_eq!(b.current_policy().unwrap().unwrap().1.policy.root_threshold, 2); let mut c = Repo::init(&root.join("c")).unwrap().with_home(root.join("home-c")); let (key_c, id_c) = c.create_identity("Cy", "cy@example.org", IdentityScope::User).unwrap(); let serial_before = a.current_policy().unwrap().unwrap().1.policy.serial; let proposal = a.policy_grant(id_c, None).unwrap(); assert!(!proposal.applied && proposal.sigs.len() == 1); assert_eq!(proposal.policy.serial, serial_before + 1); assert_eq!(a.current_policy().unwrap().unwrap().1.policy.serial, serial_before, "a proposal changes nothing yet"); // Bob signs the proposal, handed over as a file, and it applies for him. let bytes = proposal.signed().encode(); let signed = b.policy_sign(SignedPolicy::decode(&bytes).unwrap()).unwrap(); assert!(signed.applied && signed.sigs.len() == 2); assert!(b.current_policy().unwrap().unwrap().1.policy.authorizes(&key_c, "main")); assert!(b.policy_sign(signed.signed()).is_err(), "a stale proposal is refused"); // Bob publishes it and Ada picks it up. assert!(b.push("origin", false).unwrap().iter().any(|x| x.name == "policy" && x.ok)); a.pull("origin").unwrap(); assert_eq!(a.current_policy().unwrap().unwrap().1.policy.serial, serial_before + 1); // A root cannot revoke itself; revoking Cy needs both roots but is recorded locally at once. assert!(a.policy_revoke(&key_a3).is_err()); let u = a.revoke_identity(&key_c, "left the project").unwrap().unwrap(); assert!(!u.applied); assert!(a.is_revoked(&key_c).unwrap()); assert!(!b.is_revoked(&key_c).unwrap()); let u = b.policy_sign(u.signed()).unwrap(); assert!(u.applied && u.policy.is_revoked(&key_c)); assert!(b.is_revoked(&key_c).unwrap()); // Bob can revoke Ada's root key, leaving himself as the only root (threshold 2 would be unmeetable). assert!(b.policy_revoke(&key_a3).is_err(), "would leave one root for a threshold of 2"); // Revoking one's own key without a policy root works locally: a plain marker plus attestation. let key_c_own = c.config.author_key.unwrap(); assert!(c.revoke_identity(&key_c_own, "lost laptop").unwrap().is_none()); assert!(c.is_revoked(&key_c_own).unwrap()); assert_eq!(key_c_own, key_c); } #[test] fn renames_are_detected_and_tracked_as_moves() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "src/old.rs", b"fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n"); write(root, "img.bin", b"\x89PNG\0\0\x01\x02"); write(root, "tiny.txt", b"x\n"); let h1 = repo.commit("base", &[]).unwrap(); let node_old = repo.load_state(&[h1]).unwrap().materialize().files["src/old.rs"].node; let node_img = repo.load_state(&[h1]).unwrap().materialize().files["img.bin"].node; // Rename with a small edit, a binary moved unchanged, and a rewrite // that shares nothing with the removed file. fs::remove_file(root.join("src/old.rs")).unwrap(); write(root, "lib/new.rs", b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n"); fs::remove_file(root.join("img.bin")).unwrap(); write(root, "assets/img.bin", b"\x89PNG\0\0\x01\x02"); fs::remove_file(root.join("tiny.txt")).unwrap(); write(root, "other.txt", b"completely different\n"); let snap = repo.snapshot("status").unwrap(); let statuses: Vec<(&str, ChangeStatus)> = snap.statuses.iter().map(|s| (s.path.as_str(), s.status)).collect(); assert_eq!( statuses, vec![("assets/img.bin", ChangeStatus::Renamed), ("lib/new.rs", ChangeStatus::Renamed), ("other.txt", ChangeStatus::Added), ("src", ChangeStatus::Removed), ("tiny.txt", ChangeStatus::Removed)] ); let h2 = repo.commit("move things", &[]).unwrap(); let c = repo.store().get_change(&h2).unwrap(); let moves: Vec<_> = c.tree_ops.iter().filter(|op| matches!(op, TreeOp::Move { .. })).collect(); assert_eq!(moves.len(), 2, "{:?}", c.tree_ops); assert!(c.tree_ops.iter().any(|op| matches!(op, TreeOp::Move { node, name, .. } if *node == node_old && name == "new.rs"))); assert!(c.tree_ops.iter().any(|op| matches!(op, TreeOp::Move { node, name, .. } if *node == node_img && name == "img.bin"))); let snap = repo.load_state(&[h2]).unwrap().materialize(); assert_eq!(snap.files["lib/new.rs"].node, node_old, "the node survives the rename"); assert!(matches!(&snap.files["lib/new.rs"].content, FileContent::Text { bytes, .. } if bytes == b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n")); assert!(!snap.files.contains_key("src/old.rs") && !snap.files.contains_key("src")); assert!(render_change(&c, &repo.load_state(&[h1]).unwrap(), &repo.load_state(&[h1]).unwrap().materialize()).contains("R ")); // A fresh checkout reproduces the tree. let other = tempfile::tempdir().unwrap(); let clone = Repo::clone(&root.to_string_lossy(), other.path()).unwrap(); assert_eq!(fs::read(other.path().join("lib/new.rs")).unwrap(), b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n"); assert!(!other.path().join("src").exists()); assert!(!clone.snapshot("status").unwrap().changed); // Similarity is line based, byte weighted. assert_eq!(similarity(b"a\nb\n", b"a\nb\n"), 100); assert_eq!(similarity(b"a\nb\nc\nd\n", b"a\nb\nx\ny\n"), 50); assert_eq!(similarity(b"hello\n", b"world\n"), 0); assert_eq!(similarity(b"", b"x\n"), 0); } #[test] fn git_fetch_and_push_round_trip() { hermetic(); if std::process::Command::new("git").arg("--version").output().is_err() { eprintln!("git not installed; skipping"); return; } let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let git_in = |d: &Path, args: &[&str]| -> String { let out = std::process::Command::new("git") .arg("-C") .arg(d) .args(["-c", "user.name=Grace", "-c", "user.email=grace@example.org", "-c", "commit.gpgsign=false"]) .args(args) .output() .unwrap(); assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr)); String::from_utf8_lossy(&out.stdout).trim().to_string() }; // A bare "remote" and a work tree that pushes to it. let remote = root.join("remote.git"); fs::create_dir_all(&remote).unwrap(); git_in(&remote, &["init", "-q", "--bare", "-b", "main"]); let work = root.join("work"); git_in(root, &["clone", "-q", remote.to_str().unwrap(), work.to_str().unwrap()]); git_in(&work, &["checkout", "-q", "-b", "main"]); write(&work, "README.md", b"# Project\n"); write(&work, "src/lib.rs", b"pub fn a() {}\n"); git_in(&work, &["add", "."]); git_in(&work, &["commit", "-q", "-m", "initial"]); write(&work, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\n"); git_in(&work, &["commit", "-q", "-am", "second"]); git_in(&work, &["push", "-q", "origin", "main"]); let tip1 = git_in(&work, &["rev-parse", "HEAD"]); // Fetch into a fresh oxy repository. let o = root.join("oxy"); let mut repo = Repo::init(&o).unwrap().with_home(root.join("home")); repo.create_identity("Ada", "ada@example.org", IdentityScope::User).unwrap(); let url = remote.to_string_lossy().to_string(); let r = repo.git_fetch(&url, "main").unwrap(); assert_eq!((r.commits, r.heads), (2, 1)); assert_eq!(fs::read(o.join("src/lib.rs")).unwrap(), b"pub fn a() {}\npub fn b() {}\n"); assert!(!repo.snapshot("status").unwrap().changed); assert_eq!(repo.git_fetch(&url, "main").unwrap().commits, 0, "nothing new"); // An oxy change, pushed back: one commit on top of the Git tip. write(&o, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\npub fn c() {}\n"); write(&o, "NOTES.md", b"from oxy\n"); let h_oxy = repo.commit("add c and notes", &[]).unwrap(); let r = repo.git_push(&url, "main").unwrap(); assert_eq!(r.exported, 1, "{r:?}"); git_in(&work, &["pull", "-q", "--ff-only", "origin", "main"]); assert_eq!(git_in(&work, &["log", "--format=%s", "main"]), "add c and notes\nsecond\ninitial"); assert_eq!(git_in(&work, &["rev-parse", "HEAD~1"]), tip1); assert_eq!(fs::read(work.join("NOTES.md")).unwrap(), b"from oxy\n"); assert_eq!(git_in(&work, &["log", "-1", "--format=%an", "main"]), "Ada"); assert!(git_in(&work, &["log", "-1", "--format=%B", "main"]).contains(&format!("Oxy-Hash: {}", h_oxy.to_hex()))); assert_eq!(repo.git_push(&url, "main").unwrap().exported, 0, "already pushed"); // Git moves on; pushing without fetching is refused, fetching brings // the commit in and the working copy follows. write(&work, "README.md", b"# Project\n\nMore.\n"); git_in(&work, &["commit", "-q", "-am", "readme from git"]); git_in(&work, &["push", "-q", "origin", "main"]); let tip2 = git_in(&work, &["rev-parse", "HEAD"]); assert!(repo.git_push(&url, "main").unwrap_err().to_string().contains("fetch")); let r = repo.git_fetch(&url, "main").unwrap(); assert_eq!((r.commits, r.heads), (1, 2), "the Git change and the oxy change commute"); assert_eq!(fs::read(o.join("README.md")).unwrap(), b"# Project\n\nMore.\n"); assert_eq!(fs::read(o.join("NOTES.md")).unwrap(), b"from oxy\n"); assert!(!repo.snapshot("status").unwrap().changed); // Another oxy change pushes on top of Git's newest commit. write(&o, "NOTES.md", b"from oxy\nagain\n"); repo.commit("notes again", &[]).unwrap(); assert_eq!(repo.git_push(&url, "main").unwrap().exported, 1); git_in(&work, &["pull", "-q", "--ff-only", "origin", "main"]); assert_eq!(git_in(&work, &["rev-parse", "HEAD~1"]), tip2); assert_eq!(fs::read(work.join("NOTES.md")).unwrap(), b"from oxy\nagain\n"); assert_eq!(git_in(&work, &["log", "--format=%s", "main"]).lines().count(), 5); }