//! Tests for the repository layer. use super::*; use crate::protocol::SignedPolicy; use crate::repo::{BisectStep, Selection}; /// Point `$OXY_HOME` at a throwaway directory once per test process so no /// test reads or writes the developer's real user configuration or keys. fn hermetic() { static ONCE: std::sync::Once = std::sync::Once::new(); static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); ONCE.call_once(|| { let d = tempfile::tempdir().unwrap(); std::env::set_var("OXY_HOME", d.path()); DIR.set(d).ok(); }); } fn write(root: &Path, rel: &str, content: &[u8]) { let p = root.join(rel); fs::create_dir_all(p.parent().unwrap()).unwrap(); fs::write(p, content).unwrap(); } fn statuses(s: &SnapshotResult) -> Vec<(String, ChangeStatus)> { s.statuses .iter() .map(|p| (p.path.clone(), p.status)) .collect() } #[test] fn init_snapshot_commit_log_undo() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); assert!(Repo::init(root).is_err()); let ops = repo.op_log().unwrap(); assert_eq!(ops.len(), 1); assert_eq!(ops[0].1.command, "oxy init"); // Empty working copy: snapshot records no operation (spec §12.3). let s = repo.snapshot("oxy status").unwrap(); assert!(!s.changed); assert_eq!(repo.op_log().unwrap().len(), 1); write(root, "src/main.rs", b"fn main() {}\n"); write(root, "README.md", b"# Hello\n"); write(root, ".oxyignore", b"/target\n"); write(root, "target/junk.o", b"\0\0\0"); let s = repo.snapshot("oxy status").unwrap(); assert!(s.changed); assert_eq!( statuses(&s), vec![ (".oxyignore".into(), ChangeStatus::Added), ("README.md".into(), ChangeStatus::Added), ("src/main.rs".into(), ChangeStatus::Added), ] ); assert_eq!(repo.op_log().unwrap().len(), 2); // Snapshotting again with nothing changed records no op. let s2 = repo.snapshot("oxy status").unwrap(); assert!(!s2.changed); assert_eq!(s2.wc_hash, s.wc_hash); assert_eq!(repo.op_log().unwrap().len(), 2); let h1 = repo.commit("Initial import", &[]).unwrap(); let c1 = repo.store().get_change(&h1).unwrap(); assert_eq!(c1.kind, ChangeKind::Normal); assert!(c1.deps.is_empty(), "first change has nothing to depend on"); assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]); let (wc_hash, wc) = repo.working_copy().unwrap(); assert_eq!(wc.deps, vec![h1]); assert!(wc.edits.is_empty()); assert!(repo.commit("nothing", &[]).is_err()); // Modify one file, remove another, add a binary file. write( root, "src/main.rs", b"fn main() {\n println!(\"hi\");\n}\n", ); fs::remove_file(root.join("README.md")).unwrap(); write(root, "logo.png", b"\x89PNG\0\0\x01"); let s = repo.snapshot("oxy status").unwrap(); assert_eq!( statuses(&s), vec![ ("README.md".into(), ChangeStatus::Removed), ("logo.png".into(), ChangeStatus::Added), ("src/main.rs".into(), ChangeStatus::Modified), ] ); assert_ne!(s.wc_hash, wc_hash); // The rewritten working copy keeps its ChangeId and depends on h1. assert_eq!(s.wc.change_id, wc.change_id); assert!(s.wc.deps.contains(&h1)); let h2 = repo.commit("Second", &[]).unwrap(); let c2 = repo.store().get_change(&h2).unwrap(); assert_eq!( c2.deps, vec![h1], "everything it touches was introduced by h1" ); assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]); // The materialized head state matches the working copy. let st = repo.load_state(&[h2]).unwrap(); let snap = st.materialize(); assert!(!snap.is_conflicted()); assert_eq!( snap.files.keys().cloned().collect::>(), [".oxyignore", "logo.png", "src", "src/main.rs"] ); assert!( matches!(&snap.files["src/main.rs"].content, FileContent::Text { bytes, .. } if bytes == b"fn main() {\n println!(\"hi\");\n}\n") ); assert!(snap.files["logo.png"].mode.opaque); if let FileContent::Opaque { blobs } = &snap.files["logo.png"].content { assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\x89PNG\0\0\x01"); } else { panic!("logo should be opaque"); } // Log: newest first, working copy included. let log = repo.log().unwrap(); assert_eq!(log.len(), 3); assert!(log[0].is_working_copy); assert_eq!(log[1].hash, h2); assert!(log[1].is_head); assert_eq!(log[2].hash, h1); let d = repo.describe_change(&h2).unwrap(); assert!(d.contains("D README.md"), "{d}"); assert!(d.contains("+ println!(\"hi\");"), "{d}"); assert!(d.contains("-fn main() {}"), "{d}"); // Resolution by prefix. assert_eq!(repo.resolve_change(&c2.change_id.short(6)).unwrap(), h2); assert_eq!( repo.resolve_change(&format!("#{}", h1.short(8))).unwrap(), h1 ); assert!(repo.resolve_change("zzzz").is_err()); // Undo the commit: bookmark and working copy go back. repo.undo().unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]); let s = repo.snapshot("oxy status").unwrap(); assert!( !s.changed, "files on disk still match the pre-commit working copy" ); assert_eq!(s.statuses.len(), 3); } /// Make `main` conflicted: two commits on the same base that edit the same line. fn conflicted_repo() -> (tempfile::TempDir, Repo, ChangeHash, ChangeHash, ChangeHash) { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "f.txt", b"a\nb\nc\n"); let h1 = repo.commit("base", &[]).unwrap(); write(root, "f.txt", b"a\nB\nc\n"); let h2 = repo.commit("upper", &[]).unwrap(); repo.checkout(&[h1], false, "checkout").unwrap(); write(root, "f.txt", b"a\nX\nc\n"); let h3 = repo.commit("ex", &[]).unwrap(); (dir, repo, h1, h2, h3) } /// oxygen#126: a file whose text includes an example of oxy's markers (as /// the spec's §9.5 does) can still have its conflict resolved; a real /// marker left behind is still caught. #[test] fn marker_like_text_does_not_block_a_resolution() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); let example = "```\n<<<<<<< oxy conflict 1 of 2, side 1: change k7m2p9 \"Add retry loop\"\n... lines ...\n======= oxy conflict 1 of 2, side 2: change q3x8w1 \"Refactor client\"\n>>>>>>> oxy conflict 1 of 2 end\n```\n"; write( root, "doc.md", format!("intro\n{example}b\nend\n").as_bytes(), ); let h1 = repo.commit("base", &[]).unwrap(); write( root, "doc.md", format!("intro\n{example}B\nend\n").as_bytes(), ); repo.commit("upper", &[]).unwrap(); repo.checkout(&[h1], false, "checkout").unwrap(); write( root, "doc.md", format!("intro\n{example}X\nend\n").as_bytes(), ); repo.commit("ex", &[]).unwrap(); repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main") .unwrap(); let marked = fs::read_to_string(root.join("doc.md")).unwrap(); assert!(marked.contains("oxy conflict 1 of 1"), "{marked}"); // A real marker left: still caught. write(root, "doc.md", marked.replace("B\n", "BB\n").as_bytes()); let s = repo.snapshot("status").unwrap(); assert_eq!( statuses(&s), vec![("doc.md".into(), ChangeStatus::ConflictEdited)] ); // Resolved, the example kept: a resolution. write( root, "doc.md", format!("intro\n{example}X\nend\n").as_bytes(), ); let s = repo.snapshot("status").unwrap(); assert_eq!( statuses(&s), vec![("doc.md".into(), ChangeStatus::Resolved)] ); let h = repo.commit("resolve", &[]).unwrap(); assert_eq!( repo.store().get_change(&h).unwrap().kind, ChangeKind::Resolution ); assert!(!repo.load_state(&[h]).unwrap().materialize().is_conflicted()); } /// oxygen#121: two changes each adding a line after the same line conflict; /// the markers list the earlier change first, and `oxy resolve --both` /// keeps both lines in that order (or the other with `--reverse`). #[test] fn adjacent_insertions_are_settled_by_keeping_both() { hermetic(); for reverse in [false, true] { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "f.txt", b"a\nb\n"); let h1 = repo.commit("base", &[]).unwrap(); write(root, "f.txt", b"a\nfirst\nb\n"); repo.commit("first", &[]).unwrap(); repo.checkout(&[h1], false, "checkout").unwrap(); write(root, "f.txt", b"a\nsecond\nb\n"); repo.commit("second", &[]).unwrap(); repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main") .unwrap(); let marked = fs::read_to_string(root.join("f.txt")).unwrap(); let (f, s) = ( marked.find("first\n").unwrap(), marked.find("second\n").unwrap(), ); assert!(f < s, "the earlier change's side comes first: {marked}"); let snap = repo.snapshot("status").unwrap(); let node = snap.base_snapshot.files["f.txt"].node; assert_eq!(snap.base.both_sides(&node).iter().flatten().count(), 1); let kept = repo.keep_both(&snap, &[], reverse).unwrap(); assert_eq!( kept, vec![KeptBoth { path: "f.txt".into(), settled: vec![1], identical: vec![], left: vec![] }] ); let want: &[u8] = if reverse { b"a\nsecond\nfirst\nb\n" } else { b"a\nfirst\nsecond\nb\n" }; assert_eq!(fs::read(root.join("f.txt")).unwrap(), want); let s = repo.snapshot("status").unwrap(); assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::Resolved)]); let h = repo.commit("both", &[]).unwrap(); assert_eq!( repo.store().get_change(&h).unwrap().kind, ChangeKind::Resolution ); assert!(!repo.load_state(&[h]).unwrap().materialize().is_conflicted()); } } /// oxygen#145: a file marked `merge=union` has its adjacent insertions /// settled by the checkout itself, both kept, earliest first; the next /// commit records the resolution. Other files keep their markers. #[test] fn union_files_keep_both_sides_on_checkout() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, ".oxyattributes", b"CHANGELOG.md merge=union\n"); write( root, "CHANGELOG.md", b"# Changelog\n## Unreleased\n## 1.0\n", ); write(root, "f.txt", b"a\nb\n"); let h1 = repo.commit("base", &[]).unwrap(); write( root, "CHANGELOG.md", b"# Changelog\n## Unreleased\n- one\n## 1.0\n", ); write(root, "f.txt", b"a\none\nb\n"); repo.commit("one", &[]).unwrap(); repo.checkout(&[h1], false, "checkout").unwrap(); write( root, "CHANGELOG.md", b"# Changelog\n## Unreleased\n- two\n## 1.0\n", ); write(root, "f.txt", b"a\ntwo\nb\n"); repo.commit("two", &[]).unwrap(); let r = repo .checkout(&repo.bookmark_heads().unwrap(), false, "checkout main") .unwrap(); assert!(r.conflicted); assert_eq!(r.kept_both.len(), 1, "{:?}", r.kept_both); assert_eq!(r.kept_both[0].path, "CHANGELOG.md"); assert_eq!( fs::read_to_string(root.join("CHANGELOG.md")).unwrap(), "# Changelog\n## Unreleased\n- one\n- two\n## 1.0\n" ); assert!(has_conflict_markers(&fs::read(root.join("f.txt")).unwrap())); let s = repo.snapshot("status").unwrap(); assert_eq!( statuses(&s), vec![("CHANGELOG.md".into(), ChangeStatus::Resolved)] ); } /// oxygen#146: two bookmarks that resolved the same conflict to the same /// text agree. Merged, their resolutions are a conflict whose sides are /// identical: a checkout keeps one, in any file, and a host settles it so /// too; the next commit records it. #[test] fn identical_resolutions_on_two_bookmarks_settle_by_themselves() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "f.txt", b"a\nx\nb\n"); let base = repo.commit("base", &[]).unwrap(); write(root, "f.txt", b"a\nX1\nb\n"); let c1 = repo.commit("X1", &[]).unwrap(); repo.checkout(&[base], false, "checkout").unwrap(); write(root, "f.txt", b"a\nX2\nb\n"); let c2 = repo.commit("X2", &[]).unwrap(); // Each bookmark merges both and resolves the conflict the same way. repo.checkout(&[c1, c2], false, "checkout").unwrap(); assert!(has_conflict_markers(&fs::read(root.join("f.txt")).unwrap())); write(root, "f.txt", b"a\nX\nb\n"); let r1 = repo.commit("resolve on one", &[]).unwrap(); repo.checkout(&[c1, c2], false, "checkout").unwrap(); write(root, "f.txt", b"a\nX\nb\n"); let r2 = repo.commit("resolve on two", &[]).unwrap(); assert_ne!(r1, r2); let st = repo.load_state(&[r1, r2]).unwrap(); assert!( st.materialize().is_conflicted(), "two resolutions, one place" ); assert_eq!( Repo::union_resolution(&st, &st.materialize()), Some(vec![("f.txt".into(), b"a\nX\nb\n".to_vec())]), "a host settles it" ); let r = repo.checkout(&[r1, r2], false, "checkout").unwrap(); assert!(r.conflicted); assert_eq!(r.kept_both.len(), 1, "{:?}", r.kept_both); assert_eq!( (r.kept_both[0].path.as_str(), &r.kept_both[0].identical), ("f.txt", &vec![1]) ); assert_eq!(fs::read_to_string(root.join("f.txt")).unwrap(), "a\nX\nb\n"); let s = repo.snapshot("status").unwrap(); assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::Resolved)]); let merged = repo.commit("merge", &[]).unwrap(); let st = repo.load_state(&[merged]).unwrap(); assert!(!st.materialize().is_conflicted()); } /// oxygen#145: a host (OxygenHub landing a proposal) settles a state whose /// only conflicts are adjacent insertions in union files; any other /// conflict needs a person. #[test] fn a_host_settles_a_state_whose_conflicts_are_all_union() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, ".oxyattributes", b"*.md merge=union\n"); write(root, "A.md", b"a\nb\n"); write(root, "B.md", b"x\ny\n"); let h1 = repo.commit("base", &[]).unwrap(); write(root, "A.md", b"a\none\nb\n"); write(root, "B.md", b"x\nY1\n"); let h2 = repo.commit("one", &[]).unwrap(); repo.checkout(&[h1], false, "checkout").unwrap(); write(root, "A.md", b"a\ntwo\nb\n"); let h3 = repo.commit("two", &[]).unwrap(); let st = repo.load_state(&[h2, h3]).unwrap(); assert_eq!( Repo::union_resolution(&st, &st.materialize()), Some(vec![("A.md".into(), b"a\none\ntwo\nb\n".to_vec())]) ); // B.md's line replaced two ways: a person decides. repo.checkout(&[h3], false, "checkout").unwrap(); write(root, "B.md", b"x\nY2\n"); let h4 = repo.commit("Y2", &[]).unwrap(); let st = repo.load_state(&[h2, h4]).unwrap(); assert!(st.materialize().is_conflicted()); assert_eq!(Repo::union_resolution(&st, &st.materialize()), None); } /// oxygen#121: two changes that each replaced the same line are not an /// adjacent insertion: keeping both would keep two versions of the line, /// so `--both` leaves the markers, as it does for markers already edited. #[test] fn keeping_both_refuses_replaced_or_edited_lines() { hermetic(); let (dir, repo, _, _, _) = conflicted_repo(); let root = dir.path(); repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main") .unwrap(); let marked = fs::read(root.join("f.txt")).unwrap(); let snap = repo.snapshot("status").unwrap(); let node = snap.base_snapshot.files["f.txt"].node; assert_eq!(snap.base.both_sides(&node), vec![None]); let kept = repo.keep_both(&snap, &["f.txt".into()], false).unwrap(); assert!(kept[0].settled.is_empty()); assert_eq!(kept[0].left[0].0, 1); assert!(kept[0].left[0].1.contains("both sides changed"), "{kept:?}"); assert_eq!(fs::read(root.join("f.txt")).unwrap(), marked); // An adjacent insertion whose lines were edited between the markers. let sides = vec![Some(vec![b"x\n".to_vec(), b"y\n".to_vec()])]; let text = b"a\n<<<<<<< oxy conflict 1 of 1, side 1 of 2: change k \"x\"\nx!\n======= oxy conflict 1 of 1, side 2 of 2: change q \"y\"\ny\n>>>>>>> oxy conflict 1 of 1 end\nb\n"; let (out, settled, left) = super::snapshot::keep_both_text(text, &sides, false); assert_eq!(out, text.to_vec()); assert!(settled.is_empty()); assert!(left[0].1.contains("edited"), "{left:?}"); // Unedited, in either order on disk: kept in the graph's order. let text = b"a\n<<<<<<< oxy conflict 1 of 1, side 1 of 2: change q \"y\"\ny\n======= oxy conflict 1 of 1, side 2 of 2: change k \"x\"\nx\n>>>>>>> oxy conflict 1 of 1 end\nb\n"; let (out, settled, _) = super::snapshot::keep_both_text(text, &sides, false); assert_eq!(out, b"a\nx\ny\nb\n".to_vec()); assert_eq!(settled, vec![1]); } #[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") ); } /// oxygen#118 (Metal Gear): a resolution commits by paths, leaving other /// work uncommitted, so long as it takes every file it resolves; and /// `amend PATHS` folds only those paths into the head. #[test] fn resolutions_and_amends_take_paths() { hermetic(); let (dir, repo, _h1, _h2, _h3) = conflicted_repo(); let root = dir.path(); repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main") .unwrap(); // The conflict resolved, and other work beside it. write(root, "f.txt", b"a\nX\nc\n"); write(root, "notes.txt", b"pending\n"); let s = repo.snapshot("status").unwrap(); assert!(s.resolves); let err = repo .commit_selection_partial("resolve", &[], &Selection::Paths(vec!["notes.txt".into()])) .unwrap_err() .to_string(); assert!( err.contains("takes every file it resolves") && err.contains("f.txt"), "{err}" ); let (h4, partial) = repo .commit_selection_partial("resolve", &[], &Selection::Paths(vec!["f.txt".into()])) .unwrap(); assert!(partial); assert_eq!( repo.store().get_change(&h4).unwrap().kind, ChangeKind::Resolution ); assert!(!repo .load_state(&[h4]) .unwrap() .materialize() .is_conflicted()); let s = repo.snapshot("status").unwrap(); assert_eq!( statuses(&s), vec![("notes.txt".into(), ChangeStatus::Added)], "the other work waits" ); // amend PATHS: only those paths join the head; it keeps its ChangeId. // On the resolution, so the bookmark has one head to amend. let h5 = repo.commit("notes", &[h4]).unwrap(); let id5 = repo.store().get_change(&h5).unwrap().change_id; write(root, "notes.txt", b"pending\nmore\n"); write(root, "other.txt", b"later\n"); let h6 = repo .amend_paths(Some("notes, amended"), &["notes.txt".into()]) .unwrap(); let c6 = repo.store().get_change(&h6).unwrap(); assert_eq!((c6.change_id, c6.message.as_str()), (id5, "notes, amended")); assert_eq!(repo.bookmark_heads().unwrap(), vec![h6]); let snap6 = repo.load_state(&[h6]).unwrap().materialize(); assert!( matches!(&snap6.files["notes.txt"].content, FileContent::Text { bytes, .. } if bytes == b"pending\nmore\n") ); assert!( !snap6.files.contains_key("other.txt"), "other.txt stays out" ); let s = repo.snapshot("status").unwrap(); assert_eq!( statuses(&s), vec![("other.txt".into(), ChangeStatus::Added)] ); } #[test] fn resolution_may_keep_both_sides_or_neither() { hermetic(); let (dir, repo, _h1, _h2, _h3) = conflicted_repo(); let root = dir.path(); repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main") .unwrap(); // Keep both sides in a chosen order. write(root, "f.txt", b"a\nX\nB\nc\n"); let h = repo.commit("both", &[]).unwrap(); let snap = repo.load_state(&[h]).unwrap().materialize(); assert!(!snap.is_conflicted()); assert!( matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nB\nc\n") ); // Undo and instead drop the whole region. repo.undo().unwrap(); write(root, "f.txt", b"a\nc\n"); let h = repo.commit("neither", &[]).unwrap(); let snap = repo.load_state(&[h]).unwrap().materialize(); assert!(!snap.is_conflicted()); assert!( matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nc\n") ); } #[test] fn name_conflict_resolved_by_removing_a_candidate() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "keep.txt", b"k\n"); let h1 = repo.commit("base", &[]).unwrap(); write(root, "new.txt", b"one\n"); repo.commit("one", &[]).unwrap(); repo.checkout(&[h1], false, "checkout").unwrap(); write(root, "new.txt", b"two\n"); repo.commit("two", &[]).unwrap(); let r = repo .checkout(&repo.bookmark_heads().unwrap(), false, "checkout main") .unwrap(); assert!(r.conflicted); assert!( root.join("new.txt.oxy-conflict-1").exists() && root.join("new.txt.oxy-conflict-2").exists() ); assert!(!root.join("new.txt").exists()); // Candidate numbering follows change-hash order, so pick by content: // keep the "two" candidate under the real name and drop the other. let (keep, drop) = if fs::read(root.join("new.txt.oxy-conflict-1")).unwrap() == b"two\n" { (1, 2) } else { (2, 1) }; fs::remove_file(root.join(format!("new.txt.oxy-conflict-{drop}"))).unwrap(); fs::rename( root.join(format!("new.txt.oxy-conflict-{keep}")), root.join("new.txt"), ) .unwrap(); let h = repo.commit("pick two", &[]).unwrap(); assert_eq!( repo.store().get_change(&h).unwrap().kind, ChangeKind::Resolution ); // The resolution does not touch keep.txt, so it does not depend on h1; // the bookmark keeps both as heads and their union is the state. let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect(); assert_eq!(heads, [h1, h].into_iter().collect()); let snap = repo .load_state(&repo.bookmark_heads().unwrap()) .unwrap() .materialize(); assert!(!snap.is_conflicted(), "{:?}", snap.tree.conflicts); assert_eq!( snap.files.keys().cloned().collect::>(), ["keep.txt", "new.txt"] ); assert!( matches!(&snap.files["new.txt"].content, FileContent::Text { bytes, .. } if bytes == b"two\n") ); } #[test] fn identities_and_signed_commits() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"a\n"); let h0 = repo.commit("unsigned", &[]).unwrap(); assert_eq!(repo.verify_change(&h0).unwrap(), SignatureStatus::Unsigned); assert_eq!( repo.store().get_change(&h0).unwrap().author, ObjectRef::ZERO ); let (key, id) = repo .create_identity("Ada", "ada@example.org", IdentityScope::Repo) .unwrap(); assert_eq!(id.hash(), key); assert_eq!(repo.config.author_key, Some(key)); // Config survives reopen; the secret key is found again. let reopened = Repo::open(root).unwrap().with_home(root.join("home")); assert_eq!(reopened.config.author_key, Some(key)); assert_eq!(reopened.secret_key().unwrap().unwrap().0, key); // Default core.sign = seal: commits carry the author but no signature. write(root, "a.txt", b"a\nb\n"); let h1 = repo.commit("authored", &[]).unwrap(); assert_eq!(repo.store().get_change(&h1).unwrap().author, key); assert_eq!(repo.verify_change(&h1).unwrap(), SignatureStatus::Unsigned); // Signing later works too. repo.sign_change(&h1).unwrap(); assert!( matches!(repo.verify_change(&h1).unwrap(), SignatureStatus::Valid { key: k, .. } if k == key) ); repo.config.set("core.sign", "change").unwrap(); repo.save_config().unwrap(); write(root, "a.txt", b"a\nb\nc\n"); let h2 = repo.commit("signed", &[]).unwrap(); assert_eq!( repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, name: "Ada".into(), email: "ada@example.org".into(), sealed: false, revoked: false } ); // A signature by a key that is not the author or a delegate is rejected // when the change names an author; an anonymous change (made before any // identity, author zero) is adopted by whoever signs it (spec §14.4). let other = SecretKey::generate(); let other_id = other.identity("Mallory", "m@example.org", 0); let other_key = repo.store().put_identity(&other_id).unwrap(); write(root, "a.txt", b"a\nb\nc\nd\n"); repo.config.set("core.sign", "seal").unwrap(); let h3 = repo.commit("ada's, unsigned", &[]).unwrap(); let forged = other.sign(Domain::Change, &other_key, &h3); repo.store().put_signature(&h3, &forged).unwrap(); assert!( matches!(repo.verify_change(&h3).unwrap(), SignatureStatus::Invalid(m) if m.contains("neither the author")) ); let adopted = other.sign(Domain::Change, &other_key, &h0); repo.store().put_signature(&h0, &adopted).unwrap(); assert!( matches!(repo.verify_change(&h0).unwrap(), SignatureStatus::Valid { name, .. } if name == "Mallory") ); // Tampering with the stored signature invalidates it. let mut sig = repo.store().signatures(&h2).unwrap().remove(0); sig.ed[0] ^= 1; repo.store().put_signature(&h2, &sig).unwrap(); assert!(matches!( repo.verify_change(&h2).unwrap(), SignatureStatus::Invalid(_) )); // Without the secret key, signing at commit fails cleanly. let no_keys = Repo::open(root).unwrap().with_home(root.join("nowhere")); write(root, "a.txt", b"z\n"); assert!(no_keys.commit("cannot sign", &[]).is_err()); } #[test] fn checkout_moves_the_working_copy() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"one\n"); write(root, "sub/deep/b.txt", b"b\n"); let h1 = repo.commit("first", &[]).unwrap(); write(root, "a.txt", b"two\n"); fs::remove_file(root.join("sub/deep/b.txt")).unwrap(); write(root, "bin.dat", &[0, 1, 2, 3]); let h2 = repo.commit("second", &[]).unwrap(); let r = repo.checkout(&[h1], false, "oxy checkout").unwrap(); assert_eq!( r, CheckoutReport { written: 2, removed: 1, removed_paths: vec!["bin.dat".into()], conflicted: false, notes: vec![], lacks: 1, kept_both: vec![], } ); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n"); assert_eq!(fs::read(root.join("sub/deep/b.txt")).unwrap(), b"b\n"); assert!(!root.join("bin.dat").exists()); let (_, wc) = repo.working_copy().unwrap(); assert_eq!(wc.deps, vec![h1]); assert!( !repo.snapshot("status").unwrap().changed, "checkout leaves a clean working copy" ); // The bookmark still points at h2. assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]); // Dirty working copy refuses without --discard. write(root, "a.txt", b"dirty\n"); assert!(repo.checkout(&[h2], false, "oxy checkout").is_err()); let r = repo .checkout(&[h2], true, "oxy checkout --discard") .unwrap(); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"two\n"); assert_eq!(fs::read(root.join("bin.dat")).unwrap(), [0, 1, 2, 3]); assert!(!root.join("sub").exists(), "empty directories are pruned"); assert_eq!(r.removed, 1); assert!(!repo.snapshot("status").unwrap().changed); // Refuses to write through a symlinked directory. #[cfg(unix)] { fs::remove_dir_all(root.join("sub")).ok(); std::os::unix::fs::symlink("/tmp", root.join("sub")).unwrap(); assert!(repo.checkout(&[h1], true, "oxy checkout").is_err()); } } #[test] fn large_files_are_chunked_and_read_back() { hermetic(); let dir = tempfile::tempdir().unwrap(); let repo = Repo::init(dir.path()).unwrap(); let mut big = vec![0u8; 700 * 1024]; for (i, b) in big.iter_mut().enumerate() { *b = (i as u32).wrapping_mul(2654435761).to_le_bytes()[0]; } let r = repo.store_blob(&big).unwrap(); let blob = Blob::decode(&repo.store().get_typed(&r, ObjectType::Blob).unwrap()).unwrap(); assert!(blob.chunks.len() >= 3, "{}", blob.chunks.len()); assert_eq!(repo.read_blob(&r).unwrap(), big); assert_eq!(Repo::blob_ref(&big), r); } #[test] fn clone_push_pull_with_seals_and_attestations() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_path = root.join("origin"); let origin = Repo::init(&origin_path).unwrap(); let origin_url = origin_path.to_string_lossy().to_string(); // A clones, creates an identity (default core.sign = seal), commits, pushes. let mut a = Repo::clone(&origin_url, &root.join("a")) .unwrap() .with_home(root.join("home-a")); assert_eq!(a.id().unwrap(), origin.id().unwrap()); let (key_a, _) = a .create_identity("Ada", "ada@example.org", IdentityScope::Repo) .unwrap(); write(&root.join("a"), "f.txt", b"a\n"); let h1 = a.commit("first", &[]).unwrap(); let results = a.push("origin", false).unwrap(); origin.refresh().unwrap(); assert!(results[0].ok, "{}", results[0].reason); assert_eq!(origin.bookmark_heads().unwrap(), vec![h1]); // The change was sealed at push and the reference attested. assert!( matches!(origin.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { key, sealed: true, .. } if key == key_a) ); assert_eq!( origin.verify_change(&h1).unwrap(), SignatureStatus::Unsigned ); let serial_of = |att: &SignedAttest| match att.attest.body { AttestBody::Ref { serial, .. } => serial, _ => unreachable!(), }; let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap(); // A bookmark new to the remote starts at the clock (past any deleted // namesake's serial); later updates count up from there. let first = serial_of(&att); assert!(first > 1_000_000_000_000, "{first}"); let again = a.push("origin", false).unwrap(); origin.refresh().unwrap(); assert!(again[0].ok && again[0].reason.contains("up to date")); let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap(); assert_eq!(serial_of(&att), first, "a no-op push does not attest again"); // B clones and sees the file and the seal. let b = Repo::clone(&origin_url, &root.join("b")).unwrap(); assert_eq!(fs::read(root.join("b/f.txt")).unwrap(), b"a\n"); assert_eq!(b.bookmark_heads().unwrap(), vec![h1]); assert!(matches!( b.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { sealed: true, .. } )); // B (no identity) commits and pushes unsigned; the unverified origin accepts. write(&root.join("b"), "f.txt", b"a\nb\n"); let h2 = b.commit("second", &[]).unwrap(); assert!(b.push("origin", false).unwrap()[0].ok); origin.refresh().unwrap(); 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(); origin.refresh().unwrap(); assert!(results[0].ok, "{}", results[0].reason); let mut origin_heads = origin.bookmark_heads().unwrap(); origin_heads.sort(); assert_eq!(origin_heads, want); let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap(); assert_eq!(serial_of(&att), first + 1); // B pulls and ends up identical. let pr = b.pull("origin").unwrap(); assert_eq!(pr.heads, want); assert_eq!(fs::read(root.join("b/g.txt")).unwrap(), b"g\n"); assert!(!b.snapshot("status").unwrap().changed); // Concurrent edits of the same line: the union is a reified conflict. write(&root.join("a"), "f.txt", b"a\nA\n"); a.commit("a-edit", &[]).unwrap(); a.push("origin", false).unwrap(); origin.refresh().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); origin.refresh().unwrap(); 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()); } /// oxygen#87: a binary file given new contents and the executable bit in /// one commit kept the contents and lost the bit. #[cfg(unix)] #[test] fn a_binary_file_changed_and_made_executable_at_once_keeps_both() { use std::os::unix::fs::PermissionsExt; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "tool.exe", b"MZ\0one"); repo.commit("add", &[]).unwrap(); write(root, "tool.exe", b"MZ\0two"); fs::set_permissions(root.join("tool.exe"), fs::Permissions::from_mode(0o755)).unwrap(); let h = repo.commit("update and +x", &[]).unwrap(); let snap = repo.load_state(&[h]).unwrap().materialize(); let f = &snap.files["tool.exe"]; assert!(f.mode.opaque && f.mode.exec, "{:?}", f.mode); assert!( repo.snapshot("status").unwrap().statuses.is_empty(), "nothing left to commit" ); } /// Keyless step 4 (oxygenhub#241): an agent signing with a short-lived /// key whose certificate an issuer gave pushes when the policy grants its /// subject, and a fetch accepts what it attested; an expired certificate, /// or a subject the policy does not grant, may not move the bookmark. #[test] fn a_certified_key_pushes_under_a_subject_grant() { use crate::cert::{Certificate, SignedCertificate}; use crate::identity::Scope; 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.create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); write(&root.join("a"), "a.txt", b"a\n"); a.commit("a", &[]).unwrap(); a.policy_init().unwrap(); assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); let hub_sk = SecretKey::generate(); let hub = hub_sk.identity("hub", "hub@example.org", 1); a.policy_grant_subject( "agent:claude@ada", hub.clone(), Scope::CHANGES | Scope::REFS, None, ) .unwrap(); assert!(a .push("origin", false) .unwrap() .iter() .any(|r| r.name == "policy" && r.ok)); let now = Hlc::now().wall_ms; // An agent repository whose key the hub certified for `subject`, from // `from` to `to`. let agent = |name: &str, subject: &str, from: u64, to: u64| { let mut r = Repo::clone(&url, &root.join(name)) .unwrap() .with_home(root.join(format!("home-{name}"))); let (_, id) = r .create_identity(name, "agent@example.org", IdentityScope::User) .unwrap(); let cert = SignedCertificate::sign( Certificate { key: id, issuer: hub.hash(), subject: subject.into(), claims: vec![], scope: Scope::CHANGES | Scope::REFS, not_before: from, expires: to, grant: None, }, &hub, &hub_sk, ); r.store().put_identity(&hub).unwrap(); r.store().put_certificate(&cert).unwrap(); write(&root.join(name), &format!("{name}.txt"), b"agent work\n"); r.commit("work", &[]).unwrap(); r }; let claude = agent("claude", "agent:claude@ada", now - 60_000, now + 600_000); let r = claude.push("origin", false).unwrap(); assert!(r.iter().all(|x| x.ok), "{r:?}"); // Ada's fetch accepts the bookmark the certified key attested. a.pull("origin").unwrap(); assert!(a.bookmark_heads().unwrap().iter().any(|h| a .store() .get_change(h) .map(|c| c.message == "work") .unwrap_or(false))); // Step 4c: a host with an acceptor key records when it accepted the // certified update and serves it; a fetch checks the certificate at // that time only when the certificate's issuer signed it for exactly // this attestation, else at the attestation's own time. let host = Repo::open(&root.join("origin")) .unwrap() .with_acceptor(hub.clone(), hub_sk.clone()); let (attest_ref, sa) = host.latest_ref_attest("main").unwrap().unwrap(); host.record_acceptance(&attest_ref).unwrap(); let served = host.remote_refs().unwrap(); let acc = served .refs .iter() .find(|r| r.name == "main") .unwrap() .accepted .clone() .expect("served with the refs"); let signer = sa.sigs[0].key; assert_eq!( a.certified_time(&signer, &sa, Some(&acc)), acc.attest.hlc.wall_ms ); assert_eq!(a.certified_time(&signer, &sa, None), sa.attest.hlc.wall_ms); let stranger_sk = SecretKey::generate(); let stranger = stranger_sk.identity("stranger", "s@x", 1); let mut forged = acc.clone(); forged.sigs = vec![stranger_sk.sign(Domain::Attest, &stranger.hash(), &acc.attest.hash())]; assert_eq!( a.certified_time(&signer, &sa, Some(&forged)), sa.attest.hlc.wall_ms, "not the issuer's" ); let mut elsewhere = acc.clone(); elsewhere.attest.body = AttestBody::Accepted { attest: ObjectRef([7; 32]), }; elsewhere.sigs = vec![hub_sk.sign(Domain::Attest, &hub.hash(), &elsewhere.attest.hash())]; assert_eq!( a.certified_time(&signer, &sa, Some(&elsewhere)), sa.attest.hlc.wall_ms, "another attestation's" ); a.pull("origin").unwrap(); // gc keeps the acceptance with the attestation. host.gc(true).unwrap(); assert!(host.acceptance_of(&attest_ref).unwrap().is_some()); let late = agent("late", "agent:claude@ada", now - 1_200_000, now - 600_000); let r = late.push("origin", false).unwrap(); assert!( !r[0].ok && r[0].reason.contains("may not update"), "an expired certificate: {r:?}" ); let other = agent("other", "agent:mallory@ada", now - 60_000, now + 600_000); let r = other.push("origin", false).unwrap(); assert!( !r[0].ok && r[0].reason.contains("may not update"), "a subject not granted: {r:?}" ); } /// A tag made under one short-lived key and pushed under another: its /// signer travels with the push (identity and certificate), so a receiver /// that has never seen that key accepts it. A tag-only push by a keyless /// agent was refused "unknown key" (found releasing OxygenFlow 0.2.0). #[test] fn a_tag_signed_by_an_earlier_short_lived_key_is_pushed() { use crate::cert::{Certificate, SignedCertificate}; use crate::identity::Scope; 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.create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); write(&root.join("a"), "a.txt", b"a\n"); a.commit("a", &[]).unwrap(); a.policy_init().unwrap(); assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); let hub_sk = SecretKey::generate(); let hub = hub_sk.identity("hub", "hub@example.org", 1); a.policy_grant_subject( "agent:claude@ada", hub.clone(), Scope::CHANGES | Scope::REFS, Some("*"), ) .unwrap(); assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); let now = Hlc::now().wall_ms; let mut agent = Repo::clone(&url, &root.join("agent")) .unwrap() .with_home(root.join("home-agent")); agent.store().put_identity(&hub).unwrap(); let certify = |agent: &Repo, id| { let cert = SignedCertificate::sign( Certificate { key: id, issuer: hub.hash(), subject: "agent:claude@ada".into(), claims: vec![], scope: Scope::CHANGES | Scope::REFS, not_before: now - 60_000, expires: now + 600_000, grant: None, }, &hub, &hub_sk, ); agent.store().put_certificate(&cert).unwrap(); }; // The tag, under the first short-lived key. let (first, first_id) = agent .create_identity("claude", "agent@example.org", IdentityScope::User) .unwrap(); certify(&agent, first_id); let heads = agent.bookmark_heads().unwrap(); agent.tag_create("v1", &heads, "first").unwrap(); // The push, under a second one (a later process's ten-minute key). let (second, second_id) = agent .create_identity("claude", "agent@example.org", IdentityScope::User) .unwrap(); assert_ne!(first, second); certify(&agent, second_id); agent .set_user_config("author.signer", &second.to_hex()) .unwrap(); let host = Repo::open(&root.join("origin")).unwrap(); assert!( host.store().get_identity(&first).is_err(), "the host has never seen the tag's key" ); let r = agent.push("origin", false).unwrap(); assert!(r.iter().all(|x| x.ok), "{r:?}"); let host = Repo::open(&root.join("origin")).unwrap(); assert!( host.tag_refs().unwrap().iter().any(|t| t.name == "v1"), "the tag arrived" ); } /// oxygen#129: a short-lived key's tag is checked on a fetch at a time its /// signer did not choose, its issuer's acceptance, never at the time it /// claims nor at the fetch's (when the ten-minute certificate has long /// expired). Without an acceptance it is left out and said so; with one /// inside the certificate's window it is fetched however much later, and /// a host it is pushed on to keeps and serves the acceptance. #[test] fn a_short_lived_key_s_tag_is_fetched_at_its_issuer_s_acceptance() { use crate::cert::{Certificate, SignedCertificate}; use crate::identity::Scope; 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.create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); write(&root.join("a"), "a.txt", b"a\n"); a.commit("a", &[]).unwrap(); a.policy_init().unwrap(); assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); let hub_sk = SecretKey::generate(); let hub = hub_sk.identity("hub", "hub@example.org", 1); a.policy_grant_subject( "agent:claude@ada", hub.clone(), Scope::CHANGES | Scope::REFS, Some("*"), ) .unwrap(); assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); let now = Hlc::now().wall_ms; // An agent whose key the hub certified from `from` to `to`. let agent = |name: &str, from: u64, to: u64| { let mut r = Repo::clone(&url, &root.join(name)) .unwrap() .with_home(root.join(format!("home-{name}"))); r.store().put_identity(&hub).unwrap(); let (_, id) = r .create_identity(name, "agent@example.org", IdentityScope::User) .unwrap(); let cert = SignedCertificate::sign( Certificate { key: id, issuer: hub.hash(), subject: "agent:claude@ada".into(), claims: vec![], scope: Scope::CHANGES | Scope::REFS, not_before: from, expires: to, grant: None, }, &hub, &hub_sk, ); r.store().put_certificate(&cert).unwrap(); r }; // A tag by a key certified now, pushed to a host that records no // acceptance (a path on disk): it lands, but a fetch has no time to // check its certificate at, so it is left out and the fetch goes on. let claude = agent("claude", now - 60_000, now + 600_000); let heads = claude.bookmark_heads().unwrap(); claude.tag_create("v1", &heads, "one").unwrap(); let r = claude.push("origin", false).unwrap(); assert!(r.iter().all(|x| x.ok), "{r:?}"); let b = Repo::clone(&url, &root.join("b")) .unwrap() .with_home(root.join("home-b")); let rep = b.fetch("origin").unwrap(); assert!( rep.skipped .iter() .any(|(n, why)| n == "tags/v1" && why.contains("no time to check its certificate at")), "{rep:?}" ); assert_eq!(b.tag_heads("v1").unwrap(), None); // The host's acceptance, signed by the certificate's issuer, served // with the tag: fetched. let host = Repo::open(&root.join("origin")) .unwrap() .with_acceptor(hub.clone(), hub_sk.clone()); let (v1, _) = host.tag_attest("v1").unwrap().unwrap(); host.record_acceptance(&v1).unwrap(); let served = host.tag_refs().unwrap(); assert!(served .iter() .any(|t| t.name == "v1" && t.accepted.is_some())); let rep = b.fetch("origin").unwrap(); assert!(rep.skipped.is_empty(), "{rep:?}"); assert_eq!(b.tag_heads("v1").unwrap(), Some(heads.clone())); // gc keeps the acceptance with the tag. host.gc(true).unwrap(); assert!(host.acceptance_of(&v1).unwrap().is_some()); // Long after: a key whose certificate expired an hour ago may not // create a tag now. let late = agent("late", now - 7_200_000, now - 3_600_000); late.tag_create("v2", &heads, "two").unwrap(); let r = late.push("origin", false).unwrap(); assert!( r.iter() .any(|x| x.name == "tags/v2" && !x.ok && x.reason.contains("may not create")), "{r:?}" ); // With its issuer's acceptance from inside the window (as a hub gave // it then, passed on to another host), it is taken at that time: by // the host it is pushed to, which keeps it, and by a fresh clone. let (v2, _) = late.tag_attest("v2").unwrap().unwrap(); let acc = Attest { repo: late.id().unwrap(), hlc: Hlc { wall_ms: now - 5_400_000, logical: 0, }, body: AttestBody::Accepted { attest: v2 }, }; let sig = hub_sk.sign(Domain::Attest, &hub.hash(), &acc.hash()); // One by any other key gives no time. let stranger_sk = SecretKey::generate(); let stranger = stranger_sk.identity("stranger", "s@example.org", 1); let forged = stranger_sk.sign(Domain::Attest, &stranger.hash(), &acc.hash()); late.keep_acceptance( &v2, &SignedAttest { attest: acc.clone(), sigs: vec![forged], }, ) .unwrap(); let r = late.push("origin", false).unwrap(); assert!( r.iter().any(|x| x.name == "tags/v2" && !x.ok), "not the issuer's: {r:?}" ); let theirs = SignedAttest { attest: acc, sigs: vec![sig], }; // Kept beside the stranger's: the issuer's is the one sent. late.keep_acceptance(&v2, &theirs).unwrap(); let r = late.push("origin", false).unwrap(); // (Its re-attestation of main is refused: an expired key moves no // bookmark.) assert!(r.iter().any(|x| x.name == "tags/v2" && x.ok), "{r:?}"); let host = Repo::open(&root.join("origin")).unwrap(); let (hv2, _) = host.tag_attest("v2").unwrap().unwrap(); assert_eq!(hv2, v2); assert!(host .store() .index_get_pub("accepted", &v2) .unwrap() .contains(&theirs.attest.hash())); let c = Repo::clone(&url, &root.join("c")) .unwrap() .with_home(root.join("home-c")); assert_eq!(c.tag_heads("v2").unwrap(), Some(heads.clone())); assert_eq!(c.tag_heads("v1").unwrap(), Some(heads)); } /// oxygenhub#266: a short-lived key its issuer certified moves a bookmark /// and signs no change (a keyless agent naming a bookmark). A third /// party's clone gets the key's identity, its certificate and the /// issuer's identity with the fetch, so the policy's subject grant /// authorizes the move and the signature verifies. #[test] fn a_bookmark_moved_by_a_certified_key_alone_is_fetched() { use crate::cert::{Certificate, SignedCertificate}; use crate::identity::Scope; 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.create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); write(&root.join("a"), "a.txt", b"a\n"); a.commit("a", &[]).unwrap(); a.policy_init().unwrap(); assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); let hub_sk = SecretKey::generate(); let hub = hub_sk.identity("hub", "hub@example.org", 1); a.policy_grant_subject( "agent:claude@ada", hub.clone(), Scope::CHANGES | Scope::REFS, None, ) .unwrap(); assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); let now = Hlc::now().wall_ms; let mut agent = Repo::clone(&url, &root.join("agent")) .unwrap() .with_home(root.join("home-agent")); let (key, id) = agent .create_identity("claude", "agent@example.org", IdentityScope::User) .unwrap(); let cert = SignedCertificate::sign( Certificate { key: id, issuer: hub.hash(), subject: "agent:claude@ada".into(), claims: vec![], scope: Scope::CHANGES | Scope::REFS, not_before: now - 60_000, expires: now + 600_000, grant: None, }, &hub, &hub_sk, ); agent.store().put_identity(&hub).unwrap(); agent.store().put_certificate(&cert).unwrap(); let heads = agent.bookmark_heads().unwrap(); agent.bookmark_create("agent-work", Some(&heads)).unwrap(); let r = agent.push_bookmark("origin", "agent-work", false).unwrap(); assert!(r.iter().all(|x| x.ok), "{r:?}"); let host = Repo::open(&root.join("origin")).unwrap(); let (_, sa) = host.latest_ref_attest("agent-work").unwrap().unwrap(); assert_eq!(sa.sigs[0].key, key, "the certified key alone moved it"); // A fetch naming no change brings the certificate and both identities. let bundle = host.bundle_raw_for(&[], None).unwrap(); assert!(bundle.changes.is_empty() && bundle.raw_changes.is_empty()); assert_eq!(bundle.certificates.len(), 1); for k in [key, hub.hash()] { assert!(bundle .objects .iter() .any(|(t, b)| *t == ObjectType::Identity && hash_object(*t, b) == k)); } let c = Repo::clone(&url, &root.join("c")).unwrap(); let tracked = c.view().unwrap().remotes["origin"].clone(); assert_eq!( tracked.get("agent-work"), host.view().unwrap().bookmarks.get("agent-work") ); assert!(c.store().certificate_of(&key).unwrap().is_some()); } /// Trusted time (oxygenhub#240, spec §14.8): a fetched attestation by a /// short-lived key is judged at its integrated time, the earliest time a /// pinned witness cosigned a checkpoint holding it. A certificate valid /// then but expired now passes; a witness this user does not trust gives /// no time (the earlier rule, said so); a backdated attestation is noted; /// an answer without the new fields is judged as before. #[test] fn a_fetched_attestation_is_judged_at_its_integrated_time() { use super::transparency::LogCheck; use crate::cert::{Certificate, SignedCertificate}; use crate::identity::Scope; use crate::protocol::{Integration, Logged, RefsResponse, RemoteRef}; use crate::tlog::{self, Checkpoint, Entry, Hash, Integrated, Note}; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut a = Repo::init(&root.join("a")) .unwrap() .with_home(root.join("home-a")); a.create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); write(&root.join("a"), "a.txt", b"a\n"); a.commit("a", &[]).unwrap(); a.policy_init().unwrap(); let issuer_sk = SecretKey::generate(); let issuer = issuer_sk.identity("hub", "hub@example.org", 1); a.policy_grant_subject( "agent:claude@ada", issuer.clone(), Scope::CHANGES | Scope::REFS, None, ) .unwrap(); let p = a.current_policy().unwrap().unwrap().1.policy; // An agent key the issuer certified for ten minutes two hours ago. let now = Hlc::now().wall_ms; let t0 = now - 2 * 3_600_000; let agent_sk = SecretKey::generate(); let agent = agent_sk.identity("claude", "agent@example.org", t0); let cert = SignedCertificate::sign( Certificate { key: agent.clone(), issuer: issuer.hash(), subject: "agent:claude@ada".into(), claims: vec![], scope: Scope::CHANGES | Scope::REFS, not_before: t0, expires: t0 + 600_000, grant: None, }, &issuer, &issuer_sk, ); a.store().put_identity(&agent).unwrap(); a.store().put_certificate(&cert).unwrap(); let signer = agent.hash(); let attested = |claimed: u64| { let attest = Attest { repo: a.id().unwrap(), hlc: Hlc { wall_ms: claimed, logical: 0, }, body: AttestBody::Ref { name: "main".into(), old_state: ObjectRef([1; 32]), new_state: ObjectRef([2; 32]), serial: 1, }, }; let sig = agent_sk.sign(Domain::Attest, &signer, &attest.hash()); SignedAttest { attest, sigs: vec![sig], } }; // The hub's log: eight entries, the attestation's at 3, the latest // checkpoint of 8, and one of 4 that witnesses cosigned. let log_sk = SecretKey::generate(); let log_key = log_sk.identity("hub.example", "hub@example.org", 1); let origin = "hub.example/log"; let hub = "https://hub.example"; let url = "https://hub.example/ada/a"; let w_sk = SecretKey::generate(); let w = w_sk.identity("w", "w@example.org", 1); let stranger_sk = SecretKey::generate(); let stranger = stranger_sk.identity("s", "s@example.org", 1); let sa = attested(t0 + 300_000); let entry_of = |i: u64| { let (kind, object) = if i == 3 { ("ref", sa.attest.hash()) } else { ("seal", ObjectRef([i as u8; 32])) }; Entry { kind: kind.into(), repo: sa.attest.repo, object, signers: vec![signer], received: t0 + 300_000, } .encode() }; let mut stored: Vec = Vec::new(); for i in 0..8 { let add = tlog::stored_hashes_for_leaf(i, tlog::leaf_hash(&entry_of(i)), &stored).unwrap(); stored.extend(add); } let note_of = |n: u64, cosigners: &[(&str, &Identity, &SecretKey, u64)]| { let mut note = Checkpoint { origin: origin.into(), size: n, root: tlog::tree_hash(n, &stored).unwrap(), extensions: vec![], } .sign(&log_key, &log_sk) .unwrap(); for (name, id, sk, t) in cosigners { note.sigs .extend(tlog::cosign(¬e.text, name, id, sk, *t).unwrap()); } note.encode() }; let latest = note_of(8, &[]); let witnessed_at = (t0 + 360_000) / 1000; let cosigned = note_of( 4, &[ ("w.example", &w, &w_sk, witnessed_at), ("s.example", &stranger, &stranger_sk, 1), ], ); let answer = |integration: Vec, witnesses: Vec<(String, Vec)>| RefsResponse { refs: vec![RemoteRef { name: "main".into(), state: ObjectRef([2; 32]), heads: vec![], attestation: Some(sa.clone()), last_serial: None, accepted: None, logged: Some(Logged { index: 3, entry: entry_of(3), proof: tlog::prove_inclusion(8, 3, &stored).unwrap(), }), integration, }], log: Some(latest.clone()), witnesses, ..RefsResponse::default() }; let proof4 = tlog::prove_inclusion(4, 3, &stored).unwrap(); let served = vec![Integration { note: cosigned.clone(), proof: proof4.clone(), }]; let listed = vec![("w.example".to_string(), w.encode())]; // This user saw the hub's log before (pinned, so nothing is fetched). a.remember_log(hub, &log_key, &Note::parse(&latest).unwrap()) .unwrap(); // The witness is pinned on first sight, silently; the stranger's // earlier cosignature counts for nothing. let resp = answer(served.clone(), listed.clone()); let lc = a.check_log(url, None, &resp).unwrap(); assert!(lc.notes.is_empty(), "{:?}", lc.notes); let t = lc.time_of("main").unwrap(); assert_eq!((t.time, t.witness.as_str()), (witnessed_at, "w.example")); // The certificate expired an hour and fifty minutes ago, but covered // the integrated time: accepted, and nothing to say. assert!(cert.cert.expires < now); let mut notes = Vec::new(); a.authorize_in_time(&p, "origin", &resp.refs[0], &sa, &signer, &lc, &mut notes) .unwrap(); assert!(notes.is_empty(), "{notes:?}"); // Only the stranger cosigned: no witnessed time, so the earlier rule // (the attestation's own time, inside the window), said so. let lonely = vec![Integration { note: note_of(4, &[("s.example", &stranger, &stranger_sk, 1)]), proof: proof4.clone(), }]; let resp = answer(lonely, vec![]); let lc = a.check_log(url, None, &resp).unwrap(); assert!(lc .time_of("main") .unwrap_err() .contains("no witness this user trusts")); let mut notes = Vec::new(); a.authorize_in_time(&p, "origin", &resp.refs[0], &sa, &signer, &lc, &mut notes) .unwrap(); assert!( notes.len() == 1 && notes[0].contains("not at a witnessed time"), "{notes:?}" ); // A witness listed later under the pinned name with another key is // pinned with a note, as a new log key is. let resp = answer( served.clone(), vec![("w.example".into(), stranger.encode())], ); let lc = a.check_log(url, None, &resp).unwrap(); assert!( lc.notes.iter().any(|n| n.contains("another key")), "{:?}", lc.notes ); assert!( lc.time_of("main").is_err(), "w's cosignature is by its old key" ); a.check_log(url, None, &answer(served.clone(), listed.clone())) .unwrap(); // An old peer's answer: no log, no witnesses, no integration. Judged // as before, silently. let mut old = answer(vec![], vec![]); old.log = None; old.refs[0].logged = None; let lc = a.check_log(url, None, &old).unwrap(); assert!(!lc.kept && lc.notes.is_empty()); let mut notes = Vec::new(); a.authorize_in_time(&p, "origin", &old.refs[0], &sa, &signer, &lc, &mut notes) .unwrap(); assert!(notes.is_empty(), "{notes:?}"); // Judged at given integrated times. let at = |secs_after_t0: u64| { let mut times = std::collections::HashMap::new(); times.insert( "main".to_string(), Ok(Integrated { time: (t0 + secs_after_t0 * 1000) / 1000, witness: "w.example".into(), size: 4, }), ); LogCheck { kept: true, times, ..LogCheck::default() } }; let judged = |sa: &SignedAttest, lc: &LogCheck| { let mut notes = Vec::new(); a.authorize_in_time(&p, "origin", &old.refs[0], sa, &signer, lc, &mut notes) .map(|_| notes) }; // Claimed inside the window, first witnessed an hour later, after // the certificate expired: backdated, or held back. Accepted by the // earlier rule while logs are new, with both said. let notes = judged(&sa, &at(3600)).unwrap(); assert!( notes .iter() .any(|n| n.contains("had lapsed when witness w.example")) && notes .iter() .any(|n| n.contains("55 minutes before witness")), "{notes:?}" ); // Claimed after the certificate expired, but witnessed inside its // window: the clock the signer did not choose decides. The gap is // still noted. let fast = attested(now); let notes = judged(&fast, &at(400)).unwrap(); assert!( notes.len() == 1 && notes[0].contains("after witness w.example saw it"), "{notes:?}" ); // Claimed and witnessed after it expired: refused, as before. assert!(judged(&fast, &at(3600)).is_err()); // A gap within ten minutes is nothing to note. assert!(judged(&sa, &at(500)).unwrap().is_empty()); } /// Keyless step 5 (oxygenhub#241): an agent's fresh key, certified for a /// subject the author granted, seals changes in the author's name; with /// no grant, a stranger's, or one that had ended, it is neither the /// author nor a delegate. /// oxygen#135: an owner renews or rotates with every delegation, and a /// repository may name the newest key as the author; the agent's grant by /// the earlier key still counts, but not one that key made after. #[test] fn a_grant_outlives_its_owner_s_rotation() { use crate::cert::{Certificate, Grant, SignedCertificate, SignedGrant}; use crate::identity::Scope; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut a = Repo::init(&root.join("a")) .unwrap() .with_home(root.join("home-a")); let (old, _) = a .create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); let (_, old_sk) = a.secret_key().unwrap().unwrap(); let old_id = a.identity(&old).unwrap(); let hub_sk = SecretKey::generate(); let hub = hub_sk.identity("hub", "hub@example.org", 1); let now = Hlc::now().wall_ms; let grant = |from: u64| { SignedGrant::sign( Grant { grantor: old, subject: "agent:claude@ada".into(), issuer: hub.hash(), scope: Scope::CHANGES | Scope::SEAL, not_before: from, expires: now + 86_400_000, }, &old_id, &old_sk, ) }; let before = grant(now - 60_000); let after = grant(now + 60_000); let rot = a.renew_identity(None).unwrap(); assert_ne!(rot.new, old); let agent = |name: &str, g: SignedGrant| { let mut r = Repo::init(&root.join(name)) .unwrap() .with_home(root.join(format!("home-{name}"))); let (key, id) = r .create_identity(name, "agent@example.org", IdentityScope::User) .unwrap(); for i in [&old_id, &rot.identity, &hub] { r.store().put_identity(i).unwrap(); } r.set_user_config("author.key", &rot.new.to_hex()).unwrap(); r.set_user_config("author.signer", &key.to_hex()).unwrap(); let cert = SignedCertificate::sign( Certificate { key: id, issuer: hub.hash(), subject: "agent:claude@ada".into(), claims: vec![], scope: Scope::CHANGES | Scope::REFS | Scope::SEAL, not_before: now - 60_000, expires: now + 600_000, grant: Some(g), }, &hub, &hub_sk, ); r.store().put_certificate(&cert).unwrap(); write(&root.join(name), "work.txt", b"agent work\n"); let h = r.commit("work", &[]).unwrap(); assert_eq!(r.store().get_change_meta(&h).unwrap().author, rot.new); r.seal(&[h]).unwrap(); (r, h) }; let (r, h) = agent("kept", before); assert!( matches!( r.authenticate_change(&h).unwrap(), SignatureStatus::Valid { .. } ), "{:?}", r.authenticate_change(&h).unwrap() ); let (r, h) = agent("late", after); assert!( matches!(r.authenticate_change(&h).unwrap(), SignatureStatus::Invalid(e) if e.contains("neither")), "a grant the old key made after the rotation: {:?}", r.authenticate_change(&h).unwrap() ); } #[test] fn a_certified_key_acts_for_its_author_under_a_grant() { use crate::cert::{Certificate, Grant, SignedCertificate, SignedGrant}; use crate::identity::{Identity, Scope}; 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 (ada, _) = a .create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); write(&root.join("a"), "a.txt", b"a\n"); a.commit("a", &[]).unwrap(); a.policy_init().unwrap(); assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); let hub_sk = SecretKey::generate(); let hub = hub_sk.identity("hub", "hub@example.org", 1); a.policy_grant_subject( "agent:claude@ada", hub.clone(), Scope::CHANGES | Scope::REFS, None, ) .unwrap(); assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); let (_, ada_sk) = a.secret_key().unwrap().unwrap(); let ada_id = a.identity(&ada).unwrap(); let now = Hlc::now().wall_ms; let grant = |by: &Identity, sk: &SecretKey, from: u64, to: u64| { SignedGrant::sign( Grant { grantor: by.hash(), subject: "agent:claude@ada".into(), issuer: hub.hash(), scope: Scope::CHANGES | Scope::SEAL, not_before: from, expires: to, }, by, sk, ) }; // Ada's work as an agent runs it: Ada the author, a fresh key the hub // certified (with `g` inside) the signer. let agent = |name: &str, g: Option| { let mut r = Repo::clone(&url, &root.join(name)) .unwrap() .with_home(root.join(format!("home-{name}"))); let (key, id) = r .create_identity(name, "agent@example.org", IdentityScope::User) .unwrap(); r.set_user_config("author.key", &ada.to_hex()).unwrap(); r.set_user_config("author.signer", &key.to_hex()).unwrap(); let cert = SignedCertificate::sign( Certificate { key: id, issuer: hub.hash(), subject: "agent:claude@ada".into(), claims: vec![], scope: Scope::CHANGES | Scope::REFS | Scope::SEAL, not_before: now - 60_000, expires: now + 600_000, grant: g, }, &hub, &hub_sk, ); r.store().put_identity(&hub).unwrap(); r.store().put_certificate(&cert).unwrap(); write(&root.join(name), &format!("{name}.txt"), b"agent work\n"); let h = r.commit("work", &[]).unwrap(); assert_eq!(r.store().get_change_meta(&h).unwrap().author, ada); (r, key, h) }; let (claude, key, h) = agent( "claude", Some(grant(&ada_id, &ada_sk, now - 60_000, now + 86_400_000)), ); let r = claude.push("origin", false).unwrap(); assert!(r.iter().all(|x| x.ok), "{r:?}"); let host = Repo::open(&root.join("origin")).unwrap(); assert!( matches!( host.authenticate_change(&h).unwrap(), SignatureStatus::Valid { key: k, sealed: true, name, .. } if k == key && name == "agent:claude@ada" ), "named by its certificate's subject" ); a.pull("origin").unwrap(); assert!(matches!( a.authenticate_change(&h).unwrap(), SignatureStatus::Valid { key: k, .. } if k == key )); let (none, _, _) = agent("none", None); let r = none.push("origin", false).unwrap(); assert!(r.iter().all(|x| !x.ok), "no grant: {r:?}"); let mallory_sk = SecretKey::generate(); let mallory = mallory_sk.identity("Mallory", "m@example.org", 1); let (stranger, _, _) = agent( "stranger", Some(grant(&mallory, &mallory_sk, now - 60_000, now + 600_000)), ); let r = stranger.push("origin", false).unwrap(); assert!(r.iter().all(|x| !x.ok), "a stranger's grant: {r:?}"); let (ended, _, eh) = agent( "ended", Some(grant(&ada_id, &ada_sk, now - 7_200_000, now - 3_600_000)), ); let r = ended.push("origin", false).unwrap(); assert!(r.iter().all(|x| !x.ok), "an ended grant: {r:?}"); assert!(matches!( ended.authenticate_change(&eh).unwrap(), SignatureStatus::Invalid(e) if e.contains("grant did not cover") )); } #[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); origin.refresh().unwrap(); // 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(); origin.refresh().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(); origin.refresh().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(); origin.refresh().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(); origin.refresh().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(); origin.refresh().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(); origin.refresh().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); } /// oxygen#66: a gc says what it is doing, phase by phase. #[test] fn gc_reports_its_phases() { hermetic(); let dir = tempfile::tempdir().unwrap(); let repo = Repo::init(dir.path()).unwrap(); for i in 0..3 { write( dir.path(), &format!("f{i}.txt"), format!("{i}\n").as_bytes(), ); repo.commit(&format!("c{i}"), &[]).unwrap(); } let mut phases = Vec::new(); repo.gc_with(true, &mut |p| phases.push(p.to_string())) .unwrap(); assert_eq!(phases[0], "finding what is reachable", "{phases:?}"); assert!( phases.iter().any(|p| p.starts_with("pruning")), "{phases:?}" ); assert!( phases.iter().any(|p| p.starts_with("packing 0 of ")), "{phases:?}" ); assert!( phases .iter() .any(|p| p.starts_with("writing the index of ")), "{phases:?}" ); } #[test] fn gc_packs_objects_and_history_still_works() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); repo.create_identity("Ada", "a@b", IdentityScope::User) .unwrap(); repo.set_user_config("core.sign", "change").unwrap(); write(root, "a.txt", b"one\n"); let h1 = repo.commit("one", &[]).unwrap(); write(root, "a.txt", b"two\n"); write(root, "big.bin", &vec![0u8; 100_000]); let h2 = repo.commit("two", &[]).unwrap(); // Several snapshots leave superseded working-copy versions behind. for i in 0..5 { write(root, "a.txt", format!("two\n{i}\n").as_bytes()); repo.snapshot("status").unwrap(); } write(root, "a.txt", b"two\n"); repo.snapshot("status").unwrap(); let before = repo.store().list().unwrap(); assert!(repo.store().loose_count().unwrap() > 0); // With the default retention every operation's view is a root: nothing prunes. let r = repo.gc(true).unwrap(); assert_eq!(r.packed.objects, before.len()); assert_eq!(r.pruned.objects, 0); assert_eq!(repo.store().loose_count().unwrap(), 0); assert_eq!(repo.store().list().unwrap(), before); // With zero retention only the current view is a root: superseded // working copies go, everything reachable stays. repo.config.set("core.op-retention", "0").unwrap(); repo.save_config().unwrap(); let mut repo = Repo::open(root).unwrap().with_home(root.join("home")); let r = repo.gc(true).unwrap(); assert!(r.pruned.objects >= 5, "{r:?}"); let after = repo.store().list().unwrap(); assert!(after.len() < before.len()); assert!(after.contains(&h1) && after.contains(&h2)); for (_, op) in repo.op_log().unwrap() { assert!(!op.command.is_empty(), "operation objects are all kept"); } // Everything still works from the pruned, packed store. assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]); assert!(matches!( repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { .. } )); assert_eq!(repo.log().unwrap().len(), 3); repo.checkout(&[h1], false, "checkout").unwrap(); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n"); repo.checkout(&[h2], false, "checkout").unwrap(); assert_eq!(fs::read(root.join("big.bin")).unwrap().len(), 100_000); assert!(!repo.snapshot("status").unwrap().changed); // Grace period defers pruning of young garbage. write(root, "a.txt", b"three\n"); repo.commit("three", &[]).unwrap(); let r = repo.gc(false).unwrap(); assert_eq!(r.pruned.objects, 0); assert!(r.pruned.deferred > 0); let r = repo.gc(true).unwrap(); assert!(r.pruned.objects > 0); assert_eq!(repo.log().unwrap().len(), 4); // Restoring the default retention makes the old views roots again; the // working copies they name were pruned, which gc tolerates. repo.config.set("core.op-retention", "90").unwrap(); repo.save_config().unwrap(); let repo = Repo::open(root).unwrap().with_home(root.join("home")); let r = repo.gc(false).unwrap(); assert_eq!(r.pruned.objects, 0); assert_eq!(repo.log().unwrap().len(), 4); } #[test] fn describe_amend_abandon_heads() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); repo.create_identity("Ada", "a@b", IdentityScope::User) .unwrap(); repo.set_user_config("core.sign", "change").unwrap(); write(root, "a.txt", b"one\n"); let h1 = repo.commit("first", &[]).unwrap(); write(root, "a.txt", b"one\ntwo\n"); let h2 = repo.commit("secnod", &[]).unwrap(); let id2 = repo.store().get_change(&h2).unwrap().change_id; // describe: same ChangeId, new hash, bookmark and working copy follow. let h2b = repo.describe(&h2, "second").unwrap(); assert_ne!(h2b, h2); let c = repo.store().get_change(&h2b).unwrap(); assert_eq!( (c.change_id, c.message.as_str(), c.deps.clone()), (id2, "second", vec![h1]) ); assert_eq!(repo.bookmark_heads().unwrap(), vec![h2b]); assert!(matches!( repo.verify_change(&h2b).unwrap(), SignatureStatus::Valid { .. } )); let s = repo.snapshot("status").unwrap(); assert!(s.statuses.is_empty(), "{:?}", s.statuses); assert_eq!( repo.resolve_change(&id2.short(8)).unwrap(), h2b, "the old object is no longer reachable from the log" ); // Describing a non-head rewrites its dependents (h2b → h2c') and the working copy. let h1b = repo.describe(&h1, "first, described").unwrap(); assert_ne!(h1b, h1); let heads = repo.bookmark_heads().unwrap(); assert_eq!(heads.len(), 1); let top = repo.store().get_change(&heads[0]).unwrap(); assert_eq!((top.change_id, top.deps.clone()), (id2, vec![h1b])); assert!( matches!( repo.verify_change(&heads[0]).unwrap(), SignatureStatus::Valid { .. } ), "rewritten own change is re-signed" ); let (_, wc) = repo.working_copy().unwrap(); assert_eq!( wc.deps, vec![heads[0]], "the working copy follows the rewrite" ); assert!(!repo.snapshot("status").unwrap().changed); let snap = repo.load_state(&heads).unwrap().materialize(); assert!( matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n") ); // amend: fold an edit into the head; the result is one change from h1. write(root, "a.txt", b"one\ntwo\nthree\n"); write(root, "b.txt", b"b\n"); let h2c = repo.amend(None).unwrap(); let c = repo.store().get_change(&h2c).unwrap(); assert_eq!(c.change_id, id2); assert_eq!(c.message, "second"); assert_eq!(repo.bookmark_heads().unwrap(), vec![h2c]); assert_eq!( repo.log().unwrap().len(), 3, "working copy, amended head, first" ); let snap = repo.load_state(&[h2c]).unwrap().materialize(); assert!( matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\nthree\n") ); assert!(snap.files.contains_key("b.txt")); assert!(!repo.snapshot("status").unwrap().changed); assert!( repo.amend(None).is_err(), "clean working copy and no message" ); let h2d = repo.amend(Some("second, amended")).unwrap(); assert_eq!( repo.store().get_change(&h2d).unwrap().message, "second, amended" ); // abandon: the head goes, its content stays as uncommitted work. repo.abandon(&h2d).unwrap(); assert_eq!( repo.bookmark_heads().unwrap(), vec![h1b], "the described first change is the head again" ); let s = repo.snapshot("status").unwrap(); let paths: Vec<_> = s .statuses .iter() .map(|p| (p.path.as_str(), p.status)) .collect(); assert_eq!( paths, vec![ ("a.txt", ChangeStatus::Modified), ("b.txt", ChangeStatus::Added) ] ); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\ntwo\nthree\n"); let h3 = repo.commit("recommitted", &[]).unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![h3]); // undo brings the abandoned head back. repo.undo().unwrap(); repo.undo().unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![h2d]); // Abandoning a non-head whose dependent references its atoms reifies // the references (here: a.txt vanishes with it, so the dependent's edit // of a.txt is dropped with a note) instead of refusing; undo restores it. let base = repo.bookmark_heads().unwrap()[0]; let base_change = repo.store().get_change(&base).unwrap(); let h1_now = base_change.deps[0]; let notes = repo.abandon(&h1_now).unwrap(); assert!( !notes.is_empty(), "the dependent's edit of a.txt was reified" ); let heads = repo.bookmark_heads().unwrap(); let after = repo.load_state(&heads).unwrap().materialize(); assert!( !after.files.contains_key("a.txt") && after.files.contains_key("b.txt"), "{:?}", after.files.keys() ); repo.undo().unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![base]); write(root, "c.txt", b"c\n"); let hc = repo.commit("c", &[]).unwrap(); write(root, "d.txt", b"d\n"); let hd = repo.commit("d", &[hc]).unwrap(); assert_eq!(repo.store().get_change(&hd).unwrap().deps, vec![hc]); repo.abandon(&hc).unwrap(); let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect(); assert!(!heads.contains(&hc)); let d_now = heads .iter() .find(|h| repo.store().get_change(h).unwrap().message == "d") .copied() .unwrap(); assert!( repo.store().get_change(&d_now).unwrap().deps.is_empty(), "d inherited c's (empty) dependencies" ); let s = repo.snapshot("status").unwrap(); assert_eq!( s.statuses .iter() .map(|p| p.path.as_str()) .collect::>(), vec!["c.txt"], "c's content is now uncommitted work" ); } #[test] fn git_export_mirrors_history() { hermetic(); if std::process::Command::new("git") .arg("--version") .output() .is_err() { eprintln!("git not installed; skipping"); return; } let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(&root.join("r")) .unwrap() .with_home(root.join("home")); repo.create_identity("Ada Lovelace", "ada@example.org", IdentityScope::User) .unwrap(); let r = root.join("r"); write(&r, "README.md", b"hello\n"); write(&r, "src/main.rs", b"fn main() {}\n"); let h1 = repo.commit("first", &[]).unwrap(); write(&r, "src/main.rs", b"fn main() { run(); }\n"); write(&r, "logo.png", b"\x89PNG\0\0"); let h2 = repo.commit("second", &[]).unwrap(); let git_dir = root.join("mirror.git"); let rep = repo.git_export(&git_dir).unwrap(); assert_eq!((rep.exported, rep.skipped, rep.heads), (2, 0, 1)); let git = |args: &[&str]| -> String { let out = std::process::Command::new("git") .arg("-C") .arg(&git_dir) .args(args) .output() .unwrap(); assert!( out.status.success(), "{}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_string() }; assert_eq!(git(&["log", "--format=%s", "main"]), "second\nfirst"); assert_eq!(git(&["show", "main:src/main.rs"]), "fn main() { run(); }"); assert_eq!(git(&["show", "main:README.md"]), "hello"); let body = git(&["log", "-1", "--format=%B", "main"]); assert!( body.contains(&format!("Oxy-Hash: {}", h2.to_hex())), "{body}" ); assert!(body.contains("Change-Id: "), "{body}"); assert_eq!( git(&["log", "-1", "--format=%an <%ae>", "main"]), "Ada Lovelace " ); assert_eq!( git(&["ls-tree", "--name-only", "main"]), "README.md\nlogo.png\nsrc" ); // Re-export is incremental. let rep = repo.git_export(&git_dir).unwrap(); assert_eq!((rep.exported, rep.skipped), (0, 2)); // An independent change makes two heads; the next state is one more // commit on the linear branch whose tree is the union. repo.checkout(&[h1], false, "checkout").unwrap(); write(&r, "other.txt", b"o\n"); let h3 = repo.commit("third", &[]).unwrap(); let rep = repo.git_export(&git_dir).unwrap(); assert_eq!((rep.exported, rep.heads), (1, 2)); assert_eq!(git(&["log", "--format=%s", "main"]), "third\nsecond\nfirst"); let parents = git(&["rev-list", "--parents", "-1", "main"]); assert_eq!(parents.split(' ').count(), 2, "one parent: {parents}"); assert_eq!(git(&["show", "main:other.txt"]), "o"); assert_eq!(git(&["show", "main:src/main.rs"]), "fn main() { run(); }"); assert_eq!( git(&["ls-tree", "--name-only", "main"]), "README.md\nlogo.png\nother.txt\nsrc" ); let body = git(&["log", "-1", "--format=%B", "main"]); assert!( body.contains(&format!("Oxy-Hash: {}", h3.to_hex())) && body.contains("Oxy-State: "), "{body}" ); // A rewrite is a new state whose one new change is the rewritten one. repo.describe(&h3, "third, described").unwrap(); let rep = repo.git_export(&git_dir).unwrap(); assert_eq!(rep.exported, 1); assert_eq!( git(&["log", "-1", "--format=%s", "main"]), "third, described" ); assert_eq!(git(&["log", "--format=%s", "main"]).lines().count(), 4); let _ = h2; } #[test] fn git_import_reproduces_trees() { git_import_reproduces_trees_with(None); } /// The same import with every merge rendered in batches of one file /// (oxygen#25): what a merge onto a base of thousands of files takes. #[test] fn git_import_reproduces_trees_in_batches() { git_import_reproduces_trees_with(Some(1)); } fn git_import_reproduces_trees_with(batch: Option) { crate::repo::git::set_merge_batch_for_tests(batch); 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"]); // A branch that edits run.sh, merged keeping main's tree (`-s ours`): // run.sh is the same as in the first parent's tree, but the merged // change touched it, so the importer must compare it, not skip it. git(&["checkout", "-q", "-b", "discarded"]); write(&g, "run.sh", b"#!/bin/sh\necho discarded\n"); git(&["commit", "-q", "-am", "discarded edit"]); git(&["checkout", "-q", "main"]); git(&[ "merge", "-q", "-s", "ours", "-m", "merge discarded, keeping ours", "discarded", ]); let expected_count: usize = git(&["rev-list", "--count", "main"]).parse().unwrap(); let dest = root.join("oxy"); let repo = Repo::init(&dest).unwrap().with_home(root.join("home")); let rep = repo.import_git(&g, "main").unwrap(); assert_eq!(rep.commits, expected_count); assert_eq!(rep.merges, 3); assert_eq!( fs::read(dest.join("run.sh")).unwrap(), b"#!/bin/sh\n", "the ours merge kept main's run.sh" ); let v = repo.verify_git_import(&g, "main").unwrap(); assert_eq!(v.commits, expected_count, "every commit's tree reproduced"); assert!(v.mismatches.is_empty(), "{:?}", v.mismatches); // The working copy equals the Git tree at main. assert_eq!( fs::read(dest.join("src/lib2.rs")).unwrap(), b"pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n" ); assert!(!dest.join("src/lib.rs").exists()); assert_eq!( fs::read(dest.join("README.md")).unwrap(), b"# Hi\n\nMore.\n" ); assert!(!dest.join("logo.png").exists()); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; assert!( fs::metadata(dest.join("run.sh")) .unwrap() .permissions() .mode() & 0o111 != 0 ); } assert!( !repo.snapshot("status").unwrap().changed, "clean after import" ); let heads = repo.bookmark_heads().unwrap(); let st = repo.load_state(&heads).unwrap(); assert!(!st.materialize().is_conflicted()); assert_eq!(st.len(), expected_count); let tip = repo .log() .unwrap() .into_iter() .find(|e| e.change.message.starts_with("rename lib")) .unwrap(); let c = repo.store().get_change(&tip.hash).unwrap(); assert!( c.tree_ops .iter() .any(|op| matches!(op, TreeOp::Move { name, .. } if name == "lib2.rs")), "{:?}", c.tree_ops ); assert!(!c .tree_ops .iter() .any(|op| matches!(op, TreeOp::AddNode { .. }))); // Messages carry Git trailers; the Change-Id trailer was honoured. let log = repo.log().unwrap(); let second = log .iter() .find(|e| e.change.message.starts_with("second")) .unwrap(); assert_eq!( second.change.change_id, ChangeId::from_hex("0123456789abcdef0123456789abcdef").unwrap() ); assert!(second .change .message .contains("Git-Author: Grace ")); let resolve = log .iter() .find(|e| e.change.message.starts_with("resolve clash")) .unwrap(); assert_eq!( resolve.change.kind, ChangeKind::Resolution, "a conflicting Git merge becomes a resolution" ); // Re-importing into another fresh repository gives the same ChangeIds. let repo2 = Repo::init(&root.join("oxy2")) .unwrap() .with_home(root.join("home")); repo2.import_git(&g, "main").unwrap(); // New nodes' identifiers derive from the commit (spec §23), so the // re-import is not just the same ChangeIds but the same changes. assert_eq!( repo2.bookmark_heads().unwrap(), repo.bookmark_heads().unwrap() ); let ids: BTreeSet = repo .log() .unwrap() .iter() .filter(|e| !e.is_working_copy) .map(|e| e.change.change_id) .collect(); let ids2: BTreeSet = repo2 .log() .unwrap() .iter() .filter(|e| !e.is_working_copy) .map(|e| e.change.change_id) .collect(); assert_eq!(ids, ids2); // Round trip back out to Git reproduces the tip tree. let mirror = root.join("mirror.git"); repo.git_export(&mirror).unwrap(); let out = std::process::Command::new("git") .arg("-C") .arg(&mirror) .args(["show", "main:src/lib2.rs"]) .output() .unwrap(); assert_eq!( String::from_utf8_lossy(&out.stdout), "pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n" ); let out = std::process::Command::new("git") .arg("-C") .arg(&mirror) .args(["show", "main:src/lib.rs"]) .output() .unwrap(); assert!( !out.status.success(), "the old path is gone from the export" ); } #[test] fn checkpoints_accelerate_loading_and_stay_correct() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); repo.config.set("core.checkpoint-interval", "5").unwrap(); repo.save_config().unwrap(); let repo = Repo::open(root).unwrap().with_home(root.join("home")); let mut hashes = Vec::new(); for i in 0..12 { write( root, "a.txt", format!( "{}\n", (0..=i) .map(|k| k.to_string()) .collect::>() .join("\n") ) .as_bytes(), ); write(root, &format!("f{i}.txt"), b"x\n"); hashes.push(repo.commit(&format!("c{i}"), &[]).unwrap()); } let (_, cp) = repo .latest_checkpoint("main") .unwrap() .expect("automatic checkpoint"); assert!(cp.count >= 5 && cp.count <= 12, "{}", cp.count); // Loading through the checkpoint equals loading everything. let heads = repo.bookmark_heads().unwrap(); let fast = repo.load_state(&heads).unwrap(); let full = repo.load_state_full(&heads).unwrap(); assert_eq!(fast.len(), full.len()); assert_eq!(fast.heads(), full.heads()); let (a, b) = (fast.materialize(), full.materialize()); assert_eq!( a.files.keys().collect::>(), b.files.keys().collect::>() ); for (p, f) in &a.files { assert_eq!(f.content, b.files[p].content, "{p}"); } assert!(repo.load_state_checkpointed(&heads).unwrap().is_some()); // A head set that does not contain the checkpoint's heads falls back. assert!(repo .load_state_checkpointed(&[hashes[0]]) .unwrap() .is_none()); assert_eq!(repo.load_state(&[hashes[0]]).unwrap().len(), 1); // Everything else keeps working on top of the checkpoint. assert!(!repo.snapshot("status").unwrap().changed); let h = repo.describe(&hashes[3], "c3 described").unwrap(); assert_eq!(repo.store().get_change(&h).unwrap().message, "c3 described"); let (r, n, bytes) = repo.checkpoint().unwrap(); assert!(n >= 12 && bytes > 0); assert_eq!(repo.latest_checkpoint("main").unwrap().unwrap().0, r); write(root, "a.txt", b"after\n"); let h_after = repo.commit("after", &[]).unwrap(); let st = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap(); assert!(st.contains(&h_after)); assert!( matches!(&st.materialize().files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"after\n") ); assert_eq!(repo.log().unwrap().len(), 14); // Checkpoints survive gc. repo.gc(true).unwrap(); assert!(repo.latest_checkpoint("main").unwrap().is_some()); assert!(repo .load_state_checkpointed(&repo.bookmark_heads().unwrap()) .unwrap() .is_some()); } #[test] fn quick_status_for_prompts() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = Repo::init(&root.join("origin")).unwrap(); let url = root.join("origin").to_string_lossy().to_string(); let a = Repo::clone(&url, &root.join("a")) .unwrap() .with_home(root.join("home")); let ops_before = a.op_log().unwrap().len(); let info = a.quick_status().unwrap(); assert_eq!( ( info.bookmark.as_str(), info.heads, info.dirty, info.conflicted, info.ahead, info.behind ), ("main", 0, false, false, Some(0), Some(0)) ); write(&root.join("a"), "f.txt", b"f\n"); assert!(a.quick_status().unwrap().dirty, "a new file is dirty"); assert_eq!( a.op_log().unwrap().len(), ops_before, "quick_status records nothing" ); // A snapshotted but uncommitted change is still dirty. a.snapshot("status").unwrap(); assert!(a.quick_status().unwrap().dirty); a.commit("f", &[]).unwrap(); let info = a.quick_status().unwrap(); assert!(!info.dirty, "clean right after a commit"); assert_eq!((info.ahead, info.behind), (Some(1), Some(0))); assert_eq!( info.format("%b %c%d%x%A%D"), format!("main {}+1", info.change_id) ); assert_eq!(info.format("[%h heads] %a/%B %%"), "[1 heads] 1/0 %"); // Touching a file without changing it is not dirty; changing content of // the same size is. let p = root.join("a/f.txt"); let content = fs::read(&p).unwrap(); fs::write(&p, &content).unwrap(); assert!(!a.quick_status().unwrap().dirty); fs::write(&p, b"g\n").unwrap(); assert!(a.quick_status().unwrap().dirty); fs::write(&p, b"f\n").unwrap(); // Push, then a second clone commits and pushes: we are behind. assert!(a.push("origin", false).unwrap()[0].ok); assert_eq!(a.quick_status().unwrap().ahead, Some(0)); let b = Repo::clone(&url, &root.join("b")) .unwrap() .with_home(root.join("home-b")); write(&root.join("b"), "g.txt", b"g\n"); b.commit("g", &[]).unwrap(); b.push("origin", false).unwrap(); a.fetch("origin").unwrap(); let info = a.quick_status().unwrap(); assert_eq!((info.ahead, info.behind), (Some(0), Some(1))); assert_eq!(info.format("%A%D"), "-1"); let _ = origin; } #[test] fn lazy_blobs_are_fetched_on_first_read() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_dir = root.join("origin"); let origin = Repo::init(&origin_dir) .unwrap() .with_home(root.join("home")); let url = origin_dir.to_string_lossy().to_string(); let big: Vec = (0..200_000u32).map(|i| (i % 251) as u8).collect(); // has NUL bytes: opaque write(&origin_dir, "big.bin", &big); write(&origin_dir, "small.bin", &[0u8, 1, 2]); write(&origin_dir, "t.txt", b"t\n"); let h = origin.commit("blobs", &[]).unwrap(); let big_ref = Repo::blob_ref(&big); // Fetch only (no checkout) with a filter: the big blob stays absent. let a = Repo::init_with_id(&root.join("a"), origin.id().unwrap()) .unwrap() .with_home(root.join("home")); let a = { let mut a = a; a.config.set("remote.origin.url", &url).unwrap(); a.config.set("filter.max-blob", "100K").unwrap(); a.save_config().unwrap(); a }; assert_eq!(a.config.max_blob, Some(100 * 1024)); a.fetch("origin").unwrap(); assert!(a.store().has(&h)); assert!(!a.store().has(&big_ref), "withheld"); assert!( a.store().has(&Repo::blob_ref(&[0u8, 1, 2])), "small blob came along" ); assert_eq!(a.lazy_remote(&big_ref).as_deref(), Some("origin")); // status and commit do not need it: adopt the state, edit a text file, commit. let (op_ref, op) = a.head_op().unwrap(); let mut view = op.view.clone(); view.bookmarks.insert( "main".into(), a.store().put_state(&StateObject::new(vec![h])).unwrap(), ); a.record_op(&[op_ref], "adopt", view).unwrap(); // Checkout reads the blob lazily, which fetches it. a.checkout(&[h], true, "checkout").unwrap(); assert!(a.store().has(&big_ref), "fetched on first read"); assert_eq!(fs::read(root.join("a/big.bin")).unwrap(), big); assert!(a.lazy_remote(&big_ref).is_none(), "no longer registered"); write(&root.join("a"), "t.txt", b"t2\n"); a.commit("text edit", &[]).unwrap(); // clone --max-blob records the filter; the checkout fetches lazily. let b = Repo::clone_with(&url, &root.join("b"), Some(1024), &[]).unwrap(); assert_eq!(b.config.max_blob, Some(1024)); assert_eq!(fs::read(root.join("b/big.bin")).unwrap(), big); assert_eq!(parse_size("2M").unwrap(), 2 << 20); assert!(parse_size("lots").is_err()); } #[test] fn sparse_checkout_materializes_only_chosen_paths() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_dir = root.join("origin"); let origin = Repo::init(&origin_dir) .unwrap() .with_home(root.join("home")); write(&origin_dir, "src/main.rs", b"fn main() {}\n"); write(&origin_dir, "src/lib.rs", b"pub fn a() {}\n"); write(&origin_dir, "docs/guide.md", b"# guide\n"); let logo: Vec = (0..50_000u32).map(|i| (i % 7) as u8).collect(); write(&origin_dir, "docs/logo.png", &logo); write(&origin_dir, "README.md", b"# top\n"); let h1 = origin.commit("tree", &[]).unwrap(); let url = origin_dir.to_string_lossy().to_string(); let a = Repo::clone_with(&url, &root.join("a"), None, &["src".into()]) .unwrap() .with_home(root.join("home")); assert_eq!(a.config.sparse_paths, vec!["src"]); assert_eq!(a.config.max_blob, Some(0), "sparse implies lazy blobs"); assert!(root.join("a/src/main.rs").exists()); assert!(!root.join("a/docs").exists() && !root.join("a/README.md").exists()); assert!( !a.store().has(&Repo::blob_ref(&logo)), "out-of-scope blob never fetched" ); assert!( !a.snapshot("status").unwrap().changed, "out-of-scope files are not removals" ); assert!(!a.quick_status().unwrap().dirty); // Edits in scope commit normally and keep the rest of the tree intact. write( &root.join("a"), "src/lib.rs", b"pub fn a() {}\npub fn b() {}\n", ); let h2 = a.commit("edit lib", &[]).unwrap(); let st = a.load_state(&[h2]).unwrap().materialize(); assert!( st.files.contains_key("docs/guide.md"), "the state still has everything" ); assert!(a.push("origin", false).unwrap()[0].ok); origin.refresh().unwrap(); // 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; } /// #137: a depth-0 clone takes no change, so the blobs of the state's /// binary files came with nothing and the checkout failed ("object … not /// found"); they are fetched with the checkpoint, or left lazy under /// `--max-blob`. #[test] fn a_depth_0_clone_brings_its_binary_files() { 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 png: Vec = (0..4000u32) .map(|i| (i * 7 % 251) as u8) .chain([0, 0xff]) .collect(); // The logo comes with a.txt's first line, and the head edits that line, // so the change that set the logo is below the head, not a head itself. write(&origin_dir, "logo.png", &png); write(&origin_dir, "a.txt", b"first\n"); origin.commit("logo", &[]).unwrap(); write(&origin_dir, "a.txt", b"later\n"); origin.commit("later", &[]).unwrap(); assert_eq!(origin.bookmark_heads().unwrap().len(), 1); let url = origin_dir.to_string_lossy().to_string(); Repo::clone_shallow(&url, &root.join("a"), 0, None, &[]).unwrap(); assert_eq!(fs::read(root.join("a/logo.png")).unwrap(), png); let lazy = Repo::clone_shallow(&url, &root.join("b"), 0, Some(0), &[]).unwrap(); assert_eq!(fs::read(root.join("b/a.txt")).unwrap(), b"later\n"); let blob = { let heads = lazy.bookmark_heads().unwrap(); let st = lazy.load_state_history(&heads).unwrap(); st.opaque_nodes() .iter() .flat_map(|n| st.live_blobs(n)) .next() .unwrap() }; assert_eq!(lazy.read_blob(&blob).unwrap(), png, "fetched on read"); // Depth 0 takes no change: the head is the boundary; the log comes from // the checkpoint; a change on top commits and pushes. let mut a = Repo::open(&root.join("a")) .unwrap() .with_home(root.join("home")); let head = origin.bookmark_heads().unwrap()[0]; assert!(!a.store().has(&head), "no change fetched"); assert!(a.boundary().contains(&head)); assert_eq!( a.log() .unwrap() .iter() .filter(|e| !e.is_working_copy) .count(), 2, "the log from the checkpoint" ); write(&root.join("a"), "a.txt", b"later\nmine\n"); a.config.set("remote.origin.url", &url).unwrap(); a.save_config().unwrap(); let mine = a.commit("mine", &[]).unwrap(); assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); origin.refresh().unwrap(); assert!(origin.store().has(&mine)); assert_eq!(origin.bookmark_heads().unwrap(), vec![mine]); // And pulls what comes after. origin .checkout(&origin.bookmark_heads().unwrap(), false, "co") .unwrap(); write(&origin_dir, "a.txt", b"later\nmine\ntheirs\n"); origin.snapshot("t").unwrap(); origin.commit("theirs", &[]).unwrap(); a.pull("origin").unwrap(); assert_eq!( fs::read(root.join("a/a.txt")).unwrap(), b"later\nmine\ntheirs\n" ); } #[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); origin.refresh().unwrap(); 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 a_tag_is_withdrawn_by_a_signed_withdrawal() { 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.create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); write(&root.join("a"), "f.txt", b"1\n"); let h1 = a.commit("one", &[]).unwrap(); a.tag_create("v1", &[h1], "first release").unwrap(); a.tag_create("v2", &[h1], "").unwrap(); a.policy_init().unwrap(); a.push("origin", false).unwrap(); origin.refresh().unwrap(); let original = a .tag_refs() .unwrap() .into_iter() .find(|t| t.name == "v1") .unwrap(); 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])); // A key the policy does not let create the tag may not withdraw it. let mut c = Repo::clone(&url, &root.join("c")) .unwrap() .with_home(root.join("home-c")); c.create_identity("Cy", "cy@example.org", IdentityScope::User) .unwrap(); let r = c.push_delete("origin", "tags/v1").unwrap(); assert!( !r[0].ok && r[0].reason.contains("may not withdraw"), "{r:?}" ); assert_eq!(c.tag_heads("v1").unwrap(), None, "withdrawn there first"); origin.refresh().unwrap(); assert_eq!(origin.tag_heads("v1").unwrap(), Some(vec![h1])); // Its creator's key may: the tag goes, the withdrawal stays. let r = a.push_delete("origin", "tags/v1").unwrap(); assert!(r[0].ok && r[0].reason == "withdrawn", "{r:?}"); origin.refresh().unwrap(); assert_eq!(origin.tag_heads("v1").unwrap(), None); assert_eq!(a.tag_heads("v1").unwrap(), None); assert!(origin.tag_withdrawal("v1").unwrap().is_some()); assert!(origin.tag_attest("v1").unwrap().is_some(), "kept beside it"); let refs = origin.remote_refs().unwrap(); assert!(refs.tags.iter().all(|t| t.name != "v1")); assert_eq!(refs.withdrawn.len(), 1); let r = a.push_delete("origin", "tags/v1").unwrap(); assert!(r[0].ok && r[0].reason == "already withdrawn", "{r:?}"); // A fetch withdraws it too, and the name is never used again. let rep = b.fetch("origin").unwrap(); assert_eq!(rep.withdrawn_tags, vec!["v1".to_string()]); assert_eq!(b.tag_heads("v1").unwrap(), None); assert!(b.tag_create("v1", &[h1], "").is_err()); let r = origin .receive_push(Bundle::default(), vec![], None, vec![original]) .unwrap(); assert!(!r[0].ok && r[0].reason.contains("withdrawn"), "{r:?}"); // `oxy tag delete`, then a plain push, sends it. a.tag_delete("v2", "tagged too early").unwrap(); let r = a.push("origin", false).unwrap(); assert!( r.iter() .any(|x| x.name == "tags/v2" && x.ok && x.reason == "withdrawn"), "{r:?}" ); origin.refresh().unwrap(); assert_eq!(origin.tag_heads("v2").unwrap(), None); let (_, w) = origin.tag_withdrawal("v2").unwrap().unwrap(); assert!( matches!(&w.attest.body, AttestBody::Untag { reason, .. } if reason == "tagged too early") ); } #[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); // Without a policy every client is sent every tag, whatever features // it declares (oxygen#131). let mut refs = a.remote_refs().unwrap(); a.refs_for_client(&mut refs, None); assert_eq!(refs.tags.len(), 1); // 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(); origin.refresh().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(); origin.refresh().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(); origin.refresh().unwrap(); let unattested = crate::protocol::TagRef::new( "v3".into(), origin .store() .put_state(&StateObject::new(vec![h2])) .unwrap(), vec![h2], 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(); origin.refresh().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"); // After a rotation the policy names only the new key, yet a fresh clone // still takes the tags the old key made before it; the old key makes // nothing new. let rot = a.rotate_identity().unwrap(); let r = a.push("origin", false).unwrap(); origin.refresh().unwrap(); assert!(r.iter().all(|x| x.ok), "{r:?}"); let c = Repo::clone(&url, &root.join("c")) .unwrap() .with_home(root.join("home-c")); assert_eq!(c.tag_heads("v3").unwrap(), Some(vec![h2])); let (_, sp) = c.current_policy().unwrap().unwrap(); assert!(!sp.policy.authorizes(&key_a, "tags/v9")); let made = c.identity(&rot.new).unwrap().created; assert!(c.predecessor_authorized(&sp.policy, &key_a, "tags/v9", made - 1)); assert!(!c.predecessor_authorized(&sp.policy, &key_a, "tags/v9", made)); assert!(!c.predecessor_authorized(&sp.policy, &rot.new, "tags/v9", 0)); } #[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 ops = repo.op_log().unwrap().len(); let ws = repo.workspace_add(&ws_dir, None).unwrap(); assert_eq!( ws.op_log().unwrap().len(), ops + 2, "the add and its checkout, and no snapshot finding the tree deleted (oxygen#137)" ); 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(); repo.refresh().unwrap(); let mut heads = repo.bookmark_heads().unwrap(); heads.sort(); let mut want = vec![h1, h2]; want.sort(); assert_eq!(heads, want, "both workspaces see the bookmark move"); assert!(!main_dir.join("b.txt").exists()); repo.checkout(&repo.bookmark_heads().unwrap(), false, "co") .unwrap(); assert!(main_dir.join("b.txt").exists()); // Reopening from inside the second workspace finds the shared repository. let reopened = Repo::open(&ws_dir).unwrap(); assert_eq!(reopened.workspace(), ws.workspace()); assert_eq!(reopened.id().unwrap(), repo.id().unwrap()); assert_eq!(reopened.quick_status().unwrap().heads, 2); // Workspaces cannot nest; forget drops one from the view but keeps files. assert!(repo.workspace_add(&main_dir.join("inner"), None).is_err()); assert!(repo.workspace_forget(&repo.workspace()).is_err()); repo.workspace_forget(&ws.workspace()).unwrap(); assert_eq!(repo.workspaces().unwrap().len(), 1); assert!(ws_dir.join("b.txt").exists()); assert!( ws.snapshot("status").is_err(), "a forgotten workspace is no longer in the view" ); // The working-copy lock: re-entrant in a thread; a lock whose process // is gone is taken over, and a live process's is not, however old. let lock = repo.lock_snapshot().unwrap(); let again = repo.lock_working_copy().unwrap(); drop(again); drop(lock); let lock_path = main_dir .join(".oxy/workspaces") .join(repo.workspace().to_hex()) .join("lock"); let old = std::time::SystemTime::now() - std::time::Duration::from_secs(120); let age = |p: &Path| fs::File::open(p).unwrap().set_modified(old).unwrap(); fs::write(&lock_path, std::process::id().to_string()).unwrap(); age(&lock_path); assert!( !super::workspace::lock_is_stale(&lock_path), "a live process keeps its lock" ); fs::write(&lock_path, "4294967295").unwrap(); age(&lock_path); assert!(repo.lock_snapshot().is_ok(), "stale lock taken over"); } #[test] fn attributes_binary_eol_nocompress_lockable() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write( root, ".oxyattributes", b"*.dat binary nocompress\n*.txt eol=crlf\nassets/** lockable\n", ); write(root, "plain.dat", b"no nul bytes here\n"); write(root, "notes.txt", b"one\r\ntwo\r\n"); write(root, "assets/model.bin", b"\0model"); let h = repo.commit("attrs", &[]).unwrap(); let snap = repo.load_state(&[h]).unwrap().materialize(); assert!( snap.files["plain.dat"].mode.opaque, "binary attribute forces opaque without NUL bytes" ); assert!( matches!(&snap.files["notes.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n"), "stored with LF" ); assert!( !repo.snapshot("status").unwrap().changed, "CRLF on disk equals LF in the state" ); // nocompress chunks pack raw. let r = repo.gc(true).unwrap(); assert!(r.packed.objects > 0); // Checkout writes CRLF for eol=crlf and makes lockable files read-only. fs::remove_file(root.join("notes.txt")).unwrap(); repo.checkout(&[h], true, "co").unwrap(); assert_eq!(fs::read(root.join("notes.txt")).unwrap(), b"one\r\ntwo\r\n"); assert!(fs::metadata(root.join("assets/model.bin")) .unwrap() .permissions() .readonly()); assert!(repo.lock_path("plain.dat").is_err(), "not lockable"); repo.lock_path("assets/model.bin").unwrap(); assert!(!fs::metadata(root.join("assets/model.bin")) .unwrap() .permissions() .readonly()); assert!(repo.is_locked("assets/model.bin")); write(root, "assets/model.bin", b"\0model2"); repo.commit("edit model", &[]).unwrap(); repo.unlock_path("assets/model.bin").unwrap(); assert!(fs::metadata(root.join("assets/model.bin")) .unwrap() .permissions() .readonly()); // A later checkout can still overwrite a read-only lockable file. repo.checkout(&[h], true, "co").unwrap(); assert_eq!(fs::read(root.join("assets/model.bin")).unwrap(), b"\0model"); } #[cfg(unix)] #[test] fn hooks_can_veto_observe_and_add_dependencies() { use std::os::unix::fs::PermissionsExt; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); let hooks = root.join(".oxy/hooks"); fs::create_dir_all(&hooks).unwrap(); let hook = |name: &str, body: &str| { let p = hooks.join(name); fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap(); fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap(); }; hook("pre-commit", "cat > \"$OXY_ROOT/.last-pre-commit\"; grep -q WIP \"$OXY_ROOT/.last-pre-commit\" && { echo 'no WIP commits' >&2; exit 1; }; exit 0"); hook("post-commit", "cat > \"$OXY_ROOT/.last-post-commit\""); hook("post-checkout", "cat > \"$OXY_ROOT/.last-post-checkout\""); hook("pre-push", "echo pushing blocked >&2; exit 3"); write(root, ".oxyignore", b".last-*\n"); write(root, "a.txt", b"a\n"); let err = repo.commit("WIP: nope", &[]).unwrap_err().to_string(); assert!(err.contains("pre-commit refused: no WIP commits"), "{err}"); let h1 = repo.commit("first", &[]).unwrap(); let pre = fs::read_to_string(root.join(".last-pre-commit")).unwrap(); assert!( pre.contains("\"event\":\"pre-commit\"") && pre.contains("\"path\":\"a.txt\"") && pre.contains("\"message\":\"first\""), "{pre}" ); let post = fs::read_to_string(root.join(".last-post-commit")).unwrap(); assert!(post.contains(&h1.to_hex()), "{post}"); repo.checkout(&[h1], false, "co").unwrap(); assert!(fs::read_to_string(root.join(".last-post-checkout")) .unwrap() .contains("post-checkout")); repo.config.set("remote.origin.url", "/nowhere").unwrap(); repo.save_config().unwrap(); let err = repo.push("origin", false).unwrap_err().to_string(); assert!(err.contains("pre-push refused: pushing blocked"), "{err}"); // semantic-deps: only when enabled; adds an explicit dependency. write(root, "b.txt", b"b\n"); hook( "semantic-deps", &format!( "cat > \"$OXY_ROOT/.last-semantic\"; echo '{{\"deps\": [\"{}\"]}}'", h1.to_hex() ), ); let h2 = repo.commit("b without hook", &[]).unwrap(); assert!( repo.store().get_change(&h2).unwrap().deps.is_empty(), "hook disabled by default" ); repo.config.set("hooks.semantic-deps", "true").unwrap(); repo.save_config().unwrap(); write(root, "c.txt", b"c\n"); let h3 = repo.commit("c with hook", &[]).unwrap(); assert_eq!(repo.store().get_change(&h3).unwrap().deps, vec![h1]); let sem = fs::read_to_string(root.join(".last-semantic")).unwrap(); assert!( sem.contains("\"touched_paths\":[\"c.txt\"]") && sem.contains("candidate_deps"), "{sem}" ); assert_eq!(json_str("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\""); } #[test] fn amend_into_older_change_and_split() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); repo.create_identity("Ada", "a@b", IdentityScope::User) .unwrap(); repo.set_user_config("core.sign", "change").unwrap(); write(root, "a.txt", b"one\ntwo\n"); let h0 = repo.commit("base", &[]).unwrap(); write(root, "a.txt", b"one\nTWO\n"); let h1 = repo.commit("edit two", &[]).unwrap(); write(root, "a.txt", b"one\nTWO\nthree\n"); let h2 = repo.commit("add three", &[]).unwrap(); let id1 = repo.store().get_change(&h1).unwrap().change_id; // Fixing the line h1 introduced folds into h1; h2 is rewritten on top. write(root, "a.txt", b"one\nTWO!\nthree\n"); let h1b = repo.amend_into(&h1, None).unwrap(); assert_ne!(h1b, h1); let c1b = repo.store().get_change(&h1b).unwrap(); assert_eq!(c1b.change_id, id1); assert_eq!(c1b.deps, vec![h0]); assert!(matches!( repo.verify_change(&h1b).unwrap(), SignatureStatus::Valid { .. } )); let heads = repo.bookmark_heads().unwrap(); assert_eq!(heads.len(), 1); let top = repo.store().get_change(&heads[0]).unwrap(); assert_eq!(top.message, "add three"); assert_eq!( top.deps.iter().copied().collect::>(), [h0, h1b].into_iter().collect(), "depends on the file's creator and the rewritten line" ); let snap = repo.load_state(&heads).unwrap().materialize(); assert!( matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\nTWO!\nthree\n") ); assert!( !repo.snapshot("status").unwrap().changed, "the working copy is clean after folding" ); // Only h1b's own state shows the fix without "three". let only = repo.load_state(&[h1b]).unwrap().materialize(); assert!( matches!(&only.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\nTWO!\n") ); assert_eq!(repo.log().unwrap().len(), 4); // An edit to a line introduced by h2 cannot fold into h1. write(root, "a.txt", b"one\nTWO!\nthree!\n"); let err = repo.amend_into(&h1b, None).unwrap_err().to_string(); assert!(err.contains("introduced after"), "{err}"); assert!( repo.snapshot("status").unwrap().statuses.len() == 1, "nothing was changed by the refusal" ); write(root, "a.txt", b"one\nTWO!\nthree\n"); // Split the head that touches two files. write(root, "b.txt", b"b\n"); write(root, "c.txt", b"c\n"); let h3 = repo.commit("b and c", &[]).unwrap(); let id3 = repo.store().get_change(&h3).unwrap().change_id; let (p1, p2) = repo.split(&h3, &["b.txt".into()]).unwrap(); let c_1 = repo.store().get_change(&p1).unwrap(); let c_2 = repo.store().get_change(&p2).unwrap(); assert!(c_1.message.contains("part 1: b.txt")); assert_eq!(c_2.change_id, id3); let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect(); assert!( heads.contains(&p1) && heads.contains(&p2), "independent parts are both heads" ); let snap = repo .load_state(&repo.bookmark_heads().unwrap()) .unwrap() .materialize(); assert!(snap.files.contains_key("b.txt") && snap.files.contains_key("c.txt")); let only1 = repo.load_state(&[p1]).unwrap().materialize(); assert!(only1.files.contains_key("b.txt") && !only1.files.contains_key("c.txt")); assert!(!repo.snapshot("status").unwrap().changed); assert!(repo.split(&p2, &["nope.txt".into()]).is_err()); let _ = h2; } #[test] fn identity_lifecycle() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = Repo::init(&root.join("origin")).unwrap(); let url = root.join("origin").to_string_lossy().to_string(); let mut a = Repo::clone(&url, &root.join("a")) .unwrap() .with_home(root.join("home-a")); a.set_user_config("core.sign", "change").unwrap(); let (key_a, _) = a .create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); write(&root.join("a"), "a.txt", b"a\n"); let h1 = a.commit("one", &[]).unwrap(); assert!(a.policy_init().unwrap().applied); assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); origin.refresh().unwrap(); // 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(); origin.refresh().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(), Scope::ALL, None).unwrap(); assert_eq!(r.old, key_a); let key_a2 = r.new; assert_eq!( a.identity(&key_a2).unwrap().delegates, vec![Delegate::full(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(), Scope::ALL, None).is_err(), "already a delegate" ); assert!(a.push("origin", false).unwrap().iter().all(|x| x.ok)); origin.refresh().unwrap(); 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(); origin.refresh().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)); origin.refresh().unwrap(); 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)); origin.refresh().unwrap(); a.pull("origin").unwrap(); assert_eq!( a.current_policy().unwrap().unwrap().1.policy.serial, serial_before + 1 ); // A root cannot revoke itself; revoking Cy needs both roots but is recorded locally at once. assert!(a.policy_revoke(&key_a3).is_err()); let u = a .revoke_identity(&key_c, "left the project") .unwrap() .unwrap(); assert!(!u.applied); assert!(a.is_revoked(&key_c).unwrap()); assert!(!b.is_revoked(&key_c).unwrap()); let u = b.policy_sign(u.signed()).unwrap(); assert!(u.applied && u.policy.is_revoked(&key_c)); assert!(b.is_revoked(&key_c).unwrap()); // Bob can revoke Ada's root key, leaving himself as the only root (threshold 2 would be unmeetable). assert!( b.policy_revoke(&key_a3).is_err(), "would leave one root for a threshold of 2" ); // Revoking one's own key without a policy root works locally: a plain marker plus attestation. let key_c_own = c.config.author_key.unwrap(); assert!(c .revoke_identity(&key_c_own, "lost laptop") .unwrap() .is_none()); assert!(c.is_revoked(&key_c_own).unwrap()); assert_eq!(key_c_own, key_c); } #[test] fn renames_are_detected_and_tracked_as_moves() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write( root, "src/old.rs", b"fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n", ); write(root, "img.bin", b"\x89PNG\0\0\x01\x02"); write(root, "tiny.txt", b"x\n"); let h1 = repo.commit("base", &[]).unwrap(); let node_old = repo.load_state(&[h1]).unwrap().materialize().files["src/old.rs"].node; let node_img = repo.load_state(&[h1]).unwrap().materialize().files["img.bin"].node; // Rename with a small edit, a binary moved unchanged, and a rewrite // that shares nothing with the removed file. fs::remove_file(root.join("src/old.rs")).unwrap(); write( root, "lib/new.rs", b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n", ); fs::remove_file(root.join("img.bin")).unwrap(); write(root, "assets/img.bin", b"\x89PNG\0\0\x01\x02"); fs::remove_file(root.join("tiny.txt")).unwrap(); write(root, "other.txt", b"completely different\n"); let snap = repo.snapshot("status").unwrap(); let statuses: Vec<(&str, ChangeStatus)> = snap .statuses .iter() .map(|s| (s.path.as_str(), s.status)) .collect(); assert_eq!( statuses, vec![ ("assets/img.bin", ChangeStatus::Renamed), ("lib/new.rs", ChangeStatus::Renamed), ("other.txt", ChangeStatus::Added), ("src", ChangeStatus::Removed), ("tiny.txt", ChangeStatus::Removed) ] ); let h2 = repo.commit("move things", &[]).unwrap(); let c = repo.store().get_change(&h2).unwrap(); let moves: Vec<_> = c .tree_ops .iter() .filter(|op| matches!(op, TreeOp::Move { .. })) .collect(); assert_eq!(moves.len(), 2, "{:?}", c.tree_ops); assert!(c.tree_ops.iter().any( |op| matches!(op, TreeOp::Move { node, name, .. } if *node == node_old && name == "new.rs") )); assert!(c.tree_ops.iter().any( |op| matches!(op, TreeOp::Move { node, name, .. } if *node == node_img && name == "img.bin") )); let snap = repo.load_state(&[h2]).unwrap().materialize(); assert_eq!( snap.files["lib/new.rs"].node, node_old, "the node survives the rename" ); assert!( matches!(&snap.files["lib/new.rs"].content, FileContent::Text { bytes, .. } if bytes == b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n") ); assert!(!snap.files.contains_key("src/old.rs") && !snap.files.contains_key("src")); assert!(render_change( &c, &repo.load_state(&[h1]).unwrap(), &repo.load_state(&[h1]).unwrap().materialize() ) .contains("R ")); // A fresh checkout reproduces the tree. let other = tempfile::tempdir().unwrap(); let clone = Repo::clone(&root.to_string_lossy(), other.path()).unwrap(); assert_eq!( fs::read(other.path().join("lib/new.rs")).unwrap(), b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n" ); assert!(!other.path().join("src").exists()); assert!(!clone.snapshot("status").unwrap().changed); // Similarity is line based, byte weighted. assert_eq!(similarity(b"a\nb\n", b"a\nb\n"), 100); assert_eq!(similarity(b"a\nb\nc\nd\n", b"a\nb\nx\ny\n"), 50); assert_eq!(similarity(b"hello\n", b"world\n"), 0); assert_eq!(similarity(b"", b"x\n"), 0); } #[test] fn git_fetch_and_push_round_trip() { hermetic(); if std::process::Command::new("git") .arg("--version") .output() .is_err() { eprintln!("git not installed; skipping"); return; } let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let git_in = |d: &Path, args: &[&str]| -> String { let out = std::process::Command::new("git") .arg("-C") .arg(d) .args([ "-c", "user.name=Grace", "-c", "user.email=grace@example.org", "-c", "commit.gpgsign=false", ]) .args(args) .output() .unwrap(); assert!( out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_string() }; // A bare "remote" and a work tree that pushes to it. let remote = root.join("remote.git"); fs::create_dir_all(&remote).unwrap(); git_in(&remote, &["init", "-q", "--bare", "-b", "main"]); let work = root.join("work"); git_in( root, &[ "clone", "-q", remote.to_str().unwrap(), work.to_str().unwrap(), ], ); git_in(&work, &["checkout", "-q", "-b", "main"]); write(&work, "README.md", b"# Project\n"); write(&work, "src/lib.rs", b"pub fn a() {}\n"); git_in(&work, &["add", "."]); git_in(&work, &["commit", "-q", "-m", "initial"]); write(&work, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\n"); git_in(&work, &["commit", "-q", "-am", "second"]); git_in(&work, &["push", "-q", "origin", "main"]); let tip1 = git_in(&work, &["rev-parse", "HEAD"]); // Fetch into a fresh oxy repository. let o = root.join("oxy"); let mut repo = Repo::init(&o).unwrap().with_home(root.join("home")); repo.create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); let url = remote.to_string_lossy().to_string(); let r = repo.git_fetch(&url, "main").unwrap(); assert_eq!((r.commits, r.heads), (2, 1)); assert_eq!( fs::read(o.join("src/lib.rs")).unwrap(), b"pub fn a() {}\npub fn b() {}\n" ); assert!(!repo.snapshot("status").unwrap().changed); assert_eq!( repo.git_fetch(&url, "main").unwrap().commits, 0, "nothing new" ); // An oxy change, pushed back: one commit on top of the Git tip. write( &o, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\npub fn c() {}\n", ); write(&o, "NOTES.md", b"from oxy\n"); let h_oxy = repo.commit("add c and notes", &[]).unwrap(); let r = repo.git_push(&url, "main").unwrap(); assert_eq!(r.exported, 1, "{r:?}"); git_in(&work, &["pull", "-q", "--ff-only", "origin", "main"]); assert_eq!( git_in(&work, &["log", "--format=%s", "main"]), "add c and notes\nsecond\ninitial" ); assert_eq!(git_in(&work, &["rev-parse", "HEAD~1"]), tip1); assert_eq!(fs::read(work.join("NOTES.md")).unwrap(), b"from oxy\n"); assert_eq!(git_in(&work, &["log", "-1", "--format=%an", "main"]), "Ada"); assert!(git_in(&work, &["log", "-1", "--format=%B", "main"]) .contains(&format!("Oxy-Hash: {}", h_oxy.to_hex()))); assert_eq!( repo.git_push(&url, "main").unwrap().exported, 0, "already pushed" ); // Git moves on; pushing without fetching is refused, fetching brings // the commit in and the working copy follows. write(&work, "README.md", b"# Project\n\nMore.\n"); git_in(&work, &["commit", "-q", "-am", "readme from git"]); git_in(&work, &["push", "-q", "origin", "main"]); let tip2 = git_in(&work, &["rev-parse", "HEAD"]); assert!(repo .git_push(&url, "main") .unwrap_err() .to_string() .contains("fetch")); let r = repo.git_fetch(&url, "main").unwrap(); assert_eq!( (r.commits, r.heads), (1, 2), "the Git change and the oxy change commute" ); assert_eq!( fs::read(o.join("README.md")).unwrap(), b"# Project\n\nMore.\n" ); assert_eq!(fs::read(o.join("NOTES.md")).unwrap(), b"from oxy\n"); assert!(!repo.snapshot("status").unwrap().changed); // Another oxy change pushes on top of Git's newest commit. write(&o, "NOTES.md", b"from oxy\nagain\n"); repo.commit("notes again", &[]).unwrap(); assert_eq!(repo.git_push(&url, "main").unwrap().exported, 1); git_in(&work, &["pull", "-q", "--ff-only", "origin", "main"]); assert_eq!(git_in(&work, &["rev-parse", "HEAD~1"]), tip2); assert_eq!( fs::read(work.join("NOTES.md")).unwrap(), b"from oxy\nagain\n" ); assert_eq!( git_in(&work, &["log", "--format=%s", "main"]) .lines() .count(), 5 ); } /// Benchmark, not a gate: `cargo test --release -p oxy-core --lib bench_one_gibibyte -- --ignored --nocapture`. /// Commits, stores, clones, and reads back a 1 GiB incompressible file. #[test] #[ignore] fn bench_one_gibibyte_file() { hermetic(); // /tmp is often a small tmpfs; `OXY_BENCH_DIR` picks a roomier disk. let dir = match std::env::var_os("OXY_BENCH_DIR") { Some(d) => tempfile::tempdir_in(d).unwrap(), None => tempfile::tempdir().unwrap(), }; let root = dir.path(); let repo = Repo::init(&root.join("r")) .unwrap() .with_home(root.join("home")); let size = 1usize << 30; let mut data = vec![0u8; size]; let mut x = 0x9e3779b97f4a7c15u64; for chunk in data.chunks_mut(8) { x ^= x << 13; x ^= x >> 7; x ^= x << 17; let b = x.to_le_bytes(); chunk.copy_from_slice(&b[..chunk.len()]); } let t = std::time::Instant::now(); write(&root.join("r"), "big.bin", &data); eprintln!("write 1 GiB to disk: {:.2?}", t.elapsed()); let t = std::time::Instant::now(); let h = repo.commit("big", &[]).unwrap(); eprintln!("commit (chunk + hash + store): {:.2?}", t.elapsed()); let t = std::time::Instant::now(); assert!(!repo.snapshot("status").unwrap().changed); eprintln!("status on a clean 1 GiB working copy: {:.2?}", t.elapsed()); let t = std::time::Instant::now(); let objects = repo.store().list().unwrap().len(); eprintln!("objects stored: {objects} ({:.2?} to list)", t.elapsed()); let t = std::time::Instant::now(); let g = repo.gc(true).unwrap(); eprintln!("gc (pack): {:.2?} {g:?}", t.elapsed()); let t = std::time::Instant::now(); let clone = Repo::clone(&root.join("r").to_string_lossy(), &root.join("c")).unwrap(); eprintln!("clone (fetch + checkout): {:.2?}", t.elapsed()); let t = std::time::Instant::now(); let back = fs::read(root.join("c/big.bin")).unwrap(); assert_eq!(blake3::hash(&back), blake3::hash(&data)); eprintln!("read back and verify: {:.2?}", t.elapsed()); assert_eq!(clone.bookmark_heads().unwrap(), vec![h]); let store_bytes: u64 = walkdir_size(&root.join("r/.oxy/objects")); eprintln!( "store size: {:.1} MiB for 1 GiB of data", store_bytes as f64 / (1024.0 * 1024.0) ); } fn walkdir_size(p: &Path) -> u64 { let mut total = 0; if let Ok(rd) = fs::read_dir(p) { for e in rd.flatten() { let path = e.path(); if path.is_dir() { total += walkdir_size(&path); } else if let Ok(m) = fs::metadata(&path) { total += m.len(); } } } total } /// An opaque file whose size and mtime match the manifest is not read on /// the next snapshot: its blob comes from the manifest's stat cache. #[cfg(unix)] #[test] fn stat_cache_skips_reading_unchanged_opaque_files() { use std::os::unix::fs::PermissionsExt; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); let mut big = vec![0u8; 300 * 1024]; for (i, b) in big.iter_mut().enumerate() { *b = (i as u32).wrapping_mul(2654435761).to_le_bytes()[1]; } big[0] = 0; write(root, "big.bin", &big); write(root, "a.txt", b"text\n"); let h1 = repo.commit("base", &[]).unwrap(); let (_, _, m) = repo.read_manifest().unwrap().unwrap(); assert!( m.entries["big.bin"].blob.is_some(), "opaque files record their blob" ); assert!(m.entries["a.txt"].blob.is_none()); let blob = m.entries["big.bin"].blob.unwrap(); assert!(repo.store().has(&blob)); // Files modified within two seconds of the manifest are racy and still // read; age the file so the cache applies, then snapshot to record it. let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30); fs::File::options() .write(true) .open(root.join("big.bin")) .unwrap() .set_modified(old) .unwrap(); assert!(!repo.snapshot("status").unwrap().changed); // Now the file cannot be read at all; the snapshot succeeds without it. fs::set_permissions(root.join("big.bin"), fs::Permissions::from_mode(0o000)).unwrap(); let snap = repo.snapshot("status").unwrap(); assert!( !snap.changed && snap.statuses.is_empty(), "{:?}", snap.statuses ); fs::set_permissions(root.join("big.bin"), fs::Permissions::from_mode(0o644)).unwrap(); // A real change (new mtime, new content) is seen and committed correctly. let mut big2 = big.clone(); big2[10] ^= 0xff; write(root, "big.bin", &big2); let snap = repo.snapshot("status").unwrap(); assert_eq!( snap.statuses .iter() .map(|s| (s.path.as_str(), s.status)) .collect::>(), vec![("big.bin", ChangeStatus::Modified)] ); let h2 = repo.commit("edit", &[]).unwrap(); let files = repo.load_state(&[h2]).unwrap().materialize().files; assert_eq!( files["big.bin"].content, FileContent::Opaque { blobs: vec![Repo::blob_ref(&big2)] } ); // Checkout writes a manifest with blobs too, so the next status is cached. repo.checkout(&[h1], false, "checkout").unwrap(); let (_, _, m) = repo.read_manifest().unwrap().unwrap(); assert_eq!(m.entries["big.bin"].blob, Some(Repo::blob_ref(&big))); assert!(!repo.snapshot("status").unwrap().changed); } #[test] fn status_json_lists_conflicts_with_contributors() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"one\n"); let h1 = repo.commit("base", &[]).unwrap(); write(root, "a.txt", b"one\nleft\n"); let ha = repo.commit("left", &[]).unwrap(); repo.checkout(&[h1], true, "checkout").unwrap(); write(root, "a.txt", b"one\nright\n"); let hb = repo.commit("right", &[]).unwrap(); repo.checkout(&[ha, hb], true, "checkout").unwrap(); let snap = repo.snapshot("status").unwrap(); assert!(snap.base_snapshot.is_conflicted()); let json = repo.status_json(&snap).unwrap(); let ida = repo.store().get_change(&ha).unwrap().change_id.to_hex(); let idb = repo.store().get_change(&hb).unwrap().change_id.to_hex(); assert!(json.contains("\"conflicted\":true"), "{json}"); assert!( json.contains("\"kind\":\"text\",\"path\":\"a.txt\""), "{json}" ); assert!(json.contains(&ida) && json.contains(&idb), "{json}"); assert!(json.contains("\"resolved\":false"), "{json}"); assert!(json.starts_with('{') && json.ends_with('}')); // Resolve and the report says so; a clean copy has no conflicts. write(root, "a.txt", b"one\nleft\nright\n"); let snap = repo.snapshot("status").unwrap(); let json = repo.status_json(&snap).unwrap(); assert!(json.contains("\"resolved\":true"), "{json}"); repo.commit("resolve", &[]).unwrap(); let snap = repo.snapshot("status").unwrap(); let json = repo.status_json(&snap).unwrap(); assert!( json.contains("\"clean\":true,\"conflicted\":false,\"paths\":[],\"conflicts\":[]"), "{json}" ); } #[test] fn ignore_rules_do_not_untrack_files() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "src.c", b"int x;\n"); write(root, "build/out.o", b"\0obj"); write(root, "sub/keep.log", b"keep\n"); write(root, "sub/junk.log", b"junk\n"); write(root, ".oxyignore", b"/build\n*.log\n!sub/keep.log\n"); let snap = repo.snapshot("status").unwrap(); let added: Vec<&str> = snap.statuses.iter().map(|s| s.path.as_str()).collect(); assert_eq!( added, vec![".oxyignore", "src.c", "sub/keep.log"], "anchored, glob, and negated rules" ); let h1 = repo.commit("base", &[]).unwrap(); // Ignoring a tracked file changes nothing about it. write(root, ".oxyignore", b"/build\n*.log\n!sub/keep.log\nsrc.c\n"); let snap = repo.snapshot("status").unwrap(); assert_eq!( snap.statuses .iter() .map(|s| s.path.as_str()) .collect::>(), vec![".oxyignore"] ); let h2 = repo.commit("ignore src.c", &[]).unwrap(); assert!(repo .load_state(&[h2]) .unwrap() .materialize() .files .contains_key("src.c")); // Its edits are still seen, by status and by the prompt. assert_eq!(repo.quick_dirty().unwrap(), Some(false)); write(root, "src.c", b"int x, y;\n"); assert_eq!(repo.quick_dirty().unwrap(), Some(true)); let snap = repo.snapshot("status").unwrap(); assert_eq!( snap.statuses .iter() .map(|s| (s.path.as_str(), s.status)) .collect::>(), vec![("src.c", ChangeStatus::Modified)] ); let h3 = repo.commit("edit src.c", &[]).unwrap(); assert!( matches!(&repo.load_state(&[h3]).unwrap().materialize().files["src.c"].content, FileContent::Text { bytes, .. } if bytes == b"int x, y;\n") ); // Deleting it on disk removes it, ignored or not; a checkout brings it back. fs::remove_file(root.join("src.c")).unwrap(); let snap = repo.snapshot("status").unwrap(); assert_eq!( snap.statuses .iter() .map(|s| (s.path.as_str(), s.status)) .collect::>(), vec![("src.c", ChangeStatus::Removed)] ); repo.checkout(&[h3], true, "checkout").unwrap(); assert_eq!(fs::read(root.join("src.c")).unwrap(), b"int x, y;\n"); assert!(!repo.snapshot("status").unwrap().changed); let _ = h1; } #[test] fn split_works_on_non_heads_by_node() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"one\n"); write(root, "junk/x.lock", b"lock\n"); let h1 = repo.commit("work plus junk", &[]).unwrap(); write(root, "a.txt", b"one\ntwo\n"); let h2 = repo.commit("more work", &[]).unwrap(); assert_eq!(repo.store().get_change(&h2).unwrap().deps, vec![h1]); let id1 = repo.store().get_change(&h1).unwrap().change_id; // h1 is not a head: split it by node. let (hj, h1b) = repo.split(&h1, &["junk".into()]).unwrap(); assert_ne!(h1b, h1); assert_eq!( repo.store().get_change(&h1b).unwrap().change_id, id1, "the rest keeps the ChangeId" ); let junk = repo.store().get_change(&hj).unwrap(); assert!(junk.tree_ops.iter().all( |op| matches!(op, TreeOp::AddNode { name, .. } if name == "junk" || name == "x.lock") )); let heads = repo.bookmark_heads().unwrap(); assert!( heads.contains(&hj), "the split-off part is a head of its own" ); let snap = repo.load_state(&heads).unwrap().materialize(); assert!( matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n"), "the dependent was rewritten onto the new hash" ); assert!(snap.files.contains_key("junk/x.lock")); let h2b = heads .iter() .find(|h| repo.store().get_change(h).unwrap().message == "more work") .copied() .unwrap(); assert_eq!(repo.store().get_change(&h2b).unwrap().deps, vec![h1b]); assert!(!repo.snapshot("status").unwrap().changed); // Abandoning the split-off head removes the junk from history. repo.abandon(&hj).unwrap(); let heads = repo.bookmark_heads().unwrap(); let snap = repo.load_state(&heads).unwrap().materialize(); assert!(!snap.files.contains_key("junk/x.lock") && !snap.files.contains_key("junk")); assert!(snap.files.contains_key("a.txt")); // A dependent that touches the split-off paths blocks the split. write(root, "junk/y.lock", b"y\n"); let hb = repo.commit("junk base", &[]).unwrap(); write(root, "junk/y.lock", b"yy\n"); write(root, "b.txt", b"b\n"); repo.commit("touches junk", &[]).unwrap(); let err = repo.split(&hb, &["junk".into()]).unwrap_err().to_string(); assert!(err.contains("depends on the split-off paths"), "{err}"); } #[test] fn bookmarks_switch_merge_and_push() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = Repo::init(&root.join("origin")).unwrap(); let url = root.join("origin").to_string_lossy().to_string(); let mut repo = Repo::clone(&url, &root.join("r")) .unwrap() .with_home(root.join("home")); let r = root.join("r"); write(&r, "a.txt", b"a\n"); let h_a = repo.commit("a", &[]).unwrap(); assert!(repo.push("origin", false).unwrap()[0].ok); origin.refresh().unwrap(); // A bookmark at the working copy's base, then work on it. assert!(repo.bookmark_create("bad name..", None).is_err()); assert!(repo.bookmark_create("-x", None).is_err()); assert_eq!(repo.bookmark_create("feature", None).unwrap(), vec![h_a]); assert!(repo.bookmark_create("feature", None).is_err(), "exists"); repo.bookmark_switch("feature", false).unwrap(); assert_eq!(repo.config.bookmark, "feature"); write(&r, "a.txt", b"a\nb\n"); write(&r, "b.txt", b"b\n"); let h_b = repo.commit("b on feature", &[]).unwrap(); assert_eq!( repo.bookmark_heads().unwrap(), vec![h_b], "b edits a.txt, so it depends on a" ); assert_eq!( repo.resolve_heads("main").unwrap(), vec![h_a], "main is untouched" ); assert_eq!( repo.resolve_change("feature").unwrap(), h_b, "a one-head bookmark names its change" ); // Reopening keeps the workspace's bookmark. let reopened = Repo::open(&r).unwrap().with_home(root.join("home")); assert_eq!(reopened.config.bookmark, "feature"); // Back to main: b.txt goes away; work there. repo.bookmark_switch("main", false).unwrap(); assert!(!r.join("b.txt").exists()); assert_eq!(fs::read(r.join("a.txt")).unwrap(), b"a\n"); write(&r, "c.txt", b"c\n"); let h_c = repo.commit("c on main", &[]).unwrap(); let list = repo.bookmarks().unwrap(); assert_eq!( list.iter() .map(|b| (b.name.as_str(), b.current)) .collect::>(), vec![("feature", false), ("main", true)] ); assert_eq!( list.iter().find(|b| b.name == "main").unwrap().ahead, Some(1), "c is not on origin yet" ); // Merge feature into main: the union, both heads, no merge change. let m = repo.merge("feature").unwrap(); assert!(m.changed && m.checked_out); let mut want = vec![h_b, h_c]; want.sort(); assert_eq!(m.heads, want); assert_eq!(fs::read(r.join("b.txt")).unwrap(), b"b\n"); assert!(!repo.merge("feature").unwrap().changed, "already merged"); assert!(repo.merge("main").is_err()); // The other bookmark can be pushed by name and appears at origin. assert!(repo.push_bookmark("origin", "feature", false).unwrap()[0].ok); origin.refresh().unwrap(); assert_eq!(origin.resolve_heads("feature").unwrap(), vec![h_b]); assert!(repo.push("origin", false).unwrap()[0].ok); origin.refresh().unwrap(); assert_eq!( repo.bookmarks() .unwrap() .iter() .find(|b| b.name == "main") .unwrap() .ahead, Some(0) ); // A second workspace inherits the current bookmark and can switch independently. let ws = repo.workspace_add(&root.join("ws"), None).unwrap(); assert_eq!(ws.config.bookmark, "main"); let mut ws = ws; ws.bookmark_switch("feature", false).unwrap(); assert_eq!( Repo::open(&r).unwrap().config.bookmark, "main", "the first workspace is unaffected" ); assert!( repo.bookmark_delete("feature").is_err(), "current bookmark of another workspace" ); ws.bookmark_switch("main", false).unwrap(); // Set and delete. assert_eq!(repo.bookmark_set("feature", &[h_a]).unwrap(), vec![h_a]); assert!(repo.bookmark_delete("main").is_err(), "current here"); repo.bookmark_delete("feature").unwrap(); assert!(repo.resolve_heads("feature").is_err()); assert!(repo.bookmark_set("feature", &[h_a]).is_err()); assert!( repo.checkout(&[h_a], false, "checkout").is_ok(), "changes of a deleted bookmark remain" ); } #[test] fn oxygen_subrepos_pin_follow_and_update() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); // A library repository with two commits, and a bare-ish origin for it. let lib_origin = Repo::init(&root.join("lib-origin")).unwrap(); let lib_url = root.join("lib-origin").to_string_lossy().to_string(); let lib = Repo::clone(&lib_url, &root.join("lib")) .unwrap() .with_home(root.join("home")); write(&root.join("lib"), "lib.txt", b"v1\n"); let l1 = lib.commit("lib v1", &[]).unwrap(); assert!(lib.push("origin", false).unwrap()[0].ok); let _ = lib_origin; // The application (a clone of its own origin) adds it as a subrepo. let app_origin = Repo::init(&root.join("app-origin")).unwrap(); let app_url = root.join("app-origin").to_string_lossy().to_string(); let _ = app_origin; let app_dir = root.join("app"); let app = Repo::clone(&app_url, &app_dir) .unwrap() .with_home(root.join("home")); write(&app_dir, "main.txt", b"app\n"); let pin = app.submodule_add(&lib_url, "libs/lib", None).unwrap(); assert!(matches!(&pin, crate::subrepo::Pin::Oxy { heads, .. } if heads == &vec![l1])); assert!(app_dir.join(".oxymodules").exists() && app_dir.join("libs/lib/.oxy").is_dir()); let snap = app.snapshot("status").unwrap(); let paths: Vec<&str> = snap.statuses.iter().map(|s| s.path.as_str()).collect(); assert_eq!( paths, vec![".oxymodules", "libs/lib", "main.txt"], "the nested files are not walked" ); let a1 = app.commit("app with lib", &[]).unwrap(); let files = app.load_state(&[a1]).unwrap().materialize().files; assert_eq!(files["libs/lib"].kind, NodeKind::Subrepo); assert!(!files.contains_key("libs/lib/lib.txt")); assert!(!app.snapshot("status").unwrap().changed); let subs = app.subrepos().unwrap(); assert_eq!(subs.len(), 1); assert_eq!(subs[0].actual.as_ref(), Some(&subs[0].pin)); // Advance the nested repository: the parent sees a modified pin. let nested = Repo::open(&app_dir.join("libs/lib")) .unwrap() .with_home(root.join("home")); write(&app_dir.join("libs/lib"), "lib.txt", b"v2\n"); let l2 = nested.commit("lib v2", &[]).unwrap(); assert!(nested.push("origin", false).unwrap()[0].ok); let snap = app.snapshot("status").unwrap(); assert_eq!( snap.statuses .iter() .map(|s| (s.path.as_str(), s.status)) .collect::>(), vec![("libs/lib", ChangeStatus::Modified)] ); let a2 = app.commit("bump lib", &[]).unwrap(); // Checking out the older app state brings the nested working copy back // to the older pin, but never moves its bookmark backwards (oxygen#38). let r = app.checkout(&[a1], false, "checkout").unwrap(); assert!( r.notes.len() == 1 && r.notes[0].contains("keeps 1 newer change"), "{:?}", r.notes ); assert_eq!(fs::read(app_dir.join("libs/lib/lib.txt")).unwrap(), b"v1\n"); let nested = Repo::open(&app_dir.join("libs/lib")).unwrap(); assert_eq!(nested.bookmark_heads().unwrap(), vec![l2]); assert_eq!(nested.working_copy().unwrap().1.deps, vec![l1]); assert!(!app.snapshot("status").unwrap().changed); // A dirty nested repository is left alone and reported. write(&app_dir.join("libs/lib"), "scratch.txt", b"wip\n"); let r = app.checkout(&[a2], false, "checkout").unwrap(); assert!( r.notes.iter().any(|n| n.contains("uncommitted")), "{:?}", r.notes ); assert_eq!(fs::read(app_dir.join("libs/lib/lib.txt")).unwrap(), b"v1\n"); fs::remove_file(app_dir.join("libs/lib/scratch.txt")).unwrap(); let r = app.submodule_update(false, &[]).unwrap(); assert_eq!(r, vec![("libs/lib".to_string(), SubrepoSync::Updated)]); assert_eq!(fs::read(app_dir.join("libs/lib/lib.txt")).unwrap(), b"v2\n"); let _ = l2; // A fresh clone of the app has the pin but no nested repository until update --init. assert!(app.push("origin", false).unwrap()[0].ok); let clone_dir = root.join("app-clone"); let clone = Repo::clone(&app_url, &clone_dir) .unwrap() .with_home(root.join("home")); assert!(!clone_dir.join("libs/lib/.oxy").exists()); assert_eq!( clone.submodule_update(false, &[]).unwrap(), vec![("libs/lib".to_string(), SubrepoSync::Missing)] ); assert_eq!( clone.submodule_update(true, &[]).unwrap(), vec![("libs/lib".to_string(), SubrepoSync::Updated)] ); assert_eq!( fs::read(clone_dir.join("libs/lib/lib.txt")).unwrap(), b"v2\n" ); assert!(!clone.snapshot("status").unwrap().changed); // Removing the subrepo leaves the directory and drops the node. clone.submodule_remove("libs/lib").unwrap(); let snap = clone.snapshot("status").unwrap(); assert!( snap.statuses .iter() .any(|s| s.path == "libs/lib" && s.status == ChangeStatus::Removed), "{:?}", snap.statuses ); let c = clone.commit("drop lib", &[]).unwrap(); assert!(!clone .load_state(&[c]) .unwrap() .materialize() .files .contains_key("libs/lib")); assert!(clone_dir.join("libs/lib/lib.txt").exists()); assert!( !clone.snapshot("status").unwrap().changed, "an unregistered nested repository is ignored" ); // Registered again, it is pinned again; removed and committed, then // registered again with no snapshot between, the change that adds it // back pins it too: the removal's marker is spent (oxygen#152). let modules = format!("[libs/lib]\nurl = {lib_url}\n"); let pinned = |c: ChangeHash| { clone .load_state(&[c]) .unwrap() .materialize() .files .get("libs/lib") .map(|n| n.kind) }; write(&clone_dir, ".oxymodules", modules.as_bytes()); let c = clone.commit("lib again", &[]).unwrap(); assert_eq!(pinned(c), Some(NodeKind::Subrepo)); clone.submodule_remove("libs/lib").unwrap(); let c = clone.commit("drop lib again", &[]).unwrap(); assert_eq!(pinned(c), None); write(&clone_dir, ".oxymodules", modules.as_bytes()); let c = clone.commit("lib back", &[]).unwrap(); assert_eq!( pinned(c), Some(NodeKind::Subrepo), "pinned in the change that registers it" ); assert!(!clone.snapshot("status").unwrap().changed); } #[test] fn git_submodules_import_and_export_as_gitlinks() { hermetic(); if std::process::Command::new("git") .arg("--version") .output() .is_err() { eprintln!("git not installed; skipping"); return; } let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let git_in = |d: &Path, args: &[&str]| -> String { let out = std::process::Command::new("git") .arg("-C") .arg(d) .args([ "-c", "user.name=Grace", "-c", "user.email=grace@example.org", "-c", "commit.gpgsign=false", "-c", "protocol.file.allow=always", ]) .args(args) .output() .unwrap(); assert!( out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_string() }; let sub = root.join("sub"); fs::create_dir_all(&sub).unwrap(); git_in(&sub, &["init", "-q", "-b", "main"]); write(&sub, "s.txt", b"sub\n"); git_in(&sub, &["add", "."]); git_in(&sub, &["commit", "-q", "-m", "sub initial"]); let sub_sha = git_in(&sub, &["rev-parse", "HEAD"]); let g = root.join("main.git"); fs::create_dir_all(&g).unwrap(); git_in(&g, &["init", "-q", "-b", "main"]); write(&g, "README.md", b"# app\n"); git_in(&g, &["add", "."]); git_in(&g, &["commit", "-q", "-m", "initial"]); git_in( &g, &[ "submodule", "add", "-q", sub.to_str().unwrap(), "vendor/sub", ], ); git_in(&g, &["commit", "-q", "-m", "add submodule"]); let dest = root.join("oxy"); let repo = Repo::init(&dest).unwrap().with_home(root.join("home")); let rep = repo.import_git(&g, "main").unwrap(); assert_eq!((rep.commits, rep.submodules), (2, 1)); let files = repo .load_state(&repo.bookmark_heads().unwrap()) .unwrap() .materialize() .files; assert_eq!(files["vendor/sub"].kind, NodeKind::Subrepo); let FileContent::Opaque { blobs } = &files["vendor/sub"].content else { panic!() }; assert_eq!( crate::subrepo::Pin::decode(&repo.read_blob(&blobs[0]).unwrap()).unwrap(), crate::subrepo::Pin::Git { sha: sub_sha.clone(), branch: "HEAD".into() } ); let modules = crate::subrepo::Modules::parse(&fs::read_to_string(dest.join(".oxymodules")).unwrap()); assert_eq!(modules.entries["vendor/sub"].url, sub.to_string_lossy()); assert!(modules.entries["vendor/sub"].git); assert!( files.contains_key(".gitmodules"), "the Git file is kept for round trips" ); assert!( !dest.join("vendor/sub/.git").exists(), "not cloned until update --init" ); assert!(!repo.snapshot("status").unwrap().changed); // update --init clones it at the pinned commit; status is unchanged. assert_eq!( repo.submodule_update(true, &[]).unwrap(), vec![("vendor/sub".to_string(), SubrepoSync::Updated)] ); assert_eq!( git_in(&dest.join("vendor/sub"), &["rev-parse", "HEAD"]), sub_sha ); assert!(!repo.snapshot("status").unwrap().changed); // Export writes the gitlink and .gitmodules back. let mirror = root.join("mirror.git"); let rep = repo.git_export(&mirror).unwrap(); assert_eq!(rep.skipped_subrepos, 0); let ls = git_in(&mirror, &["ls-tree", "main", "vendor/sub"]); assert!( ls.starts_with("160000 commit ") && ls.contains(&sub_sha), "{ls}" ); assert_eq!( git_in(&mirror, &["ls-tree", "--name-only", "main"]), ".gitmodules\nREADME.md\nvendor" ); let orig_tree = git_in(&g, &["rev-parse", "main^{tree}"]); assert_eq!( git_in(&mirror, &["rev-parse", "main^{tree}"]), orig_tree, "tree round trip" ); } #[test] fn partial_commits_by_path_and_by_hunk() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"1\n2\n3\n"); write(root, "b.txt", b"b\n"); let h0 = repo.commit("base", &[]).unwrap(); write(root, "a.txt", b"0\n1\n2\n3\n4\n"); write(root, "b.txt", b"b\nbb\n"); write(root, "c.txt", b"c\n"); // By path: only a.txt goes in; the rest stays on disk as uncommitted work. let h1 = repo .commit_selection("a only", &[], &Selection::Paths(vec!["a.txt".into()])) .unwrap(); let c1 = repo.store().get_change(&h1).unwrap(); assert!( c1.tree_ops.is_empty() && c1.edits.iter().all(|e| e.node() == c1.edits[0].node()), "{:?}", c1.tree_ops ); assert_eq!(c1.deps, vec![h0]); let files = repo.load_state(&[h1]).unwrap().materialize().files; assert!( matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"0\n1\n2\n3\n4\n") ); assert!(matches!(&files["b.txt"].content, FileContent::Text { bytes, .. } if bytes == b"b\n")); assert!(!files.contains_key("c.txt")); let snap = repo.snapshot("status").unwrap(); assert_eq!( snap.statuses .iter() .map(|s| (s.path.as_str(), s.status)) .collect::>(), vec![ ("b.txt", ChangeStatus::Modified), ("c.txt", ChangeStatus::Added) ] ); assert_eq!( fs::read(root.join("b.txt")).unwrap(), b"b\nbb\n", "disk untouched" ); // By hunk: pick the b.txt insert; c.txt's addition is left. let (_, hunks) = repo.hunks().unwrap(); let b_hunk = hunks .iter() .find(|h| h.path == "b.txt" && h.text.contains("+bb")) .unwrap() .index; let h2 = repo .commit_selection("b only", &[], &Selection::Hunks(vec![b_hunk])) .unwrap(); let files = repo.load_state(&[h2]).unwrap().materialize().files; assert!( matches!(&files["b.txt"].content, FileContent::Text { bytes, .. } if bytes == b"b\nbb\n") ); assert!(!files.contains_key("c.txt")); let snap = repo.snapshot("status").unwrap(); assert_eq!( snap.statuses .iter() .map(|s| s.path.as_str()) .collect::>(), vec!["c.txt"] ); // Choosing only the content hunk of a file in a new directory pulls in // the directory and file additions it needs. write(root, "d/e.txt", b"e\n"); let (_, hunks) = repo.hunks().unwrap(); let e_insert = hunks .iter() .find(|h| h.path == "d/e.txt" && h.text.contains("+e")) .unwrap() .index; let h3 = repo .commit_selection("e only", &[], &Selection::Hunks(vec![e_insert])) .unwrap(); let c3 = repo.store().get_change(&h3).unwrap(); assert_eq!(c3.tree_ops.len(), 2, "{:?}", c3.tree_ops); let files = repo.load_state(&[h3]).unwrap().materialize().files; assert!(files.contains_key("d/e.txt") && !files.contains_key("c.txt")); // Choosing a new file's addition hunk brings its content along. write(root, "f/g.txt", b"g\n"); let (_, hunks) = repo.hunks().unwrap(); let g_add = hunks .iter() .find(|h| h.path == "f/g.txt" && h.text.starts_with("A ")) .unwrap() .index; let h4 = repo .commit_selection("g by addition", &[], &Selection::Hunks(vec![g_add])) .unwrap(); let files = repo.load_state(&[h4]).unwrap().materialize().files; assert!( matches!(&files["f/g.txt"].content, FileContent::Text { bytes, .. } if bytes == b"g\n") ); assert!(!repo .snapshot("status") .unwrap() .statuses .iter() .any(|s| s.path == "f/g.txt")); // Everything left commits normally; the working copy is then clean. repo.commit("the rest", &[]).unwrap(); assert!(!repo.snapshot("status").unwrap().changed); let files = repo .load_state(&repo.bookmark_heads().unwrap()) .unwrap() .materialize() .files; assert!(files.contains_key("c.txt")); } #[test] fn blame_attributes_lines_to_introducing_changes_across_renames() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); repo.create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); write(root, "a.txt", b"one\n"); let h1 = repo.commit("first", &[]).unwrap(); write(root, "a.txt", b"one\ntwo\n"); let h2 = repo.commit("second", &[]).unwrap(); let b = repo.blame("a.txt").unwrap(); assert_eq!( b.iter() .map(|l| (l.line, l.hash, l.text.as_slice())) .collect::>(), vec![(1, h1, b"one\n".as_slice()), (2, h2, b"two\n".as_slice())] ); assert!(b.iter().all(|l| l.author == "Ada")); assert_eq!( b[0].change_id, repo.store().get_change(&h1).unwrap().change_id ); fs::rename(root.join("a.txt"), root.join("b.txt")).unwrap(); repo.commit("rename", &[]).unwrap(); let b = repo.blame("b.txt").unwrap(); assert_eq!( b.iter().map(|l| l.hash).collect::>(), vec![h1, h2], "the node survives the rename" ); assert!(repo.blame("a.txt").is_err()); } #[test] fn bisect_finds_the_first_bad_change() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); let mut content = String::new(); let mut hashes = Vec::new(); for i in 1..=8 { content.push_str(&format!("line {i}{}\n", if i == 5 { " bug" } else { "" })); write(root, "f.txt", content.as_bytes()); hashes.push(repo.commit(&format!("step {i}"), &[]).unwrap()); } // Each step appends after the previous line, so the chain is linear. assert_eq!(repo.bookmark_heads().unwrap(), vec![hashes[7]]); repo.bisect_start().unwrap(); assert!(matches!( repo.bisect_mark(Some(true), &[hashes[0]]).unwrap(), BisectStep::NeedMarks { good: 1, bad: 0 } )); let step = repo.bisect_mark(Some(false), &[hashes[7]]).unwrap(); assert!( matches!(step, BisectStep::Testing { remaining: 7, .. }), "{step:?}" ); // Automated: bad when the bug line is present. let step = repo .bisect_run(&["sh".into(), "-c".into(), "! grep -q bug f.txt".into()]) .unwrap(); assert_eq!(step, BisectStep::Found { culprit: hashes[4] }); assert_eq!( repo.bisect_status().unwrap(), Some(BisectStep::Found { culprit: hashes[4] }) ); repo.bisect_reset().unwrap(); assert_eq!(fs::read_to_string(root.join("f.txt")).unwrap(), content); assert!(repo.bisect_status().unwrap().is_none()); assert!(!repo.snapshot("status").unwrap().changed); // Manual marks and a skip. repo.bisect_start().unwrap(); repo.bisect_mark(Some(true), &[hashes[0]]).unwrap(); let mut step = repo.bisect_mark(Some(false), &[hashes[7]]).unwrap(); let mut skipped_once = false; let mut guard = 0; while let BisectStep::Testing { .. } = step { guard += 1; assert!(guard < 20); let text = fs::read_to_string(root.join("f.txt")).unwrap(); if !skipped_once && text.contains("line 6") { // An untestable state away from the boundary: the search routes around it. skipped_once = true; step = repo.bisect_mark(None, &[]).unwrap(); continue; } step = repo.bisect_mark(Some(!text.contains("bug")), &[]).unwrap(); } assert!(skipped_once); assert_eq!(step, BisectStep::Found { culprit: hashes[4] }); repo.bisect_reset().unwrap(); // Skipping the culprit itself leaves it among the candidates reported. repo.bisect_start().unwrap(); repo.bisect_mark(Some(true), &[hashes[0]]).unwrap(); let mut step = repo.bisect_mark(Some(false), &[hashes[7]]).unwrap(); let mut guard = 0; while let BisectStep::Testing { heads, .. } = &step { guard += 1; assert!(guard < 20); let text = fs::read_to_string(root.join("f.txt")).unwrap(); let is_culprit_state = heads.contains(&hashes[4]) && !heads.iter().any(|h| hashes[5..].contains(h)); step = if is_culprit_state { repo.bisect_mark(None, &[]).unwrap() } else { repo.bisect_mark(Some(!text.contains("bug")), &[]).unwrap() }; } match step { BisectStep::Inconclusive { candidates } => assert!( candidates.contains(&hashes[4]) && candidates.len() <= 2, "{candidates:?}" ), other => panic!("{other:?}"), } repo.bisect_reset().unwrap(); } #[test] fn squash_folds_a_change_into_its_dependency() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"1\n"); let a = repo.commit("A", &[]).unwrap(); write(root, "a.txt", b"1\n2\n"); let c = repo.commit("C", &[]).unwrap(); write(root, "a.txt", b"1\n2\n3\n"); let e = repo.commit("E", &[]).unwrap(); write(root, "b.txt", b"b\n"); let b = repo.commit("B", &[]).unwrap(); let id_a = repo.store().get_change(&a).unwrap().change_id; let id_e = repo.store().get_change(&e).unwrap().change_id; // E depends on C which A lacks: refused, naming it. let err = repo.squash(&e, &a).unwrap_err().to_string(); assert!(err.contains(&c.short(12)), "{err}"); // C into A: A' has both, C is gone, E follows and still reads the same. let a2 = repo.squash(&c, &a).unwrap(); let a2c = repo.store().get_change(&a2).unwrap(); assert_eq!(a2c.change_id, id_a); assert!(a2c.deps.is_empty()); let heads = repo.bookmark_heads().unwrap(); let st = repo.load_state(&heads).unwrap(); assert!(!st.contains(&c) && !st.contains(&a) && !st.contains(&e)); let e2 = st .changes() .find(|(_, m)| m.change_id == id_e) .map(|(h, _)| *h) .unwrap(); assert_eq!( repo.store().get_change(&e2).unwrap().deps, vec![a2], "E now depends on A' alone" ); let files = st.materialize().files; assert!( matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"1\n2\n3\n") ); assert!(files.contains_key("b.txt")); assert!(st.contains(&b)); assert!(!repo.snapshot("status").unwrap().changed); assert_eq!(repo.log().unwrap().len(), 4, "working copy, B, E', A'"); // Undo restores the three original changes. repo.undo().unwrap(); repo.undo().unwrap(); let st = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap(); assert!(st.contains(&c) && st.contains(&a) && st.contains(&e)); } #[test] fn rebase_re_expresses_a_change_against_another_base() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"1\n2\n"); let a = repo.commit("A", &[]).unwrap(); write(root, "a.txt", b"1\n2\n3\n"); let c = repo.commit("C: append 3", &[]).unwrap(); write(root, "a.txt", b"1\n2\n3\n4\n"); let e = repo.commit("E: append 4", &[]).unwrap(); // A different base: A plus a change that prepends a line. repo.checkout(&[a], true, "checkout").unwrap(); write(root, "a.txt", b"0\n1\n2\n"); let d = repo.commit("D: prepend 0", &[]).unwrap(); let id_c = repo.store().get_change(&c).unwrap().change_id; let id_e = repo.store().get_change(&e).unwrap().change_id; // Rebase C onto D: its effect (append 3) re-derived against D's text. let c2 = repo.rebase(&c, &[d]).unwrap(); let c2c = repo.store().get_change(&c2).unwrap(); assert_eq!(c2c.change_id, id_c); assert!(c2c.deps.contains(&d), "{:?}", c2c.deps); let heads = repo.bookmark_heads().unwrap(); let st = repo.load_state(&heads).unwrap(); assert!(!st.contains(&c) && st.contains(&d)); let e2 = st .changes() .find(|(_, m)| m.change_id == id_e) .map(|(h, _)| *h) .unwrap(); let e2deps = repo.store().get_change(&e2).unwrap().deps; assert!( e2deps.contains(&c2) && !e2deps.contains(&c) && e2deps.contains(&a), "E follows C by content and still depends on A, which introduced the file: {e2deps:?}" ); let files = st.materialize().files; assert!( matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"0\n1\n2\n3\n4\n"), "{:?}", files["a.txt"].content ); assert!(!files["a.txt"].is_conflicted()); // Rebasing onto a state that already contains the change is refused. assert!(repo.rebase(&c2, &[c2]).is_err()); let err = repo.rebase(&e2, &[d]).unwrap_err().to_string(); assert!( err.contains("lacks dependencies"), "E needs C, which D's state does not have: {err}" ); } #[test] fn unsigned_tags_keep_their_message() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"a\n"); repo.commit("a", &[]).unwrap(); assert!(repo.secret_key().unwrap().is_none()); repo.tag_create("v1", &repo.bookmark_heads().unwrap(), "first release") .unwrap(); let tags = repo.tags().unwrap(); assert_eq!( ( tags[0].name.as_str(), tags[0].message.as_str(), tags[0].signer.as_deref() ), ("v1", "first release", None) ); assert!( repo.tag_refs().unwrap()[0].attestation.is_none(), "an unsigned attestation is not advertised" ); } #[test] fn fsck_passes_a_sound_repository_and_names_each_fault() { // Files on disk are what this tests: tampering with them, and the // directory store's crash points. if std::env::var_os("OXY_STORE_BACKEND").is_some() { return; } hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_path = root.join("origin"); let origin = Repo::init(&origin_path).unwrap(); let mut a = Repo::clone(&origin_path.to_string_lossy(), &root.join("a")) .unwrap() .with_home(root.join("home-a")); a.create_identity("Ada", "ada@example.org", IdentityScope::Repo) .unwrap(); write(&root.join("a"), "f.txt", b"one\n"); write(&root.join("a"), "bin.dat", b"\0\x01\x02binary"); let h1 = a.commit("first", &[]).unwrap(); write(&root.join("a"), "f.txt", b"one\ntwo\n"); let h2 = a.commit("second", &[]).unwrap(); a.tag_create("v1", &[h2], "first release").unwrap(); a.checkpoint().unwrap(); assert!(a.push("origin", false).unwrap()[0].ok); origin.refresh().unwrap(); // Sound: signed changes, a seal, a tag, a checkpoint, a ref attestation. for repo in [&a, &origin] { let rep = repo.fsck().unwrap(); assert_eq!(rep.problems, vec![], "{rep:?}"); // The two commits, plus the working copy's change in `a`. assert!(rep.changes >= 2 && rep.signed >= 2, "{rep:?}"); assert!(rep.ops > 1 && rep.objects > 5); } assert!(origin.fsck().unwrap().attestations >= 1); // A flipped byte in a stored object is corruption, named by hash. let c1 = a.store().path_for(&h1); let pristine = fs::read(&c1).unwrap(); let mut bytes = pristine.clone(); *bytes.last_mut().unwrap() ^= 1; fs::write(&c1, &bytes).unwrap(); let rep = a.fsck().unwrap(); assert!(rep.errors() >= 1); assert!( rep.problems .iter() .any(|p| p.object == Some(h1) && p.what.contains("unreadable")), "{rep:?}" ); fs::write(&c1, &pristine).unwrap(); assert_eq!(a.fsck().unwrap().errors(), 0); // A missing change is named with what needed it. fs::remove_file(&c1).unwrap(); let rep = a.fsck().unwrap(); assert!( rep.problems.iter().any(|p| p.severity == Severity::Error && p.object == Some(h1) && p.what.contains("change missing, needed by change")), "{rep:?}" ); fs::write(&c1, &pristine).unwrap(); // A missing blob is an error in a full clone. let c2 = a.store().get_change(&h1).unwrap(); let blob = c2 .edits .iter() .find_map(|e| match e { Edit::Replace { new: Some(b), .. } => Some(*b), _ => None, }) .expect("the binary file is a blob"); let bp = a.store().path_for(&blob); let blob_bytes = fs::read(&bp).unwrap(); fs::remove_file(&bp).unwrap(); let rep = a.fsck().unwrap(); assert!( rep.problems .iter() .any(|p| p.object == Some(blob) && p.what.contains("blob missing")), "{rep:?}" ); fs::write(&bp, &blob_bytes).unwrap(); // A broken op log head is reported, not a crash. let head = root.join("a/.oxy/ops/head"); let saved = fs::read(&head).unwrap(); fs::write(&head, b"not a hash").unwrap(); let rep = a.fsck().unwrap(); assert!( rep.problems.iter().any(|p| p.what.contains("ops/head")), "{rep:?}" ); fs::write(&head, saved).unwrap(); assert_eq!(a.fsck().unwrap().problems, vec![]); } /// Copy a directory tree (for restoring a pristine repository). fn copy_tree(from: &Path, to: &Path) { fs::create_dir_all(to).unwrap(); for e in fs::read_dir(from).unwrap() { let e = e.unwrap(); let target = to.join(e.file_name()); if e.file_type().unwrap().is_dir() { copy_tree(&e.path(), &target); } else { fs::copy(e.path(), &target).unwrap(); } } } /// Spec §13.6: stop `commit` and `push` at every durable step, as a crash /// would, and check that both repositories still open, pass `fsck`, and /// accept the same operation again. #[test] fn crash_at_every_step_of_commit_and_push_leaves_sound_repositories() { // Files on disk are what this tests: tampering with them, and the // directory store's crash points. if std::env::var_os("OXY_STORE_BACKEND").is_some() { return; } use crate::durable::failpoint; hermetic(); let dir = tempfile::tempdir().unwrap(); let pristine = dir.path().join("pristine"); let origin_path = pristine.join("origin"); Repo::init(&origin_path).unwrap(); let mut a = Repo::clone(&origin_path.to_string_lossy(), &pristine.join("a")) .unwrap() .with_home(dir.path().join("home")); a.create_identity("Ada", "ada@example.org", IdentityScope::Repo) .unwrap(); write(&pristine.join("a"), "f.txt", b"one\n"); a.commit("first", &[]).unwrap(); assert!(a.push("origin", false).unwrap()[0].ok); drop(a); type Op = fn(&Repo) -> Result<()>; let edit_and_commit: Op = |r| { write(r.root(), "f.txt", b"one\ntwo\n"); write(r.root(), "big.bin", &vec![7u8; 300_000]); r.commit("second", &[]).map(|_| ()) }; let push: Op = |r| { let res = r.push("origin", false)?; if res.iter().all(|x| x.ok) { Ok(()) } else { Err(RepoError::Msg(format!("{res:?}"))) } }; for (name, prepare, op) in [ ("commit", None, edit_and_commit), ("push", Some(edit_and_commit), push), ] { let mut steps = 0; for n in 1.. { let run = dir.path().join(format!("{name}-{n}")); copy_tree(&pristine, &run); // Clones record their remote by path; point it at this copy. let open = |p: &Path| { let mut r = Repo::open(p).unwrap().with_home(dir.path().join("home")); r.config .set("remote.origin.url", &run.join("origin").to_string_lossy()) .unwrap(); r.save_config().unwrap(); r }; let a = open(&run.join("a")); if let Some(prep) = prepare { prep(&a).unwrap(); } failpoint::crash_after(n); let outcome = op(&a); let crashed = failpoint::disarm(); drop(a); if !crashed { outcome.unwrap_or_else(|e| panic!("{name} failed without a crash: {e}")); steps = n - 1; break; } assert!( outcome.is_err(), "{name}: a crash at step {n} was swallowed" ); for repo in ["a", "origin"] { let r = Repo::open(&run.join(repo)).unwrap_or_else(|e| { panic!("{name} crash at step {n}: {repo} no longer opens: {e}") }); let rep = r.fsck().unwrap(); assert_eq!( rep.errors(), 0, "{name} crash at step {n}: {repo} fails fsck: {:?}", rep.problems ); } // The interrupted operation can simply be run again. let a = open(&run.join("a")); // A crash after the commit point leaves the commit done. let landed = a .bookmark_heads() .unwrap() .iter() .any(|h| a.store().get_change(h).unwrap().message == "second"); let again = match (name, landed) { ("commit", true) => Ok(()), ("commit", false) => a.commit("second", &[]).map(|_| ()), _ => op(&a), }; again.unwrap_or_else(|e| panic!("{name} crash at step {n}: retry failed: {e}")); assert_eq!(a.fsck().unwrap().errors(), 0); fs::remove_dir_all(&run).unwrap(); } assert!(steps >= 3, "{name} has only {steps} durable steps?"); } } /// Spec §12.1: commands in two workspaces of one repository, and pushes, /// running at the same time lose nothing: every commit ends up on the /// bookmark, locally and on the remote, and the repository passes fsck. #[test] fn concurrent_commits_in_two_workspaces_lose_nothing() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = root.join("origin"); Repo::init_bare_unbound(&origin).unwrap(); let main = root.join("main"); let mut repo = Repo::init(&main).unwrap(); repo.config .set("remote.origin.url", &origin.to_string_lossy()) .unwrap(); repo.save_config().unwrap(); write(&main, "seed.txt", b"seed\n"); repo.commit("seed", &[]).unwrap(); let second = root.join("second"); repo.workspace_add(&second, None).unwrap(); drop(repo); const EACH: usize = 15; std::thread::scope(|s| { for (tag, dir, push) in [("a", main.clone(), true), ("b", second.clone(), false)] { s.spawn(move || { let r = Repo::open(&dir).unwrap(); for i in 0..EACH { write( &dir, &format!("{tag}-{i}.txt"), format!("{tag} {i}\n").as_bytes(), ); r.commit(&format!("{tag} {i}"), &[]).unwrap(); if push && i % 5 == 4 { // A push may be refused while the other workspace moves the bookmark; nothing may be lost either way. let _ = r.push("origin", false); } } }); } }); let repo = Repo::open(&main).unwrap(); let heads = repo.bookmark_heads().unwrap(); let state = repo.load_state(&heads).unwrap(); let messages: BTreeSet = state.changes().map(|(_, m)| m.message.clone()).collect(); for tag in ["a", "b"] { for i in 0..EACH { assert!( messages.contains(&format!("{tag} {i}")), "lost commit {tag} {i}; have {messages:?}" ); } } let rep = repo.fsck().unwrap(); assert_eq!(rep.errors(), 0, "{:?}", rep.problems); assert!(repo.push("origin", false).unwrap().iter().all(|r| r.ok)); let hosted = Repo::open(&origin).unwrap(); let hosted_heads = hosted.bookmark_heads().unwrap(); assert_eq!( hosted.load_state(&hosted_heads).unwrap().len(), state.len(), "the remote has every commit" ); } /// Two operations recorded from the same parent (what two processes do /// when they race) are merged: both bookmarks' heads survive, and a /// change made on one side only is kept. #[test] fn operations_from_the_same_parent_are_merged() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"a\n"); let a = repo.commit("a", &[]).unwrap(); write(root, "b.txt", b"b\n"); let b = repo.commit("b", &[]).unwrap(); let (x, op) = repo.head_op().unwrap(); let state = |heads: Vec| repo.store().put_state(&StateObject::new(heads)).unwrap(); // Side one moves main to just `a` and adds a bookmark; side two moves main to just `b`. let mut one = op.view.clone(); one.bookmarks.insert("main".into(), state(vec![a])); one.bookmarks.insert("topic".into(), state(vec![a])); let mut two = op.view.clone(); two.bookmarks.insert("main".into(), state(vec![b])); repo.record_op(&[x], "one", one).unwrap(); let merged = repo.record_op(&[x], "two", two).unwrap(); let (head, op) = repo.head_op().unwrap(); assert_eq!(head, merged); assert_eq!(op.parents.len(), 2, "a merge of the two operations"); assert!(op.command.contains("merged with a concurrent operation")); let mut main = repo .store() .get_state(&op.view.bookmarks["main"]) .unwrap() .heads; main.sort(); let mut want = vec![a, b]; want.sort(); assert_eq!(main, want, "both sides' heads are kept"); assert!( op.view.bookmarks.contains_key("topic"), "a one-sided change survives" ); assert_eq!(repo.fsck().unwrap().errors(), 0); } #[test] fn failed_requests_come_with_a_next_step() { use crate::protocol::hint_for; assert!(hint_for("https://h/x/oxy/v1/push: HTTP 401: bad token") .unwrap() .contains("token")); assert!(hint_for("…: HTTP 403: forbidden") .unwrap() .contains("repo:write")); assert!(hint_for("…: HTTP 404: no such repository") .unwrap() .contains("owner and repository name")); assert!(hint_for("invalid peer certificate: UnknownIssuer") .unwrap() .contains("oxyhub-ca.pem")); assert!(hint_for("Connect error: Connection refused (os error 111)") .unwrap() .contains("running")); assert!(hint_for("Dns Failed: failed to lookup address information") .unwrap() .contains("resolve")); assert_eq!(hint_for("something else entirely"), None); } #[test] fn doctor_names_problems_and_what_to_do() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = root.join("origin"); Repo::init_bare_unbound(&origin).unwrap(); let mut repo = Repo::init(&root.join("r")) .unwrap() .with_home(root.join("home")); let find = |r: &DoctorReport, area: &str, h: Health| { r.checks .iter() .find(|c| c.area == area && c.health == h) .cloned() }; let r = repo.doctor(true); assert!(find(&r, "identity", Health::Warn) .unwrap() .hint .unwrap() .contains("oxy identity create")); assert!(find(&r, "repository", Health::Ok).is_some()); repo.create_identity("Ada", "ada@example.org", IdentityScope::Repo) .unwrap(); repo.config.set("author.name", "Ada").unwrap(); repo.config.set("author.email", "ada@example.org").unwrap(); repo.config .set("remote.origin.url", &origin.to_string_lossy()) .unwrap(); repo.config .set("remote.down.url", "http://127.0.0.1:9/a/b") .unwrap(); repo.config.set("remote.down.token", "t").unwrap(); repo.config .set("remote.badca.url", "https://example.invalid/a/b") .unwrap(); repo.config .set( "remote.badca.ca", &root.join("missing.pem").to_string_lossy(), ) .unwrap(); repo.save_config().unwrap(); let r = repo.doctor(true); assert!(find(&r, "identity", Health::Ok).is_some(), "{r:?}"); assert!(find(&r, "author", Health::Ok).is_some(), "{r:?}"); assert!(find(&r, "remote origin", Health::Ok) .unwrap() .message .contains("first push binds it")); let down = find(&r, "remote down", Health::Fail).unwrap(); assert!( down.hint.as_deref().unwrap_or("").contains("running"), "{down:?}" ); let badca = find(&r, "remote badca", Health::Fail).unwrap(); assert!( badca.hint.unwrap().contains("remote.badca.ca"), "the remote's own name is in the hint" ); assert_eq!(r.worst(), Health::Fail); // Offline, nothing is contacted. let r = repo.doctor(false); assert!(find(&r, "remote down", Health::Fail).is_none()); assert!(find(&r, "remote down", Health::Ok) .unwrap() .message .contains("not contacted")); } /// Spec §8.1: deleting a line depends on whoever inserted the lines right /// after it. Inserting after `b` and then deleting `b`, one commit after /// the other, must not leave a delete/insert conflict. #[test] fn deleting_a_line_after_an_insertion_below_it_is_not_a_conflict() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "f", b"a\nb\nc\n"); repo.commit("base", &[]).unwrap(); write(root, "f", b"a\nb\nx\nc\n"); let insert = repo.commit("insert x after b", &[]).unwrap(); write(root, "f", b"a\nx\nc\n"); let delete = repo.commit("delete b", &[]).unwrap(); assert!( repo.store() .get_change(&delete) .unwrap() .deps .contains(&insert), "the deletion depends on the insertion below the deleted line" ); let heads = repo.bookmark_heads().unwrap(); assert_eq!(heads, vec![delete]); let snap = repo.load_state(&heads).unwrap().materialize(); assert!(!snap.is_conflicted()); assert!( matches!(&snap.files["f"].content, FileContent::Text { bytes, .. } if bytes == b"a\nx\nc\n") ); } /// Spec §9.3: a binary file reverted to an earlier blob keeps it, and each /// replacement depends on the change that set the blob it replaces. #[test] fn a_binary_file_reverted_to_an_earlier_blob_keeps_it() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "bin", b"\0A"); let v1 = repo.commit("v1", &[]).unwrap(); write(root, "bin", b"\0B"); let v2 = repo.commit("v2", &[]).unwrap(); assert!(repo.store().get_change(&v2).unwrap().deps.contains(&v1)); write(root, "bin", b"\0A"); let v3 = repo.commit("back to A", &[]).unwrap(); assert!(repo.store().get_change(&v3).unwrap().deps.contains(&v2)); assert_eq!(repo.bookmark_heads().unwrap(), vec![v3]); let state = repo.load_state(&[v3]).unwrap(); let snap = state.materialize(); let FileContent::Opaque { blobs } = &snap.files["bin"].content else { panic!("opaque") }; assert_eq!(blobs.len(), 1, "one live blob, not none and not two"); assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\0A"); } /// Spec §23: a binary file changed on two branches and merged in Git to /// one side's version comes back as that version at the merge, and the /// import verifies commit by commit. #[test] fn git_import_resolves_a_binary_merged_in_git_and_verifies() { hermetic(); if std::process::Command::new("git") .arg("--version") .output() .is_err() { eprintln!("git not installed; skipping"); return; } let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let g = root.join("src.git"); fs::create_dir_all(&g).unwrap(); let git = |args: &[&str]| -> String { let out = std::process::Command::new("git") .arg("-C") .arg(&g) .args([ "-c", "user.name=Grace", "-c", "user.email=grace@example.org", "-c", "commit.gpgsign=false", ]) .args(args) .output() .unwrap(); assert!( out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_string() }; git(&["init", "-q", "-b", "main"]); write(&g, "logo.bin", b"\0base"); write(&g, "notes.txt", b"a\nb\nc\n"); git(&["add", "."]); git(&["commit", "-qm", "base"]); git(&["checkout", "-qb", "side"]); write(&g, "logo.bin", b"\0side"); write(&g, "notes.txt", b"a\nb\nx\nc\n"); git(&["commit", "-qam", "side"]); git(&["checkout", "-q", "main"]); write(&g, "logo.bin", b"\0main"); git(&["commit", "-qam", "main"]); // Git cannot merge the binary; the merge takes main's and keeps side's text. let _ = std::process::Command::new("git") .arg("-C") .arg(&g) .args([ "-c", "user.name=G", "-c", "user.email=g@e", "merge", "-q", "side", ]) .output(); write(&g, "logo.bin", b"\0main"); git(&["add", "."]); git(&["commit", "-qm", "merge side"]); // Then a revert of the binary to its first version. write(&g, "logo.bin", b"\0base"); git(&["commit", "-qam", "back to base"]); let repo = Repo::init(&root.join("o")).unwrap(); let rep = repo.import_git(&g, "main").unwrap(); assert_eq!(rep.merges, 1); let v = repo.verify_git_import(&g, "main").unwrap(); assert!(v.mismatches.is_empty(), "{:?}", v.mismatches); assert_eq!(v.commits, 5); let snap = repo .load_state(&repo.bookmark_heads().unwrap()) .unwrap() .materialize(); assert!(!snap.is_conflicted()); let FileContent::Opaque { blobs } = &snap.files["logo.bin"].content else { panic!("opaque") }; assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\0base"); } /// Spec §7.7 on a file system without executable bits or symlinks: the /// recorded bit is kept, and a symlink checked out as a plain file holding /// its target is still that symlink. #[cfg(unix)] #[test] fn windows_file_systems_keep_exec_bits_and_symlinks() { use crate::portable::Platform; use std::os::unix::fs::PermissionsExt; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "run.sh", b"#!/bin/sh\n"); fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap(); std::os::unix::fs::symlink("run.sh", root.join("link")).unwrap(); repo.commit("script and link", &[]).unwrap(); drop(repo); // What checking out on Windows leaves: no exec bit, the link as a file. fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o644)).unwrap(); fs::remove_file(root.join("link")).unwrap(); write(root, "link", b"run.sh"); let win = Repo::open(root).unwrap().with_platform(Platform::windows()); let s = win.snapshot("oxy status").unwrap(); assert!( s.statuses.is_empty(), "nothing changed on Windows: {:?}", statuses(&s) ); // Editing the file is a content change, not a mode change; retargeting the link keeps it a link. write(root, "run.sh", b"#!/bin/sh\necho hi\n"); write(root, "link", b"other"); let h = win.commit("edit on Windows", &[]).unwrap(); let snap = win.load_state(&[h]).unwrap().materialize(); assert!(snap.files["run.sh"].mode.exec, "the exec bit survives"); assert_eq!(snap.files["link"].kind, NodeKind::Symlink); // The same working copy read as Linux sees the lost bit and the plain file. let linux = Repo::open(root).unwrap(); assert!(!linux.snapshot("oxy status").unwrap().statuses.is_empty()); } /// Paths that collide on case-insensitive or Windows file systems are /// named at checkout there, and by doctor everywhere. #[test] fn paths_other_platforms_cannot_hold_are_reported() { use crate::portable::Platform; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "README.md", b"a\n"); write(root, "readme.md", b"b\n"); write(root, "src/aux.c", b"int x;\n"); let h = repo.commit("unportable", &[]).unwrap(); let r = repo.doctor(false); let port = r.checks.iter().find(|c| c.area == "portability").unwrap(); assert_eq!(port.health, Health::Warn); assert!( port.message.contains("differ only in case") && port.message.contains("aux.c"), "{}", port.message ); let mac = Repo::open(root).unwrap().with_platform(Platform::macos()); let rep = mac.checkout(&[h], true, "oxy checkout").unwrap(); assert!( rep.notes .iter() .any(|n| n.contains("README.md and readme.md")), "{:?}", rep.notes ); assert!( !rep.notes.iter().any(|n| n.contains("aux.c")), "macOS allows aux.c" ); } #[test] fn history_and_blame_of_a_loaded_state_match_the_full_walk() { hermetic(); let dir = tempfile::tempdir().unwrap(); let repo = Repo::init(dir.path()).unwrap(); write(dir.path(), "a.txt", b"one\n"); write(dir.path(), "other.txt", b"x\n"); write(dir.path(), "bin.dat", &[0u8, 1, 2, 0, 255]); repo.snapshot("t").unwrap(); let c1 = repo.commit("first", &[]).unwrap(); write(dir.path(), "other.txt", b"y\n"); repo.snapshot("t").unwrap(); repo.commit("other only", &[]).unwrap(); write(dir.path(), "a.txt", b"one\ntwo\n"); repo.snapshot("t").unwrap(); let c3 = repo.commit("second line", &[]).unwrap(); write(dir.path(), "other.txt", b"z\n"); repo.snapshot("t").unwrap(); repo.commit("other again", &[]).unwrap(); let heads = repo.bookmark_heads().unwrap(); let state = repo.load_state(&heads).unwrap(); let full: Vec = repo .log_for_path_in("a.txt", &heads) .unwrap() .iter() .map(|e| e.hash) .collect(); assert_eq!( full, vec![c3, c1], "newest first, only changes touching the path" ); let newest: Vec = repo .log_for_path_of(&state, "a.txt", Some(1)) .unwrap() .iter() .map(|e| e.hash) .collect(); assert_eq!(newest, vec![c3], "stops at the first"); assert_eq!( repo.log_for_path_of(&state, "a.txt", None).unwrap().len(), 2 ); assert!(repo .log_for_path_of(&state, "missing.txt", Some(1)) .is_err()); assert_eq!( repo.log_state(&heads).unwrap().len(), repo.log_of(&state).len() ); let tree = state.tree().view(); let via_state = repo.blame_of(&state, &tree, "a.txt").unwrap(); let via_heads = repo.blame_in("a.txt", &heads).unwrap(); assert_eq!( via_state .iter() .map(|l| (l.hash, l.text.clone())) .collect::>(), via_heads .iter() .map(|l| (l.hash, l.text.clone())) .collect::>() ); assert_eq!( via_state.iter().map(|l| l.hash).collect::>(), vec![c1, c3] ); assert!(repo.blame_of(&state, &tree, "missing.txt").is_err()); } /// A text file whose stat matches the manifest and whose recorded hash is /// the base's content is taken from the base without reading it; anything /// else is read, so no edit is missed (spec §12.3). #[cfg(unix)] #[test] fn stat_cache_takes_unchanged_text_from_the_base_and_misses_no_edit() { use std::os::unix::fs::PermissionsExt; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"one\ntwo\n"); write(root, "b.txt", b"bee\n"); repo.commit("base", &[]).unwrap(); let age = |p: &str| { let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30); fs::File::options() .write(true) .open(root.join(p)) .unwrap() .set_modified(old) .unwrap(); }; age("a.txt"); age("b.txt"); assert!( !repo.snapshot("status").unwrap().changed, "records the aged stat" ); // Clean and equal to the base: not read at all. fs::set_permissions(root.join("a.txt"), fs::Permissions::from_mode(0o000)).unwrap(); let snap = repo.snapshot("status").unwrap(); assert!( !snap.changed && snap.statuses.is_empty(), "{:?}", snap.statuses ); fs::set_permissions(root.join("a.txt"), fs::Permissions::from_mode(0o644)).unwrap(); // An uncommitted edit that is older than the manifest: stat-clean, but // its hash is not the base's, so it is read and stays visible. write(root, "b.txt", b"bee edited\n"); age("b.txt"); let first = repo.snapshot("status").unwrap(); assert_eq!( statuses(&first), vec![("b.txt".to_string(), ChangeStatus::Modified)] ); let again = repo.snapshot("status").unwrap(); assert_eq!( statuses(&again), vec![("b.txt".to_string(), ChangeStatus::Modified)], "still seen once stat-clean" ); assert!(!again.changed, "and the working copy hashes the same"); fs::set_permissions(root.join("b.txt"), fs::Permissions::from_mode(0o000)).unwrap(); assert!( repo.snapshot("status").is_err(), "an edited file must be read, never taken from the base" ); fs::set_permissions(root.join("b.txt"), fs::Permissions::from_mode(0o644)).unwrap(); // A same-size edit made just now is racy and read. write(root, "a.txt", b"one\nTWO\n"); assert_eq!( statuses(&repo.snapshot("status").unwrap()), vec![ ("a.txt".to_string(), ChangeStatus::Modified), ("b.txt".to_string(), ChangeStatus::Modified) ] ); // Committing makes the edits the base: both are clean and served from it again. repo.commit("edits", &[]).unwrap(); age("a.txt"); age("b.txt"); assert!(repo.snapshot("status").unwrap().statuses.is_empty()); fs::set_permissions(root.join("a.txt"), fs::Permissions::from_mode(0o000)).unwrap(); fs::set_permissions(root.join("b.txt"), fs::Permissions::from_mode(0o000)).unwrap(); let snap = repo.snapshot("status").unwrap(); assert!(!snap.changed && snap.statuses.is_empty()); fs::set_permissions(root.join("a.txt"), fs::Permissions::from_mode(0o644)).unwrap(); fs::set_permissions(root.join("b.txt"), fs::Permissions::from_mode(0o644)).unwrap(); } /// Version 2 checkpoints (spec §11): a large file's graph is a blob of its /// own, kept by gc, checked by fsck, shared by the next checkpoint when the /// file is unchanged, and loading through the checkpoint equals a full load. #[test] fn checkpoints_keep_large_graphs_as_blobs_of_their_own() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); let long: String = (0..300) .map(|i| format!("line {i} of a long file\n")) .collect(); write(root, "long.txt", long.as_bytes()); write(root, "short.txt", b"short\n"); repo.commit("base", &[]).unwrap(); let (r1, _, _) = repo.checkpoint().unwrap(); let cp1 = crate::objects::Checkpoint::decode( &repo.store().get_typed(&r1, ObjectType::Checkpoint).unwrap(), ) .unwrap(); let graphs1 = repo.checkpoint_graph_blobs(&cp1).unwrap(); assert_eq!(graphs1.len(), 1, "only the long file's graph is a blob"); // gc keeps the graph blob; fsck finds the checkpoint sound. repo.gc(true).unwrap(); assert!(repo.store().has(&graphs1[0])); let report = repo.fsck().unwrap(); assert_eq!(report.errors(), 0, "{:?}", report.problems); // Loading through the checkpoint equals a full load. write(root, "short.txt", b"short, edited\n"); repo.commit("edit the short file", &[]).unwrap(); let heads = repo.bookmark_heads().unwrap(); let fast = repo.load_state(&heads).unwrap(); let full = repo.load_state_full(&heads).unwrap(); assert_eq!(fast.checkpoint_payload(), full.checkpoint_payload()); // The next checkpoint shares the unchanged long file's graph. let (r2, _, _) = repo.checkpoint().unwrap(); let cp2 = crate::objects::Checkpoint::decode( &repo.store().get_typed(&r2, ObjectType::Checkpoint).unwrap(), ) .unwrap(); assert_eq!(repo.checkpoint_graph_blobs(&cp2).unwrap(), graphs1); // A shallow clone's bundle carries the graph blob with the checkpoint. let bundle = repo.checkpoint_bundle_for(&heads).unwrap(); assert!(bundle.objects.iter().any(|(t, body)| *t == ObjectType::Blob && crate::hash::hash_object(ObjectType::Blob, body) == graphs1[0])); // A version 1 checkpoint written by an older oxy still loads. let state = repo.load_state_full(&heads).unwrap(); let old = repo.store_blob(&state.checkpoint_payload()).unwrap(); let cp_old = crate::objects::Checkpoint { heads: cp2.heads.clone(), count: cp2.count, payload: old, }; let r_old = repo .store() .put(ObjectType::Checkpoint, &cp_old.encode()) .unwrap(); repo.install_checkpoint("main", &r_old).unwrap(); assert_eq!( repo.load_state(&heads).unwrap().checkpoint_payload(), full.checkpoint_payload() ); assert_eq!(repo.fsck().unwrap().errors(), 0); // A lost graph blob fails the load loudly rather than dropping the // file, and fsck reports it (a fresh repository, objects still loose). let dir2 = tempfile::tempdir().unwrap(); let root2 = dir2.path(); let repo2 = Repo::init(root2).unwrap().with_home(root2.join("home")); write(root2, "long.txt", long.as_bytes()); repo2.commit("base", &[]).unwrap(); let (r, _, _) = repo2.checkpoint().unwrap(); let cp = crate::objects::Checkpoint::decode( &repo2.store().get_typed(&r, ObjectType::Checkpoint).unwrap(), ) .unwrap(); let graph = repo2.checkpoint_graph_blobs(&cp).unwrap()[0]; repo2.store().remove_loose_for_test(&graph).unwrap(); assert!(repo2.load_state(&repo2.bookmark_heads().unwrap()).is_err()); assert!(repo2.fsck().unwrap().errors() > 0, "fsck reports it"); } /// A new repository has not stored the user's identity yet (that happens /// at the first signed change); looking it up falls back to the key /// directory, and a file there that does not hash to the key is refused. #[test] fn identity_is_found_in_the_key_directory_for_a_new_repository() { hermetic(); let dir = tempfile::tempdir().unwrap(); let home = dir.path().join("home"); let mut first = Repo::init(&dir.path().join("a")) .unwrap() .with_home(home.clone()); let (key, _) = first .create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); let fresh = Repo::init(&dir.path().join("b")) .unwrap() .with_home(home.clone()); assert!(!fresh.store().has(&key), "nothing stored yet"); let id = fresh.identity(&key).unwrap(); assert_eq!((id.name.as_str(), id.hash()), ("Ada", key)); // A key file that is not this identity is an error, not a wrong answer. let keys = fresh.keys_dir.clone(); let (other, _) = first .create_identity("Bob", "bob@example.org", IdentityScope::User) .unwrap(); fs::copy( keys.join(format!("{}.identity", other.to_hex())), keys.join(format!("{}.identity", key.to_hex())), ) .unwrap(); assert!(fresh .identity(&key) .unwrap_err() .to_string() .contains("does not hold identity")); } /// Status snapshots (spec §12.3): a text file whose stat matches the /// manifest, and whose base graph key is the one the manifest recorded it /// equal to, is neither read nor rendered; any edit, and any change to the /// file's graph in the base, brings it back. #[cfg(unix)] #[test] fn status_snapshots_skip_files_unchanged_since_the_manifest() { use std::os::unix::fs::PermissionsExt; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); for (p, c) in [("a.txt", "one\n"), ("b.txt", "bee\n"), ("d/c.txt", "sea\n")] { write(root, p, c.as_bytes()); } repo.commit("base", &[]).unwrap(); let age = |p: &str| { let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30); fs::File::options() .write(true) .open(root.join(p)) .unwrap() .set_modified(old) .unwrap(); }; let lock = |p: &str, mode: u32| { fs::set_permissions(root.join(p), fs::Permissions::from_mode(mode)).unwrap() }; for p in ["a.txt", "b.txt", "d/c.txt"] { age(p); } assert!(repo.snapshot("status").unwrap().statuses.is_empty()); let (_, _, m) = repo.read_manifest().unwrap().unwrap(); assert!( m.entries.values().all(|e| e.graph.is_some()), "keys recorded" ); // Unreadable files prove they are not read; the status snapshot does // not render them either, and its base lacks their graphs. lock("a.txt", 0o000); lock("b.txt", 0o000); let snap = repo.snapshot_status("status").unwrap(); assert!(!snap.changed && snap.statuses.is_empty()); assert_eq!( snap.base_snapshot .unrendered .iter() .cloned() .collect::>(), vec!["a.txt", "b.txt", "d/c.txt"] ); assert!(!snap.base.is_full()); assert!(!snap.base_snapshot.is_conflicted()); // A full snapshot does not read them either, but renders everything. let full = repo.snapshot("status").unwrap(); assert!(full.statuses.is_empty() && full.base.is_full()); assert!(full.base_snapshot.unrendered.is_empty()); assert!(matches!(&full.base_snapshot.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\n")); // An edit is seen; the file is rendered, the others still are not. write(root, "d/c.txt", b"sea edited\n"); let snap = repo.snapshot_status("status").unwrap(); assert_eq!( statuses(&snap), vec![("d/c.txt".to_string(), ChangeStatus::Modified)] ); assert!(!snap.base_snapshot.unrendered.contains("d/c.txt")); // So is a mode change of an unread file. lock("b.txt", 0o100); assert_eq!( statuses(&repo.snapshot_status("status").unwrap()), vec![ ("b.txt".to_string(), ChangeStatus::ModeChanged), ("d/c.txt".to_string(), ChangeStatus::Modified) ] ); lock("b.txt", 0o000); // Committing c.txt changes its graph in the base, not a.txt's: a.txt // stays unread, c.txt is read once and then skipped too. repo.commit_selection("edit c", &[], &Selection::Paths(vec!["d/c.txt".into()])) .unwrap(); age("d/c.txt"); let snap = repo.snapshot_status("status").unwrap(); assert!(snap.statuses.is_empty(), "{:?}", snap.statuses); assert!(snap.base_snapshot.unrendered.contains("a.txt")); lock("d/c.txt", 0o000); let snap = repo.snapshot_status("status").unwrap(); assert!(snap.statuses.is_empty()); assert!(snap.base_snapshot.unrendered.contains("d/c.txt")); for p in ["a.txt", "b.txt", "d/c.txt"] { lock(p, 0o644); } } /// Attributes decide how a file compares with the base, so a changed /// `.oxyattributes` makes every text file be compared again. #[cfg(unix)] #[test] fn a_changed_attributes_file_disables_skipping_unchanged_files() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"crlf\r\n"); repo.commit("base", &[]).unwrap(); let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30); fs::File::options() .write(true) .open(root.join("a.txt")) .unwrap() .set_modified(old) .unwrap(); assert!(repo.snapshot_status("status").unwrap().statuses.is_empty()); assert!(repo .snapshot_status("status") .unwrap() .base_snapshot .unrendered .contains("a.txt")); // With eol=lf, a.txt as stored no longer equals the base. write(root, ".oxyattributes", b"*.txt eol=lf\n"); let snap = repo.snapshot_status("status").unwrap(); assert_eq!( statuses(&snap), vec![ (".oxyattributes".to_string(), ChangeStatus::Added), ("a.txt".to_string(), ChangeStatus::Modified) ] ); } /// Bookmarks with `/` in their names: pushing records their /// attestations, and checkpoints are kept for them, beside `feature` /// itself. #[test] fn bookmarks_with_slashes_push_and_checkpoint() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = Repo::init(&root.join("origin")).unwrap(); let url = root.join("origin").to_string_lossy().to_string(); let mut repo = Repo::clone(&url, &root.join("r")) .unwrap() .with_home(root.join("home")); // With an identity, pushes carry reference attestations: the case // that failed, since the attestation is a file named after the bookmark. repo.create_identity("Ada", "ada@example.org", IdentityScope::Repo) .unwrap(); let r = root.join("r"); write(&r, "a.txt", b"a\n"); let h_a = repo.commit("a", &[]).unwrap(); assert!(repo.push("origin", false).unwrap()[0].ok); origin.refresh().unwrap(); repo.bookmark_create("feature", None).unwrap(); repo.bookmark_create("feature/x", None).unwrap(); repo.bookmark_switch("feature/x", false).unwrap(); write(&r, "b.txt", b"b\n"); let h_b = repo.commit("b", &[]).unwrap(); for name in ["feature", "feature/x"] { let res = repo.push_bookmark("origin", name, false).unwrap(); origin.refresh().unwrap(); assert!(res[0].ok, "{name}: {res:?}"); } assert_eq!(origin.resolve_heads("feature").unwrap(), vec![h_a]); assert_eq!( origin.resolve_heads("feature/x").unwrap(), repo.resolve_heads("feature/x").unwrap() ); assert!(origin.resolve_heads("feature/x").unwrap().contains(&h_b)); assert!(origin.latest_ref_attest("feature/x").unwrap().is_some()); assert!(origin.latest_ref_attest("feature").unwrap().is_some()); // A checkpoint of feature/x is found again when loading its state. let (cp, _, _) = repo.checkpoint().unwrap(); assert_eq!(repo.latest_checkpoint("feature/x").unwrap().unwrap().0, cp); assert!(repo.latest_checkpoint("feature").unwrap().is_none()); let heads = repo.resolve_heads("feature/x").unwrap(); assert!(repo.load_state_checkpointed(&heads).unwrap().is_some()); } /// Deleting a bookmark on a remote (spec §16.4): attested like any update, /// never the remote's own bookmark, and a bookmark created again later is /// accepted although the deletion's attestation stays the latest. #[test] fn bookmarks_are_deleted_on_a_remote() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = Repo::init(&root.join("origin")).unwrap(); let url = root.join("origin").to_string_lossy().to_string(); let mut repo = Repo::clone(&url, &root.join("r")) .unwrap() .with_home(root.join("home")); repo.create_identity("Ada", "ada@example.org", IdentityScope::Repo) .unwrap(); let r = root.join("r"); write(&r, "a.txt", b"a\n"); repo.commit("a", &[]).unwrap(); assert!(repo.push("origin", false).unwrap()[0].ok); origin.refresh().unwrap(); repo.bookmark_create("feature/x", None).unwrap(); assert!(repo.push_bookmark("origin", "feature/x", false).unwrap()[0].ok); origin.refresh().unwrap(); assert!(origin.resolve_heads("feature/x").is_ok()); let res = repo.push_delete("origin", "feature/x").unwrap(); origin.refresh().unwrap(); assert!(res[0].ok, "{res:?}"); assert!(!origin.view().unwrap().bookmarks.contains_key("feature/x")); let (_, att) = origin.latest_ref_attest("feature/x").unwrap().unwrap(); assert!( matches!(att.attest.body, AttestBody::Ref { new_state, .. } if new_state.is_zero()), "the deletion is attested" ); assert!( repo.view().unwrap().bookmarks.contains_key("feature/x"), "the local bookmark stays" ); assert!(!repo .view() .unwrap() .remotes .get("origin") .is_some_and(|m| m.contains_key("feature/x"))); // Pushing it again creates it again, past the deletion's serial. let res = repo.push_bookmark("origin", "feature/x", false).unwrap(); origin.refresh().unwrap(); assert!(res[0].ok, "{res:?}"); assert!(origin.view().unwrap().bookmarks.contains_key("feature/x")); // Nothing to delete, and never the remote's own bookmark. assert!(repo.push_delete("origin", "nope").is_err()); let res = repo.push_delete("origin", "main").unwrap(); origin.refresh().unwrap(); assert!( !res[0].ok && res[0].reason.contains("default bookmark"), "{res:?}" ); assert!(origin.view().unwrap().bookmarks.contains_key("main")); } /// `bookmark switch --carry` (spec §10.1): uncommitted work goes along /// when the target has the same versions of the files it touches, and is /// refused, untouched, when it has others. #[test] fn switching_bookmarks_carries_uncommitted_work() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"a\n"); write(root, "b.txt", b"b\n"); write(root, "gone.txt", b"gone\n"); repo.commit("base", &[]).unwrap(); repo.bookmark_create("feature", None).unwrap(); // Started on main, belongs on feature: edit, add, remove. write(root, "a.txt", b"a\nmore\n"); write(root, "new.txt", b"new\n"); fs::remove_file(root.join("gone.txt")).unwrap(); let (report, carried) = repo.bookmark_switch_carry("feature").unwrap(); assert_eq!(carried, vec!["a.txt", "gone.txt", "new.txt"]); // oxygen#120: the new file was carried, not removed. assert_eq!( (report.removed, report.removed_paths.len()), (0, 0), "{report:?}" ); assert_eq!(repo.config.bookmark, "feature"); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"a\nmore\n"); assert_eq!(fs::read(root.join("new.txt")).unwrap(), b"new\n"); assert!(!root.join("gone.txt").exists()); let snap = repo.snapshot("status").unwrap(); assert_eq!( statuses(&snap), vec![ ("a.txt".to_string(), ChangeStatus::Modified), ("gone.txt".to_string(), ChangeStatus::Removed), ("new.txt".to_string(), ChangeStatus::Added) ] ); repo.commit("the work", &[]).unwrap(); assert!(repo.resolve_heads("main").unwrap().len() == 1); // main moves b.txt on; work on feature touching b.txt cannot follow. repo.bookmark_switch("main", false).unwrap(); write(root, "b.txt", b"b on main\n"); repo.commit("b on main", &[]).unwrap(); repo.bookmark_switch("feature", false).unwrap(); write(root, "b.txt", b"b edited\n"); let err = repo.bookmark_switch_carry("main").unwrap_err().to_string(); assert!(err.contains("b.txt") && err.contains("--discard"), "{err}"); assert_eq!(repo.config.bookmark, "feature", "nothing happened"); assert_eq!(fs::read(root.join("b.txt")).unwrap(), b"b edited\n"); } /// Merging and checking out (feedback 3): a working copy without changes /// follows a merge whichever state it sat on; a refused checkout names /// what is in the way; `checkout --carry` takes the work along. #[test] fn merges_move_clean_working_copies_and_checkouts_say_what_blocks_them() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"a\n"); let h1 = repo.commit("a", &[]).unwrap(); write(root, "b.txt", b"b\n"); repo.commit("b", &[]).unwrap(); repo.bookmark_create("feature", None).unwrap(); repo.bookmark_switch("feature", false).unwrap(); write(root, "c.txt", b"c\n"); repo.commit("c on feature", &[]).unwrap(); repo.bookmark_switch("main", false).unwrap(); // The working copy sits on an older state than main's heads. repo.checkout(&[h1], false, "checkout").unwrap(); assert!(!root.join("b.txt").exists()); let m = repo.merge("feature").unwrap(); assert!(m.checked_out, "a clean working copy follows the merge"); for f in ["a.txt", "b.txt", "c.txt"] { assert!(root.join(f).exists(), "{f}"); } // A refusal says which paths are in the way and what to do. fs::remove_file(root.join("b.txt")).unwrap(); write(root, "a.txt", b"a edited\n"); let err = repo .checkout(&[h1], false, "checkout") .unwrap_err() .to_string(); assert!( err.contains("modified a.txt") && err.contains("removed b.txt") && err.contains("--carry"), "{err}" ); // --carry takes them along: h1 has a.txt as the base has it, and the // removed b.txt is already as h1 has it (none), so that is no conflict // either (oxygen#100). let (_, carried) = repo .checkout_carry(&[h1], "the first change", "checkout") .unwrap(); assert_eq!(carried, vec!["a.txt"]); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"a edited\n"); assert!(!root.join("b.txt").exists(), "h1 has no b.txt"); } /// Doctor and subrepos before the first commit (feedback 5 and 8): a /// user identity not stored in a new repository yet is fine while its /// secret key is there; an added subrepo is listed as not committed yet. #[test] fn a_new_repository_is_healthy_before_its_first_commit() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let home = root.join("home"); let mut first = Repo::init(&root.join("first")) .unwrap() .with_home(home.clone()); let (key, _) = first .create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); let repo = Repo::init(&root.join("second")) .unwrap() .with_home(home.clone()); // Only the secret key: no identity object anywhere this repository sees. let keys = repo.keys_dir.clone(); let _ = fs::remove_file(keys.join(format!("{}.identity", key.to_hex()))); assert!(keys.join(format!("{}.secret", key.to_hex())).is_file()); let r = repo.doctor(true); let id = r.checks.iter().find(|c| c.area == "identity").unwrap(); assert_eq!(id.health, Health::Ok, "{}", id.message); assert!(id.message.contains("first signed change"), "{}", id.message); // A subrepo added and not committed yet is listed, pinned where it is. let lib = root.join("lib"); let l = Repo::init(&lib).unwrap().with_home(home.clone()); write(&lib, "x.txt", b"x\n"); l.commit("x", &[]).unwrap(); let second = root.join("second"); write(&second, "a.txt", b"a\n"); repo.submodule_add(&lib.to_string_lossy(), "deps/lib", None) .unwrap(); let subs = repo.subrepos().unwrap(); assert_eq!(subs.len(), 1); assert_eq!( (subs[0].path.as_str(), subs[0].committed), ("deps/lib", false) ); assert_eq!(subs[0].actual.as_ref(), Some(&subs[0].pin)); repo.commit("add lib", &[]).unwrap(); let subs = repo.subrepos().unwrap(); assert!(subs[0].committed, "committed now"); } /// Commit, diff, hunks and discard use the partial snapshot: a file /// unchanged since the manifest is neither read nor rendered, and showing /// a change renders only what it edits, the same as from a full base. #[cfg(unix)] #[test] fn commits_and_diffs_leave_unchanged_files_alone() { use std::os::unix::fs::PermissionsExt; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "big.txt", b"lots\nof\nlines\n"); write(root, "a.txt", b"one\ntwo\n"); repo.commit("base", &[]).unwrap(); let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30); for f in ["big.txt", "a.txt"] { fs::File::options() .write(true) .open(root.join(f)) .unwrap() .set_modified(old) .unwrap(); } assert!(repo.snapshot_status("status").unwrap().statuses.is_empty()); // big.txt cannot be read at all from here on. fs::set_permissions(root.join("big.txt"), fs::Permissions::from_mode(0o000)).unwrap(); write(root, "a.txt", b"one\nTWO\nthree\n"); let diff = repo.diff_files(None).unwrap(); assert_eq!( diff.iter().map(|f| f.path.as_str()).collect::>(), ["a.txt"] ); let (_, hunks) = repo.hunks().unwrap(); assert!(!hunks.is_empty() && hunks.iter().all(|h| h.path == "a.txt")); let h = repo.commit("edit a", &[]).unwrap(); // Showing the change needs only a.txt's graph, and renders as a full base does. let c = repo.store().get_change(&h).unwrap(); let full = repo.load_state(&c.deps).unwrap(); let want = render_change(&c, &full, &full.materialize()); assert_eq!(repo.describe_change(&h).unwrap(), want); let (base, snap) = repo.change_base(&c).unwrap(); assert!(!base.is_full() && snap.unrendered.contains("big.txt")); // Discarding works the same way. write(root, "a.txt", b"scratch\n"); assert_eq!(repo.discard(&["a.txt".into()]).unwrap(), 1); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\nTWO\nthree\n"); fs::set_permissions(root.join("big.txt"), fs::Permissions::from_mode(0o644)).unwrap(); } /// A replace conflict (spec §9.3) is settled by a change that writes one /// candidate, or new bytes, at the path when the caller names it; a plain /// snapshot of a checkout never settles it on its own. #[test] fn replace_conflicts_are_settled_when_asked() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); let blob = |n: u8| { let mut v = vec![0u8; 64]; v[1] = n; v }; write(root, "img.bin", &blob(1)); repo.commit("add img", &[]).unwrap(); repo.bookmark_create("other", None).unwrap(); write(root, "img.bin", &blob(2)); repo.commit("img two", &[]).unwrap(); repo.bookmark_switch("other", false).unwrap(); write(root, "img.bin", &blob(3)); repo.commit("img three", &[]).unwrap(); repo.bookmark_switch("main", false).unwrap(); repo.merge("other").unwrap(); let heads = repo.bookmark_heads().unwrap(); let merged = repo.load_state(&heads).unwrap().materialize(); assert!( matches!(&merged.files["img.bin"].content, FileContent::Opaque { blobs } if blobs.len() == 2), "a replace conflict" ); // A snapshot of the checkout records nothing. assert!(repo .snapshot("status") .unwrap() .statuses .iter() .all(|s| s.path != "img.bin")); // Keeping candidate 3 settles it. let author = repo.author(); let edits = [FileEdit { path: "img.bin".into(), content: Some(blob(3)), }]; let c = repo .build_change(&heads, &edits, "keep three", author) .unwrap(); assert_eq!(c.kind, ChangeKind::Resolution); let mut st = repo.load_state(&heads).unwrap(); for d in &c.deps { assert!(st.contains(d)); } assert!( heads.iter().all(|h| c.deps.contains(h)), "depends on both contributors" ); st.add(c).unwrap(); let after = st.materialize(); assert!(!after.is_conflicted()); assert_eq!( after.files["img.bin"].content, FileContent::Opaque { blobs: vec![Repo::blob_ref(&blob(3))] } ); // New bytes settle it too. let edits = [FileEdit { path: "img.bin".into(), content: Some(blob(9)), }]; let c = repo.build_change(&heads, &edits, "new", author).unwrap(); let mut st = repo.load_state(&heads).unwrap(); st.add(c).unwrap(); assert_eq!( st.materialize().files["img.bin"].content, FileContent::Opaque { blobs: vec![Repo::blob_ref(&blob(9))] } ); } /// The history of a range of lines (spec §27): the changes that wrote /// or deleted its atoms, or deleted atoms from between them; edits outside /// the range and changes the range's lines survived are not in it. #[test] fn line_ranges_have_exact_histories() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "f.rs", b"use x;\nfn a() {\n one();\n two();\n}\n"); let base = repo.commit("add f", &[]).unwrap(); write(root, "f.rs", b"use y;\nfn a() {\n one();\n two();\n}\n"); let outside = repo.commit("change the import", &[]).unwrap(); write(root, "f.rs", b"use y;\nfn a() {\n ONE();\n two();\n}\n"); let inside = repo.commit("edit a line of a", &[]).unwrap(); write(root, "f.rs", b"use y;\nfn a() {\n ONE();\n}\n"); let deleted = repo.commit("delete a line of a", &[]).unwrap(); write(root, "f.rs", b"use y;\nfn a() {\n ONE();\n}\nfn b() {}\n"); let after = repo.commit("add b after a", &[]).unwrap(); let heads = repo.bookmark_heads().unwrap(); // Lines 2..4 are `fn a() {`, `ONE();`, `}` now. let hist: Vec = repo .line_history(&heads, "f.rs", 2, 4) .unwrap() .iter() .map(|e| e.hash) .collect(); for (h, what) in [ (base, "wrote it"), (inside, "edited a line"), (deleted, "deleted a line from inside"), ] { assert!(hist.contains(&h), "{what}"); } for (h, what) in [(outside, "an edit above"), (after, "an addition below")] { assert!(!hist.contains(&h), "{what}"); } assert_eq!(hist[0], deleted, "newest first"); // One line: only what touched it. let one: Vec = repo .line_history(&heads, "f.rs", 1, 1) .unwrap() .iter() .map(|e| e.hash) .collect(); assert!(one.contains(&outside) && !one.contains(&inside)); assert!(repo.line_history(&heads, "f.rs", 3, 9).is_err()); assert!(repo.line_history(&heads, "f.rs", 0, 1).is_err()); assert!(repo.line_history(&heads, "nope.rs", 1, 1).is_err()); } #[test] fn receipts_are_kept_with_their_state_and_verified() { use crate::receipt::{Receipt, SignedReceipt}; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"1\n"); repo.commit("A", &[]).unwrap(); let heads = repo.bookmark_heads().unwrap(); let state = repo .store() .put_state(&StateObject::new(heads.clone())) .unwrap(); let out = repo.store_blob(b"built\n").unwrap(); let receipt = Receipt { job_name: "build".into(), pipeline_hash: ObjectRef([1; 32]), scope_tree_hash: ObjectRef([2; 32]), inputs: vec![], environment: ObjectRef([3; 32]), outputs: vec![out], exit_code: 0, duration_ms: 42, }; let sk = SecretKey::generate(); let runner = sk.identity("earth-1", "runner@earth", 1); let sr = SignedReceipt::sign( receipt.clone(), repo.id().unwrap(), state, "demo#1", &runner, &sk, ); let r = repo.add_receipt(&sr).unwrap(); let found = repo.receipts(&heads).unwrap(); assert_eq!(found.len(), 1); assert_eq!(found[0].attest, r); assert_eq!(found[0].signed, sr); assert!(found[0].problem.is_none() && !found[0].revoked); assert!(repo.receipts(&[ObjectRef([5; 32])]).unwrap().is_empty()); // Another repository's receipt, or a forged one, is refused. let elsewhere = SignedReceipt::sign(receipt.clone(), [0; 16], state, "x", &runner, &sk); assert!(repo .add_receipt(&elsewhere) .unwrap_err() .to_string() .contains("another repository")); let mut forged = sr.clone(); forged.receipt.exit_code = 1; assert!(repo .add_receipt(&forged) .unwrap_err() .to_string() .contains("does not verify")); // A receipt signed by a short-lived key keeps its certificate and the // issuer's identity, and reads back with them (oxygenhub#241). let hub_sk = SecretKey::generate(); let hub = hub_sk.identity("oxygenhub.io", "hub@oxygenhub.io", 1); let job_sk = SecretKey::generate(); let job = job_sk.identity("job 2 test", "flow@oxygenhub.io", 1); let mut test_receipt = receipt.clone(); test_receipt.job_name = "test".into(); let plain = SignedReceipt::sign( test_receipt, repo.id().unwrap(), state, "demo#2", &job, &job_sk, ); let now = plain.attest.hlc.wall_ms; let cert = crate::cert::SignedCertificate::sign( crate::cert::Certificate { key: job.clone(), issuer: hub.hash(), subject: "repo:andy/demo:bookmark:main".into(), claims: vec![], scope: crate::identity::Scope::SEAL, not_before: now - 1_000, expires: now + 600_000, grant: None, }, &hub, &hub_sk, ); let certified = plain.with_certificate(cert, hub.clone()); repo.add_receipt(&certified).unwrap(); let found = repo.receipts(&heads).unwrap(); let got = found .iter() .find(|i| i.signed.receipt.job_name == "test") .unwrap(); assert_eq!(got.signed, certified); assert!(got.problem.is_none(), "{:?}", got.problem); // gc keeps the receipts, their runners, the certificate and its issuer, // and the output while the state lives. repo.gc(true).unwrap(); let found = repo.receipts(&heads).unwrap(); assert_eq!(found.len(), 2); let got = found .iter() .find(|i| i.signed.receipt.job_name == "test") .unwrap(); assert_eq!(got.signed, certified, "the certificate survives gc"); assert!(got.problem.is_none()); assert_eq!(repo.read_blob(&out).unwrap(), b"built\n"); assert!(repo.fsck().unwrap().problems.is_empty()); } #[test] fn approvals_are_kept_with_their_state_and_survive_gc() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"1\n"); repo.commit("A", &[]).unwrap(); let heads = repo.bookmark_heads().unwrap(); let state = repo.store().put_state(&StateObject::new(heads)).unwrap(); let plan = repo.store_blob(b"the reviewed plan\n").unwrap(); let sk = SecretKey::generate(); let andy = sk.identity("Andy", "andy@example.org", 1); let statement = crate::approval::Statement { repo: repo.id().unwrap(), run: "https://hub.example/andy/infra/flow/8".into(), job: "apply".into(), job_hash: ObjectRef([1; 32]), state, inputs: vec![plan], environment: "prod".into(), approve: true, }; let sa = crate::approval::SignedApproval::sign(&statement, &andy, &sk); let r = repo.add_approval(&sa).unwrap(); assert_eq!(repo.store().approvals_for(&state).unwrap(), vec![r]); // Another repository's approval, or a changed one, is refused. let elsewhere = crate::approval::SignedApproval::sign( &crate::approval::Statement { repo: [0; 16], ..statement.clone() }, &andy, &sk, ); assert!(repo .add_approval(&elsewhere) .unwrap_err() .to_string() .contains("another repository")); let mut forged = sa.clone(); if let AttestBody::Approval { environment, .. } = &mut forged.attest.body { *environment = "dev".into(); } assert!(repo .add_approval(&forged) .unwrap_err() .to_string() .contains("does not verify")); // gc keeps it, its signature and its signer while the state lives. repo.gc(true).unwrap(); assert_eq!(repo.store().approvals_for(&state).unwrap(), vec![r]); assert_eq!(repo.store().get_attest(&r).unwrap(), sa.attest); assert_eq!(repo.store().signatures(&r).unwrap(), sa.sigs); assert!(repo.fsck().unwrap().problems.is_empty()); } #[test] fn status_leaves_an_unchanged_manifest_alone() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"1\n"); write(root, "b.txt", b"2\n"); repo.commit("A", &[]).unwrap(); // Files last touched well outside the racy window. let old = std::time::SystemTime::now() - std::time::Duration::from_secs(60); for f in ["a.txt", "b.txt"] { fs::File::options() .write(true) .open(root.join(f)) .unwrap() .set_modified(old) .unwrap(); } repo.snapshot_status("status").unwrap(); let path = repo.manifest_path(); let first = fs::read(&path).unwrap(); repo.snapshot_status("status").unwrap(); assert_eq!( fs::read(&path).unwrap(), first, "an unchanged tree does not rewrite the manifest" ); // A change rewrites it. write(root, "a.txt", b"1\n2\n"); repo.snapshot_status("status").unwrap(); assert_ne!(fs::read(&path).unwrap(), first); } #[test] fn large_text_files_travel_and_survive_gc_as_text_blobs() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_path = root.join("origin"); Repo::init(&origin_path).unwrap(); let origin_url = origin_path.to_string_lossy().to_string(); let a = Repo::clone(&origin_url, &root.join("a")).unwrap(); let big: String = (0..5000).map(|i| format!("line {i}\n")).collect(); write(&root.join("a"), "big.txt", big.as_bytes()); write(&root.join("a"), "small.txt", b"small\n"); let h1 = a.commit("large", &[]).unwrap(); let (_, body) = a.store().get(&h1).unwrap(); assert!(body.len() < 2048, "the change is {} bytes", body.len()); assert!(a.push("origin", false).unwrap()[0].ok); // A clone reads the text from the blobs that came with the change. let b = Repo::clone(&origin_url, &root.join("b")).unwrap(); assert_eq!(fs::read(root.join("b/big.txt")).unwrap(), big.as_bytes()); // An edit in the middle of the stored insert is an ordinary change. let edited = big.replace("line 2500\n", "line 2500, edited\n"); write(&root.join("b"), "big.txt", edited.as_bytes()); let h2 = b.commit("edit", &[]).unwrap(); assert!(b.store().get_change(&h2).unwrap().edits.len() <= 2); assert!(b.push("origin", false).unwrap()[0].ok); assert!(a.pull("origin").unwrap().checked_out); assert_eq!(fs::read(root.join("a/big.txt")).unwrap(), edited.as_bytes()); assert_eq!( a.fsck().unwrap().errors(), 0, "{:?}", a.fsck().unwrap().problems ); // With zero retention gc keeps the text blobs of every kept change. let mut a = Repo::open(&root.join("a")).unwrap(); a.config.set("core.op-retention", "0").unwrap(); a.save_config().unwrap(); let a = Repo::open(&root.join("a")).unwrap(); a.gc(true).unwrap(); let a = Repo::open(&root.join("a")).unwrap(); assert_eq!(a.store().get_change(&h1).unwrap().message, "large"); assert_eq!( a.fsck().unwrap().errors(), 0, "{:?}", a.fsck().unwrap().problems ); // And a clone of the packed repository reads them from its packs. let c = Repo::clone(&root.join("a").to_string_lossy(), &root.join("c")).unwrap(); assert_eq!(fs::read(root.join("c/big.txt")).unwrap(), edited.as_bytes()); drop(c); } #[test] fn operations_that_both_replace_a_working_copy_are_not_merged() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"a\n"); let a = repo.commit("a", &[]).unwrap(); write(root, "b.txt", b"b\n"); repo.commit("b", &[]).unwrap(); let (x, op) = repo.head_op().unwrap(); let ws = *op.view.workspaces.keys().next().unwrap(); let mut one = op.view.clone(); one.workspaces.insert(ws, a); let mut two = op.view.clone(); two.workspaces.insert(ws, ObjectRef([7; 32])); let first = repo.record_op(&[x], "one", one).unwrap(); let e = repo.record_op(&[x], "two", two).unwrap_err(); assert!( e.to_string() .contains("changed this working copy at the same time"), "{e}" ); assert_eq!( repo.head_op().unwrap().0, first, "the second is not recorded" ); } /// Metalgear, 2026-09-29 (oxygen#36): a status run while a bookmark switch /// rewrote the working copy recorded the half-written files as edits, and /// its view replaced the switch's. #[test] fn a_snapshot_during_a_switch_never_records_the_switch_as_edits() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path().to_path_buf(); let mut repo = Repo::init(&root).unwrap(); write(&root, "keep.txt", b"keep\n"); repo.commit("base", &[]).unwrap(); repo.bookmark_create("small", None).unwrap(); for i in 0..300 { write( &root, &format!("many/f{i}.txt"), format!("file {i}\n").as_bytes(), ); } repo.commit("many", &[]).unwrap(); let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let watcher = { let (root, stop) = (root.clone(), stop.clone()); std::thread::spawn(move || { let r = Repo::open(&root).unwrap(); let mut ran = 0; while !stop.load(std::sync::atomic::Ordering::Relaxed) { if r.snapshot_status("oxy status").is_ok() { ran += 1; } } ran }) }; for i in 0..20 { let to = if i % 2 == 0 { "small" } else { "main" }; repo.bookmark_switch(to, false).unwrap(); } stop.store(true, std::sync::atomic::Ordering::Relaxed); assert!(watcher.join().unwrap() > 0, "the watcher snapshotted"); // Back on main, clean, with every file where main has it. let repo = Repo::open(&root).unwrap(); let s = repo.snapshot_status("oxy status").unwrap(); assert!(s.statuses.is_empty(), "{:?}", statuses(&s)); assert!(s.wc.edits.is_empty() && s.wc.tree_ops.is_empty()); assert_eq!(s.wc.deps, repo.bookmark_heads().unwrap()); assert!(root.join("many/f299.txt").exists()); } /// Metalgear, 2026-09-29 (oxygen#38): a parent's `bookmark switch --carry` /// put a subrepo that was ahead of its pin back at the pin and moved the /// subrepo's own bookmark backwards. #[test] fn a_parent_switch_with_carry_leaves_a_subrepo_ahead_of_its_pin_alone() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); Repo::init(&root.join("lib-origin")).unwrap(); let lib_url = root.join("lib-origin").to_string_lossy().to_string(); let lib = Repo::clone(&lib_url, &root.join("lib")).unwrap(); write(&root.join("lib"), "lib.txt", b"v1\n"); lib.commit("lib v1", &[]).unwrap(); assert!(lib.push("origin", false).unwrap()[0].ok); let app_dir = root.join("app"); let mut app = Repo::init(&app_dir).unwrap(); write(&app_dir, "main.txt", b"app\n"); app.submodule_add(&lib_url, "libs/lib", None).unwrap(); app.commit("app with lib", &[]).unwrap(); app.bookmark_create("feature", None).unwrap(); // New work in the subrepo, not yet pinned by the parent. let nested_dir = app_dir.join("libs/lib"); let nested = Repo::open(&nested_dir).unwrap(); write(&nested_dir, "new.txt", b"new\n"); let l2 = nested.commit("lib: new file", &[]).unwrap(); drop(nested); let (_, carried) = app.bookmark_switch_carry("feature").unwrap(); assert!(carried.contains(&"libs/lib".to_string()), "{carried:?}"); let nested = Repo::open(&nested_dir).unwrap(); assert!( nested.bookmark_heads().unwrap().contains(&l2), "its bookmark kept its work" ); assert!(nested_dir.join("new.txt").exists(), "and its files"); let s = app.snapshot("status").unwrap(); assert_eq!( statuses(&s), vec![("libs/lib".to_string(), ChangeStatus::Modified)] ); } /// oxygen#29 (metalgear, rstar-api): after a proposal landed on the hub, /// moving `main` to a change this clone had pushed on another bookmark, /// the first `pull` said "Already up to date" while `main` was behind. #[test] fn pull_takes_a_remote_move_to_changes_already_here() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_path = root.join("origin"); let origin = Repo::init(&origin_path).unwrap(); let url = origin_path.to_string_lossy().to_string(); let mut b = Repo::clone(&url, &root.join("b")).unwrap(); write(&root.join("b"), "base.txt", b"base\n"); b.commit("base", &[]).unwrap(); assert!(b.push("origin", false).unwrap()[0].ok); origin.refresh().unwrap(); // A feature bookmark with one change, pushed. b.bookmark_create("feat", None).unwrap(); b.bookmark_switch("feat", false).unwrap(); write(&root.join("b"), "x.txt", b"x\n"); let x = b.commit("x", &[]).unwrap(); assert!(b.push("origin", false).unwrap()[0].ok); origin.refresh().unwrap(); b.bookmark_switch("main", false).unwrap(); // The remote lands it: its main moves to the feature's state. let feat = origin .store() .get_state(&origin.view().unwrap().bookmarks["feat"]) .unwrap() .heads; origin.bookmark_set("main", &feat).unwrap(); let r = b.pull("origin").unwrap(); assert!(r.changed, "{r:?}"); assert!(r.heads.contains(&x) || b.load_state(&r.heads).unwrap().contains(&x)); let main = b .bookmarks() .unwrap() .into_iter() .find(|m| m.name == "main") .unwrap(); assert_eq!(main.behind, Some(0), "{main:?}"); } /// oxygen#48: a remote's bookmarks are names here (`origin/NAME`); switching /// to one only the remote has makes a local bookmark from it; a pull moves /// every local bookmark the remote has too; a clone checks out the remote's /// default bookmark. #[test] fn remote_bookmarks_are_named_switched_to_pulled_and_cloned() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_path = root.join("origin"); let mut origin = Repo::init(&origin_path).unwrap(); let url = origin_path.to_string_lossy().to_string(); let mut a = Repo::clone(&url, &root.join("a")).unwrap(); write(&root.join("a"), "base.txt", b"base\n"); a.commit("base", &[]).unwrap(); assert!(a.push("origin", false).unwrap()[0].ok); a.bookmark_create("develop", None).unwrap(); a.bookmark_switch("develop", false).unwrap(); write(&root.join("a"), "base.txt", b"base\nd1\n"); let d1 = a.commit("d1", &[]).unwrap(); assert!(a.push("origin", false).unwrap()[0].ok); // A clone has main only; origin/develop is a name all the same. let c_root = root.join("c"); let mut c = Repo::clone(&url, &c_root).unwrap(); assert_eq!(c.config.bookmark, "main"); assert!(!c.view().unwrap().bookmarks.contains_key("develop")); assert_eq!(c.resolve_heads("origin/develop").unwrap(), vec![d1]); assert_eq!(c.resolve_change("origin/develop").unwrap(), d1); assert_eq!(c.remote_only_bookmarks().unwrap().len(), 1); // Switching makes the local bookmark from origin's. c.bookmark_switch("develop", false).unwrap(); assert_eq!(c.config.bookmark, "develop"); assert_eq!(c.bookmark_heads().unwrap(), vec![d1]); assert_eq!(fs::read(c_root.join("base.txt")).unwrap(), b"base\nd1\n"); assert!(c.remote_only_bookmarks().unwrap().is_empty()); assert!(c.bookmark_switch("nowhere", false).is_err()); // Both bookmarks move on the remote; one pull takes both. write(&root.join("a"), "base.txt", b"base\nd1\nd2\n"); let d2 = a.commit("d2", &[]).unwrap(); assert!(a.push("origin", false).unwrap()[0].ok); a.bookmark_switch("main", false).unwrap(); write(&root.join("a"), "m.txt", b"m\n"); let m = a.commit("m", &[]).unwrap(); assert!(a.push("origin", false).unwrap()[0].ok); let r = c.pull("origin").unwrap(); assert!(r.changed && r.checked_out, "{r:?}"); assert_eq!(r.heads, vec![d2]); assert_eq!(r.others, vec!["main".to_string()]); assert!(c.resolve_heads("main").unwrap().contains(&m)); assert_eq!( fs::read(c_root.join("base.txt")).unwrap(), b"base\nd1\nd2\n" ); let again = c.pull("origin").unwrap(); assert!(!again.changed && again.others.is_empty(), "{again:?}"); // A clone checks out the remote's default bookmark. origin.config.set("workspace.bookmark", "develop").unwrap(); origin.save_config().unwrap(); let e = Repo::clone(&url, &root.join("e")).unwrap(); assert_eq!(e.config.bookmark, "develop"); assert_eq!(e.bookmark_heads().unwrap(), vec![d2]); let reopened = Repo::open(&root.join("e")).unwrap(); assert_eq!(reopened.config.bookmark, "develop"); // REMOTE/NAME names the remote's bookmark to switch to. let mut e = reopened; e.bookmark_switch("origin/main", false).unwrap(); assert_eq!(e.config.bookmark, "main"); assert!(e.bookmark_heads().unwrap().contains(&m)); } /// oxygen#51, #52: --at-op reads an earlier view and refuses changes; /// op restore goes back to an operation, files included, and undo takes /// the restore back whole; op revert undoes one operation alone; each /// operation's changes are described from the views. #[test] fn operations_can_be_read_restored_and_reverted() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"a\n"); let first = repo.commit("first", &[]).unwrap(); write(root, "a.txt", b"a\nb\n"); let second = repo.commit("second", &[]).unwrap(); let at_second = repo.head_op().unwrap().0.to_hex(); repo.bookmark_create("keep", None).unwrap(); let created = repo.head_op().unwrap().0.to_hex(); write(root, "n.txt", b"n\n"); let third = repo.commit("third", &[]).unwrap(); // What each operation changed. let ops = repo.op_log().unwrap(); let changes = repo.view_changes(&ops[1].1.view, &ops[0].1.view).unwrap(); assert!( changes .iter() .any(|c| matches!(c, ViewChange::BookmarkMoved { name, .. } if name == "main")), "{changes:?}" ); assert!(changes .iter() .any(|c| matches!(c, ViewChange::WorkingCopy { .. }))); // Reading at an earlier operation. let old = Repo::open(root).unwrap().at_op(&at_second[..10]).unwrap(); assert_eq!(old.bookmark_heads().unwrap(), vec![second]); assert!(!old.view().unwrap().bookmarks.contains_key("keep")); write(root, "z.txt", b"z\n"); assert!(old.commit("refused", &[]).is_err(), "--at-op is read-only"); fs::remove_file(root.join("z.txt")).unwrap(); assert!(repo.resolve_op("zz").is_err()); assert_eq!(repo.resolve_op("@").unwrap().0, repo.head_op().unwrap().0); // Restore goes back, files and all; undo takes it back whole. repo.op_restore(&at_second, false).unwrap(); assert_eq!(repo.bookmark_heads().unwrap(), vec![second]); assert!(!root.join("n.txt").exists()); repo.undo().unwrap(); let mut heads = repo.bookmark_heads().unwrap(); heads.sort(); let mut want = vec![second, third]; want.sort(); assert_eq!(heads, want); assert!( root.join("n.txt").exists(), "the files came back with the undo" ); // Revert the bookmark's creation alone: the commit after it stays. let r = repo.op_revert(&created).unwrap(); assert_eq!(r.reverted, vec!["keep".to_string()]); assert!(!repo.view().unwrap().bookmarks.contains_key("keep")); assert!(repo .load_state(&repo.bookmark_heads().unwrap()) .unwrap() .contains(&third)); assert!(repo.op_revert(&created).is_err(), "nothing left to revert"); let _ = first; } /// oxygen#41: with uncommitted work, a status after a snapshot is answered /// from the status cache while every file is as the snapshot left it, and /// from a snapshot again as soon as one is not. #[test] fn status_with_uncommitted_work_comes_from_the_cache_until_a_file_changes() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"a\n"); write(root, "b.txt", b"b\n"); repo.commit("base", &[]).unwrap(); assert_eq!(repo.status_by_manifest().unwrap(), Some(vec![]), "clean"); write(root, "a.txt", b"a\nmore\n"); assert_eq!( repo.status_by_manifest().unwrap(), None, "no snapshot of the edit yet" ); let snap = repo.snapshot_status("oxy status").unwrap(); assert_eq!(snap.statuses.len(), 1); let cached = repo .status_by_manifest() .unwrap() .expect("answered from the cache"); assert_eq!(cached.len(), 1); assert_eq!( (cached[0].path.as_str(), cached[0].status), ("a.txt", ChangeStatus::Modified) ); // Any further change sends status back to a snapshot. write(root, "a.txt", b"a\nmore\nand more\n"); assert_eq!(repo.status_by_manifest().unwrap(), None, "edited again"); repo.snapshot_status("oxy status").unwrap(); assert!(repo.status_by_manifest().unwrap().is_some()); write(root, "new.txt", b"n\n"); assert_eq!(repo.status_by_manifest().unwrap(), None, "a file added"); fs::remove_file(root.join("new.txt")).unwrap(); assert!( repo.status_by_manifest().unwrap().is_some(), "back as it was" ); fs::remove_file(root.join("b.txt")).unwrap(); assert_eq!(repo.status_by_manifest().unwrap(), None, "a file removed"); write(root, "b.txt", b"b\n"); // A commit leaves nothing uncommitted: clean again, no cache needed. repo.snapshot_status("oxy status").unwrap(); repo.commit("more", &[]).unwrap(); assert_eq!(repo.status_by_manifest().unwrap(), Some(vec![])); } /// oxygen#41: while the status cache holds, the working copy's diff is /// rendered from the stored working-copy change, and says exactly what a /// snapshot's diff says. #[test] fn the_working_copy_diff_from_the_cache_matches_a_snapshot() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"one\ntwo\nthree\n"); write(root, "gone.txt", b"bye\n"); repo.commit("base", &[]).unwrap(); write(root, "a.txt", b"one\n2\nthree\nfour\n"); write(root, "new.txt", b"hello\n"); fs::remove_file(root.join("gone.txt")).unwrap(); let snap = repo.snapshot_status("oxy status").unwrap(); let slow = render_change(&snap.wc, &snap.base, &snap.base_snapshot); assert!( repo.status_by_manifest().unwrap().is_some(), "the cache holds" ); let (wc, base, snapshot) = repo.working_copy_with_base().unwrap(); assert_eq!(render_change(&wc, &base, &snapshot), slow); assert_eq!(repo.diff_files(None).unwrap().len(), 3); } /// oxygen#41: a manifest written from a walk keeps every directory's time; /// while none has moved, the check stats the tracked files without listing /// the tree, and a file added or removed anywhere moves its directory's /// time, so the tree is listed again. #[test] fn the_manifest_check_skips_listing_while_no_directory_changed() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"a\n"); write(root, "sub/deep/b.txt", b"b\n"); repo.commit("base", &[]).unwrap(); // Out of the racy window: every file and directory a minute old. let old = std::time::SystemTime::now() - std::time::Duration::from_secs(60); for p in ["a.txt", "sub/deep/b.txt", "sub/deep", "sub", ""] { fs::File::open(root.join(p)) .unwrap() .set_modified(old) .unwrap(); } repo.snapshot_status("oxy status").unwrap(); repo.write_manifest(false, false).unwrap(); let m = repo.read_manifest().unwrap().unwrap().2; assert!(m.dirs.iter().any(|(d, _)| d == "sub/deep"), "{:?}", m.dirs); assert_eq!( repo.disk_differs_by_dirs(&m), Some(false), "no listing needed" ); assert_eq!(repo.status_by_manifest().unwrap(), Some(vec![])); // A same-size edit with a new time: found by its stat. write(root, "sub/deep/b.txt", b"c\n"); assert_eq!(repo.disk_differs_by_dirs(&m), Some(true)); write(root, "sub/deep/b.txt", b"b\n"); fs::File::open(root.join("sub/deep/b.txt")) .unwrap() .set_modified(old) .unwrap(); assert_eq!(repo.disk_differs_by_dirs(&m), Some(false)); // A new file moves its directory's time: the tree must be listed. write(root, "sub/deep/new.txt", b"n\n"); assert_eq!(repo.disk_differs_by_dirs(&m), None); assert_eq!(repo.status_by_manifest().unwrap(), None); fs::remove_file(root.join("sub/deep/new.txt")).unwrap(); // A removed one too. fs::remove_file(root.join("a.txt")).unwrap(); assert_eq!(repo.disk_differs_by_dirs(&m), None); } /// oxygen#41: a commit right after a status takes the snapshot the status /// left (the status cache holds) rather than walking again, and commits /// exactly what a snapshot would have. #[test] fn a_commit_after_a_status_uses_the_cached_snapshot() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"a\nb\n"); write(root, ".oxyattributes", b"*.bin binary\n"); repo.commit("base", &[]).unwrap(); write(root, "a.txt", b"a\nB\n"); write(root, "new.txt", b"new\n"); let snap = repo.snapshot_status("oxy status").unwrap(); let cached = repo .snapshot_from_cache() .unwrap() .expect("the cache holds"); assert_eq!(cached.wc_hash, snap.wc_hash); assert_eq!(cached.statuses, snap.statuses); assert!( !cached.attributes.is_empty(), "the .oxyattributes file was read" ); let h = repo.commit("edit", &[]).unwrap(); let c = repo.store().get_change(&h).unwrap(); assert_eq!( c.tree_ops.len() + c.edits.len(), snap.wc.tree_ops.len() + snap.wc.edits.len() ); assert_eq!( repo.status_by_manifest().unwrap(), Some(vec![]), "clean after the commit" ); assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"a\nB\n"); } /// oxygen#41: a clean check that verified racy entries restamps the /// manifest, so later checks can trust them, and a same-size rewrite /// straight afterwards is still found: entries within two seconds of the /// new time stay racy. #[test] fn a_verified_clean_check_restamps_the_manifest_safely() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"one\n"); repo.commit("one", &[]).unwrap(); let before = repo.read_manifest().unwrap().unwrap().2.written_ns; assert!(repo.clean_by_manifest().unwrap()); let after = repo.read_manifest().unwrap().unwrap().2.written_ns; assert!(after > before, "restamped"); write(root, "a.txt", b"two\n"); assert!( !repo.clean_by_manifest().unwrap(), "a same-size rewrite is still seen" ); } /// oxygen#35: a history-only state keeps no inserted text. It reads a /// stored insert's text blob only when it builds that file's graph, and /// builds the same graph a full load does. #[test] fn history_only_states_read_text_only_for_the_graphs_they_build() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); let big: String = (0..3000).map(|i| format!("big line {i}\n")).collect(); let other: String = (0..3000).map(|i| format!("other line {i}\n")).collect(); write(root, "big.txt", big.as_bytes()); write(root, "other.txt", other.as_bytes()); let h = repo.commit("two large files", &[]).unwrap(); write(root, "big.txt", format!("{big}one more\n").as_bytes()); let h2 = repo.commit("append", &[]).unwrap(); let full = repo.load_state_full(&[h2]).unwrap().materialize(); let text = |snap: &crate::state::Snapshot, p: &str| match &snap.files[p].content { FileContent::Text { bytes, .. } => bytes.clone(), other => panic!("{other:?}"), }; // Built on demand, every graph renders as the full load's. let mut hist = repo.load_state_history(&[h2]).unwrap(); let big_node = full.files["big.txt"].node; assert!(hist.graph_key(&big_node).is_some(), "not built yet"); repo.load_all_graphs(&mut hist).unwrap(); let rendered = hist.materialize(); for p in ["big.txt", "other.txt"] { assert_eq!(text(&rendered, p), text(&full, p), "{p}"); } // Without other.txt's text blob, history still loads and big.txt still // builds: nothing read the other text. let (_, texts) = repo.store().get_change_lazy(&h).unwrap(); assert_eq!(texts.len(), 2); let content = |r: &ObjectRef| -> Vec { let blob = crate::objects::Blob::decode(&repo.store().get(r).unwrap().1).unwrap(); blob.chunks .iter() .flat_map(|c| repo.store().get(c).unwrap().1) .collect() }; let other_text = texts .iter() .filter_map(|t| match t { crate::objects::TextRef::Blob(r, _, _) => Some(*r), _ => None, }) .find(|r| content(r).windows(12).any(|w| w == b"other line 1")) .unwrap(); repo.store().remove_loose_for_test(&other_text).unwrap(); let mut hist = repo.load_state_history(&[h2]).unwrap(); repo.load_graphs_of(&mut hist, &[big_node]).unwrap(); assert!(repo .load_graphs_of(&mut hist, &[full.files["other.txt"].node]) .is_err()); } /// oxygen#35: the first status after a commit read and rendered every /// file (79 s and more than 14 GB on the Linux tree), because the /// manifest had no graph keys for files the commit added. #[test] fn a_commit_leaves_a_manifest_the_next_status_trusts() { use std::os::unix::fs::PermissionsExt; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); let files = ["a.txt", "b.txt", "d/c.txt"]; for p in files { write(root, p, format!("{p}\n").as_bytes()); let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30); fs::File::options() .write(true) .open(root.join(p)) .unwrap() .set_modified(old) .unwrap(); } repo.commit("added", &[]).unwrap(); let (_, _, m) = repo.read_manifest().unwrap().unwrap(); assert!( m.entries.values().all(|e| e.graph.is_some()), "keys recorded at commit" ); // Unreadable files prove the next status reads none of them. for p in files { fs::set_permissions(root.join(p), fs::Permissions::from_mode(0o000)).unwrap(); } let snap = repo.snapshot_status("status").unwrap(); for p in files { fs::set_permissions(root.join(p), fs::Permissions::from_mode(0o644)).unwrap(); } assert!(!snap.changed && snap.statuses.is_empty()); assert_eq!(snap.base_snapshot.unrendered.len(), files.len()); } /// oxygen#35: a checkout reads, renders and writes only the files whose /// graphs differ, and renders the rest a batch at a time. #[test] fn checkout_touches_only_what_differs_and_renders_in_batches() { use std::os::unix::fs::PermissionsExt; hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = root.join("origin"); let mut repo = Repo::init(&origin).unwrap(); // More files than one batch holds. let n = 2100; let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30); for i in 0..n { let p = format!("f/{i:04}.txt"); write(&origin, &p, format!("file {i}\n").as_bytes()); fs::File::options() .write(true) .open(origin.join(&p)) .unwrap() .set_modified(old) .unwrap(); } repo.commit("many", &[]).unwrap(); repo.bookmark_create("feature", None).unwrap(); repo.bookmark_switch("feature", false).unwrap(); write(&origin, "f/0007.txt", b"changed\n"); repo.commit("one change", &[]).unwrap(); // Switching back to main must not read the unchanged files. for i in 0..n { if i != 7 { let p = origin.join(format!("f/{i:04}.txt")); fs::set_permissions(&p, fs::Permissions::from_mode(0o000)).unwrap(); } } let r = repo.bookmark_switch("main", false).unwrap(); for i in 0..n { let p = origin.join(format!("f/{i:04}.txt")); fs::set_permissions(&p, fs::Permissions::from_mode(0o644)).unwrap(); } assert_eq!((r.written, r.removed), (1, 0), "{r:?}"); assert_eq!(fs::read(origin.join("f/0007.txt")).unwrap(), b"file 7\n"); assert!(repo.snapshot_status("status").unwrap().statuses.is_empty()); // A clone renders every file, across batches. let c = root.join("clone"); Repo::clone(&origin.to_string_lossy(), &c).unwrap(); for i in [0, 2047, 2048, n - 1] { assert_eq!( fs::read(c.join(format!("f/{i:04}.txt"))).unwrap(), format!("file {i}\n").as_bytes() ); } let cloned = Repo::open(&c).unwrap(); assert!(cloned .snapshot_status("status") .unwrap() .statuses .is_empty()); } /// A file written just before the manifest (inside the racy window) is /// checked by its hash: trusted when equal, caught when edited in place /// with the same size and time. #[test] fn a_racy_manifest_entry_is_checked_by_hash() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"one\n"); repo.commit("fresh", &[]).unwrap(); assert!(repo.clean_by_manifest().unwrap(), "fresh but equal"); let t = fs::metadata(root.join("a.txt")) .unwrap() .modified() .unwrap(); write(root, "a.txt", b"two\n"); fs::File::options() .write(true) .open(root.join("a.txt")) .unwrap() .set_modified(t) .unwrap(); assert!( !repo.clean_by_manifest().unwrap(), "same size and time, other bytes" ); assert_eq!( statuses(&repo.snapshot_status("status").unwrap()), vec![("a.txt".to_string(), ChangeStatus::Modified)] ); } /// A fetch on one machine moves changes as stored, text blobs among the /// objects; a change whose text blob did not come is refused, not stored. #[test] fn a_raw_bundle_without_a_changes_text_is_refused() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let a = Repo::init(&root.join("a")).unwrap(); let big: String = (0..3000).map(|i| format!("line {i}\n")).collect(); write(&root.join("a"), "big.txt", big.as_bytes()); let h = a.commit("big", &[]).unwrap(); let bundle = a.bundle_raw_for(&[h], None).unwrap(); assert_eq!(bundle.raw_changes.len(), 1); assert!(bundle.changes.is_empty()); let b = Repo::init(&root.join("b")).unwrap(); let mut without = bundle.clone(); without.objects.retain(|(ty, _)| *ty != ObjectType::Blob); let e = b.import_bundle(&without, false).unwrap_err(); assert!(e.to_string().contains("came without its text blob"), "{e}"); assert!(!b.store().has(&h)); assert_eq!(b.import_bundle(&bundle, false).unwrap(), vec![h]); assert_eq!(b.store().get_change(&h).unwrap().message, "big"); } /// oxygen#30 (vscode): a directory deleted in one line of history and made /// again was a second node for one Git path, so a file moved into it on /// another line, where the first node still stood, ended up in two places. /// An import now brings the directory back rather than make a new one. #[test] fn git_import_brings_a_directory_back_rather_than_making_a_second() { hermetic(); if std::process::Command::new("git") .arg("--version") .output() .is_err() { eprintln!("git not installed; skipping"); return; } let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let g = root.join("src.git"); fs::create_dir_all(&g).unwrap(); let git = |args: &[&str]| -> String { let out = std::process::Command::new("git") .arg("-C") .arg(&g) .args([ "-c", "user.name=Grace", "-c", "user.email=grace@example.org", "-c", "commit.gpgsign=false", ]) .args(args) .output() .unwrap(); assert!( out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_string() }; git(&["init", "-q", "-b", "main"]); write(&g, "d/a.txt", b"a\n"); write(&g, "c/x.txt", b"x\n"); git(&["add", "."]); git(&["commit", "-qm", "base"]); // One line: move x into d while d still stands. git(&["checkout", "-qb", "side"]); git(&["mv", "c/x.txt", "d/x.txt"]); git(&["commit", "-qm", "x into d"]); // The other: d goes, comes back, and x moves into it too. git(&["checkout", "-q", "main"]); git(&["rm", "-q", "d/a.txt"]); git(&["commit", "-qm", "d goes"]); write(&g, "d/b.txt", b"b\n"); git(&["add", "."]); git(&["commit", "-qm", "d comes back"]); git(&["mv", "c/x.txt", "d/x.txt"]); git(&["commit", "-qm", "x into d again"]); git(&["merge", "-q", "--no-edit", "side"]); let repo = Repo::init(&root.join("o")).unwrap(); repo.import_git(&g, "main").unwrap(); let v = repo.verify_git_import(&g, "main").unwrap(); assert!(v.mismatches.is_empty(), "{:?}", v.mismatches); let st = repo .load_state_history(&repo.bookmark_heads().unwrap()) .unwrap(); let view = st.tree().view(); assert!(view.conflicts.is_empty(), "{:?}", view.conflicts); // The directory is the one the first commit made. // Commits made in one second tie on time: find the first by its message. let first = repo .log() .unwrap() .into_iter() .find(|e| e.change.message.starts_with("base")) .unwrap(); let base = repo .load_state_history(&[first.hash]) .unwrap() .tree() .view(); assert_eq!(view.entries["d"].node, base.entries["d"].node); } /// oxygen#30, the rest: a placement left under a directory another line /// removed is out of sight, so no diff reaches it; the merge that settles /// where the file is retires it. #[test] fn git_import_merge_retires_a_placement_under_a_removed_directory() { hermetic(); if std::process::Command::new("git") .arg("--version") .output() .is_err() { eprintln!("git not installed; skipping"); return; } let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let g = root.join("src.git"); fs::create_dir_all(&g).unwrap(); let run = |args: &[&str]| { std::process::Command::new("git") .arg("-C") .arg(&g) .args([ "-c", "user.name=Grace", "-c", "user.email=grace@example.org", "-c", "commit.gpgsign=false", ]) .args(args) .output() .unwrap() }; let git = |args: &[&str]| { let out = run(args); assert!( out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr) ); }; git(&["init", "-q", "-b", "main"]); write(&g, "c/x.txt", b"x\n"); write(&g, "keep.txt", b"k\n"); git(&["add", "."]); git(&["commit", "-qm", "base"]); // One line renames the file within c; the other moves it out of c, // which goes. git(&["checkout", "-qb", "side"]); git(&["mv", "c/x.txt", "c/y.txt"]); git(&["commit", "-qm", "rename within c"]); git(&["checkout", "-q", "main"]); fs::create_dir_all(g.join("n")).unwrap(); git(&["mv", "c/x.txt", "n/x.txt"]); git(&["commit", "-qm", "move out of c"]); // Git cannot merge the two renames; the merge keeps n/x.txt. let _ = run(&["merge", "-q", "side"]); let _ = run(&[ "rm", "-q", "--cached", "--ignore-unmatch", "c/y.txt", "c/x.txt", ]); let _ = std::fs::remove_dir_all(g.join("c")); write(&g, "n/x.txt", b"x\n"); git(&["add", "-A"]); git(&["commit", "-qm", "merge: keep n/x.txt"]); let repo = Repo::init(&root.join("o")).unwrap(); repo.import_git(&g, "main").unwrap(); let v = repo.verify_git_import(&g, "main").unwrap(); assert!(v.mismatches.is_empty(), "{:?}", v.mismatches); let st = repo .load_state_history(&repo.bookmark_heads().unwrap()) .unwrap(); let view = st.tree().view(); assert!(view.conflicts.is_empty(), "{:?}", view.conflicts); assert!(view.entries.contains_key("n/x.txt")); } /// oxygen#30, orphans: a file added on one line into a directory the other /// line removed is placed where the tree cannot show it; Git's merge keeps /// the file, and the import's merge leaves exactly one of it. #[test] fn git_import_merge_leaves_no_orphan_behind() { hermetic(); if std::process::Command::new("git") .arg("--version") .output() .is_err() { eprintln!("git not installed; skipping"); return; } let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let g = root.join("src.git"); fs::create_dir_all(&g).unwrap(); let git = |args: &[&str]| { let out = std::process::Command::new("git") .arg("-C") .arg(&g) .args([ "-c", "user.name=Grace", "-c", "user.email=grace@example.org", "-c", "commit.gpgsign=false", ]) .args(args) .output() .unwrap(); assert!( out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr) ); }; git(&["init", "-q", "-b", "main"]); write(&g, "d/f.txt", b"f\n"); write(&g, "keep.txt", b"k\n"); git(&["add", "."]); git(&["commit", "-qm", "base"]); git(&["checkout", "-qb", "side"]); write(&g, "d/new.txt", b"new\n"); git(&["add", "."]); git(&["commit", "-qm", "add into d"]); git(&["checkout", "-q", "main"]); git(&["rm", "-q", "d/f.txt"]); git(&["commit", "-qm", "d goes"]); git(&["merge", "-q", "--no-edit", "side"]); let repo = Repo::init(&root.join("o")).unwrap(); repo.import_git(&g, "main").unwrap(); let v = repo.verify_git_import(&g, "main").unwrap(); assert!(v.mismatches.is_empty(), "{:?}", v.mismatches); let st = repo .load_state_history(&repo.bookmark_heads().unwrap()) .unwrap(); let view = st.tree().view(); assert!(view.conflicts.is_empty(), "{:?}", view.conflicts); assert!(view.entries.contains_key("d/new.txt")); } /// fsck checks a change's text as the blobs it is stored in, without /// decoding it: a missing text blob is an error named for the change. #[test] fn fsck_names_a_missing_text_blob() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); let big: String = (0..3000).map(|i| format!("line {i}\n")).collect(); write(root, "big.txt", big.as_bytes()); let h = repo.commit("big", &[]).unwrap(); assert_eq!(repo.fsck().unwrap().errors(), 0); let (_, texts) = repo.store().get_change_lazy(&h).unwrap(); let Some(crate::objects::TextRef::Blob(b, _, _)) = texts.first().copied() else { panic!("a stored text") }; repo.store().remove_loose_for_test(&b).unwrap(); let rep = repo.fsck().unwrap(); assert!(rep.errors() > 0); assert!( rep.problems.iter().any(|p| p.what.contains(&h.short(12))), "{:?}", rep.problems ); } /// oxygen#32: a subrepo on the same hub as one of the parent's remotes is /// reached with that remote's token and CA (a private subrepo then clones /// for anyone who can read the parent); another hub gets none of them. #[test] fn a_subrepo_on_the_parents_hub_borrows_its_credentials() { hermetic(); let dir = tempfile::tempdir().unwrap(); let mut repo = Repo::init(dir.path()).unwrap(); repo.config .set( "remote.origin.url", "https://Hub.example:7343/doomsdayone/metalgear", ) .unwrap(); repo.config.set("remote.origin.token", "secret").unwrap(); repo.config .set("remote.origin.ca", "/tmp/hub-ca.pem") .unwrap(); let same = repo.credentials_for("https://hub.example:7343/doomsdayone/rstar-api"); assert_eq!(same.token.as_deref(), Some("secret")); assert_eq!(same.ca.as_deref(), Some(Path::new("/tmp/hub-ca.pem"))); for other in [ "https://elsewhere.example:7343/doomsdayone/rstar-api", "https://hub.example/doomsdayone/rstar-api", "http://hub.example:7343/doomsdayone/rstar-api", ] { let o = repo.credentials_for(other); assert_ne!(o.token.as_deref(), Some("secret"), "{other}"); } } /// oxygen#47: a remote with no token of its own uses the user's credential /// store for its hub, and a nested checkout finds its parent's remote /// token there instead of keeping a copy. #[test] fn remotes_use_the_credential_store_and_the_enclosing_checkout() { hermetic(); let home = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap(); let mut parent = Repo::init(dir.path()).unwrap().with_home(home.path()); parent .config .set("remote.origin.url", "https://hub.example/o/parent") .unwrap(); parent.save_config().unwrap(); assert_eq!(parent.remote_options("origin").token, None); crate::credentials::Credentials::at(home.path()) .save("https://hub.example", "stored", Some("andy"), None) .unwrap(); assert_eq!( parent.remote_options("origin").token.as_deref(), Some("stored") ); let empty_home = tempfile::tempdir().unwrap(); let nested = dir.path().join("deps").join("child"); let mut child = Repo::init(&nested).unwrap().with_home(empty_home.path()); child .config .set("remote.origin.url", "https://hub.example/o/child") .unwrap(); child.save_config().unwrap(); assert_eq!(child.remote_options("origin").token, None); parent .config .set("remote.origin.token", "checkout") .unwrap(); parent.save_config().unwrap(); assert_eq!( child.remote_options("origin").token.as_deref(), Some("checkout") ); // Another hub's checkout token is not borrowed. child .config .set("remote.origin.url", "https://other.example/o/child") .unwrap(); assert_eq!(child.remote_options("origin").token, None); } #[test] fn the_metadata_cache_survives_a_torn_tail() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"1\n"); let h = repo.commit("first", &[]).unwrap(); let meta = repo.store().get_change_meta(&h).unwrap(); let cache = repo.store().dir().join("metas"); let len = fs::metadata(&cache).unwrap().len(); assert!(len > 0, "a change read is cached"); // A torn record at the end: the cache reads up to it and cuts it off. fs::OpenOptions::new() .append(true) .open(&cache) .unwrap() .write_all(&[7; 20]) .unwrap(); let again = Repo::open(root).unwrap(); assert_eq!(again.store().get_change_meta(&h).unwrap(), meta); assert_eq!(fs::metadata(&cache).unwrap().len(), len); } #[test] fn a_manifest_that_does_not_check_reads_as_none() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"1\n"); repo.commit("first", &[]).unwrap(); let path = repo.manifest_path(); let mut bytes = fs::read(&path).unwrap(); assert!(Repo::open(root).unwrap().read_manifest().unwrap().is_some()); let last = bytes.len() - 1; bytes[last] ^= 1; fs::write(&path, &bytes).unwrap(); assert!(Repo::open(root).unwrap().read_manifest().unwrap().is_none()); // The next status reads the files and writes it again. let again = Repo::open(root).unwrap(); assert!(!again.snapshot("status").unwrap().changed); assert!(again.read_manifest().unwrap().is_some()); } #[test] fn a_commit_leaves_a_manifest_that_proves_the_working_copy_clean() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"1\n"); write(root, "b.txt", b"2\n"); // b.txt outside the racy window, so snapshots take it as unchanged. let old = std::time::SystemTime::now() - std::time::Duration::from_secs(60); fs::File::options() .write(true) .open(root.join("b.txt")) .unwrap() .set_modified(old) .unwrap(); repo.commit("first", &[]).unwrap(); assert!(repo.clean_by_manifest().unwrap(), "after the first commit"); repo.snapshot_status("status").unwrap(); write(root, "a.txt", b"1\n2\n"); repo.commit("second", &[]).unwrap(); assert!( repo.clean_by_manifest().unwrap(), "after a commit of one file" ); // Edited, seen by a status, edited again, then committed: the status // left the first edit's stat, which the commit must not keep. write(root, "a.txt", b"1\n2\n3\n"); repo.snapshot_status("status").unwrap(); write(root, "a.txt", b"1\n2\n3\n4\n5\n"); repo.commit("third", &[]).unwrap(); assert!( repo.clean_by_manifest().unwrap(), "after an edit a status saw" ); } #[test] fn a_conflicted_rebase_can_be_resolved_in_the_working_copy() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); write(root, "a.txt", b"1\n2\n"); let a = repo.commit("A", &[]).unwrap(); repo.bookmark_create("develop", Some(&[a])).unwrap(); write(root, "a.txt", b"1\nX\n"); let c = repo.commit("C: 2 to X", &[]).unwrap(); repo.bookmark_switch("develop", false).unwrap(); write(root, "a.txt", b"1\nY\n"); let d = repo.commit("D: 2 to Y", &[]).unwrap(); repo.bookmark_switch("main", false).unwrap(); let err = repo.rebase(&c, &[d]).unwrap_err(); assert!(matches!(err, RepoError::Conflicted(_)), "{err}"); let r = repo.rebase_into_working_copy(&c, &[d]).unwrap(); assert!(r.changed && r.checked_out); let snap = repo.snapshot_status("status").unwrap(); assert!( snap.base_snapshot.is_conflicted(), "the conflict is in the working copy" ); let text = fs::read_to_string(root.join("a.txt")).unwrap(); assert!(text.contains('X') && text.contains('Y'), "{text}"); write(root, "a.txt", b"1\nXY\n"); repo.commit("Resolve", &[]).unwrap(); let st = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap(); assert!(st.contains(&c) && st.contains(&d)); let files = st.materialize().files; assert!(!files["a.txt"].is_conflicted()); assert!( matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"1\nXY\n") ); // A change the current bookmark does not hold is refused. repo.bookmark_switch("develop", false).unwrap(); write(root, "b.txt", b"b\n"); let e = repo.commit("E", &[]).unwrap(); repo.bookmark_switch("main", false).unwrap(); let err = repo.rebase_into_working_copy(&e, &[a]).unwrap_err(); assert!( err.to_string().contains("not on the current bookmark"), "{err}" ); } #[test] fn an_ignore_rule_drops_an_added_file_but_not_a_tracked_one() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"a\n"); write(root, "kept.log", b"tracked\n"); repo.commit("first", &[]).unwrap(); write(root, "build.log", b"made\n"); let snap = repo.snapshot_status("status").unwrap(); assert!(snap.statuses.iter().any(|s| s.path == "build.log")); // A rule written afterwards leaves the added file out again, while the // committed one stays tracked. write(root, ".gitignore", b"*.log\n"); let snap = repo.snapshot_status("status").unwrap(); let paths: Vec<&str> = snap.statuses.iter().map(|s| s.path.as_str()).collect(); assert_eq!(paths, vec![".gitignore"], "{paths:?}"); write(root, "kept.log", b"tracked, edited\n"); let snap = repo.snapshot_status("status").unwrap(); assert!(snap.statuses.iter().any(|s| s.path == "kept.log")); } #[test] fn shed_graphs_come_back_the_same() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"1\n2\n3\n"); write(root, "b.txt", b"x\n"); repo.commit("first", &[]).unwrap(); write(root, "a.txt", b"1\ntwo\n3\n4\n"); repo.commit("second", &[]).unwrap(); let heads = repo.bookmark_heads().unwrap(); let mut st = repo.load_state_history(&heads).unwrap(); let view = st.tree().view(); let nodes: Vec = ["a.txt", "b.txt"] .iter() .map(|p| view.entries[*p].node) .collect(); repo.load_graphs_of(&mut st, &nodes).unwrap(); let before: Vec> = nodes.iter().map(|n| st.render_file(n).0).collect(); assert_eq!(st.built_count(), 2); let shed = st.shed_graphs(|b| repo.store_blob(b)).unwrap(); assert_eq!((shed, st.built_count()), (2, 0)); // A later change applies on top of the shed history. write(root, "a.txt", b"1\ntwo\n3\n4\n5\n"); let h3 = repo.commit("third", &[]).unwrap(); let (skeleton, texts) = repo.store().get_change_lazy(&h3).unwrap(); st.add_known(h3, skeleton, texts); repo.load_graphs_of(&mut st, &nodes).unwrap(); let after: Vec> = nodes.iter().map(|n| st.render_file(n).0).collect(); assert_eq!(after[1], before[1]); assert_eq!(after[0], b"1\ntwo\n3\n4\n5\n"); } #[test] fn many_edited_files_diff_in_parallel() { // Enough edited files that the builder diffs them in parallel; the // commit must hold exactly what is on disk, and a second snapshot // must find nothing left to commit. hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); let text = |i: usize, round: usize| { let mut s = String::new(); for line in 0..50 { if round > 0 && line % 7 == i % 7 { s.push_str(&format!("edited {round} {i} {line}\n")); } else { s.push_str(&format!("file {i} line {line}\n")); } } if round > 0 && i.is_multiple_of(3) { s.push_str("appended\n"); } s }; for i in 0..40 { write(root, &format!("d/f{i}.txt"), text(i, 0).as_bytes()); } repo.commit("first", &[]).unwrap(); for i in 0..40 { write(root, &format!("d/f{i}.txt"), text(i, 1).as_bytes()); } let h = repo.commit("edit every file", &[]).unwrap(); let snap = repo.load_state(&[h]).unwrap().materialize(); for i in 0..40 { let want = text(i, 1); assert!( matches!(&snap.files[&format!("d/f{i}.txt")].content, FileContent::Text { bytes, .. } if bytes == want.as_bytes()), "d/f{i}.txt as committed" ); } assert!( repo.commit("nothing", &[]).is_err(), "nothing left to commit" ); } /// oxygen#56: after a fetch, how a bookmark stands against its remote copy /// is known (fetch printed "up to date" while main was behind), a change /// only fetched resolves and says where it is, and a checkout whose only /// remote is not `origin` uses that one. #[test] fn a_fetched_change_shows_as_behind_and_resolves() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = Repo::init(&root.join("origin")).unwrap(); drop(origin); let url = root.join("origin").to_string_lossy().to_string(); let a = Repo::clone(&url, &root.join("a")) .unwrap() .with_home(root.join("home-a")); write(&root.join("a"), "f.txt", b"one\n"); a.commit("one", &[]).unwrap(); a.push("origin", false).unwrap(); let b = Repo::clone(&url, &root.join("b")) .unwrap() .with_home(root.join("home-b")); assert_eq!(b.tracking("origin", "main").unwrap(), Some((0, 0))); write(&root.join("a"), "f.txt", b"one\ntwo\n"); let h2 = a.commit("two", &[]).unwrap(); a.push("origin", false).unwrap(); b.fetch("origin").unwrap(); // Fetched, not pulled: one behind, the change found and placed. assert_eq!(b.tracking("origin", "main").unwrap(), Some((0, 1))); assert_eq!(b.tracking("origin", "nope").unwrap(), None); assert_eq!( b.resolve_change(&format!("#{}", &h2.to_hex()[..12])) .unwrap(), h2 ); assert_eq!( b.remote_only_location(&h2).unwrap().as_deref(), Some("origin/main") ); // Fetching again changes nothing: still behind, which is what the // CLI now says rather than "up to date". b.fetch("origin").unwrap(); assert_eq!(b.tracking("origin", "main").unwrap(), Some((0, 1))); b.pull("origin").unwrap(); assert_eq!(b.tracking("origin", "main").unwrap(), Some((0, 0))); assert_eq!(b.remote_only_location(&h2).unwrap(), None); // One remote, not called origin: it is the default. let mut c = Repo::init(&root.join("c")).unwrap(); assert_eq!(c.default_remote(), "origin"); c.config.set("remote.earth.url", &url).unwrap(); assert_eq!(c.default_remote(), "earth"); c.config.set("remote.origin.url", &url).unwrap(); assert_eq!(c.default_remote(), "origin"); } /// oxygen#57: a clone from a relative local path records the remote as an /// absolute path, so fetch still finds it from inside the clone. #[test] fn a_clone_from_a_relative_path_records_it_absolute() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); Repo::init(&root.join("origin")).unwrap(); let rel = crate::protocol::stored_url("relative/repo"); assert!(std::path::Path::new(&rel).is_absolute(), "{rel}"); assert_eq!( crate::protocol::stored_url("https://hub.example/o/n"), "https://hub.example/o/n" ); assert!(crate::protocol::stored_url("file://x").starts_with("file:///")); assert!(crate::protocol::stored_url("dir://x").starts_with("dir:///")); // A bucket is kept as written: it is not a path. assert_eq!( crate::protocol::stored_url("s3://bucket/prefix?profile=p"), "s3://bucket/prefix?profile=p" ); let url = root.join("origin").to_string_lossy().to_string(); let b = Repo::clone(&url, &root.join("b")).unwrap(); assert_eq!(b.config.remotes["origin"], url); b.fetch("origin").unwrap(); } /// oxygen#60 and #61: a workspace added on a bookmark is on that bookmark, /// and gets its subrepos at their pins from this workspace's copies, with /// their remotes pointing where the subrepos come from. #[test] fn a_workspace_added_on_a_bookmark_is_on_it_with_its_subrepos() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); Repo::init(&root.join("lib-origin")).unwrap(); let lib_url = root.join("lib-origin").to_string_lossy().to_string(); let lib = Repo::clone(&lib_url, &root.join("lib")) .unwrap() .with_home(root.join("home")); write(&root.join("lib"), "lib.txt", b"v1\n"); let l1 = lib.commit("lib v1", &[]).unwrap(); assert!(lib.push("origin", false).unwrap()[0].ok); let app_dir = root.join("app"); let app = Repo::init(&app_dir).unwrap().with_home(root.join("home")); write(&app_dir, "main.txt", b"app\n"); app.submodule_add(&lib_url, "libs/lib", None).unwrap(); app.commit("app with lib", &[]).unwrap(); // A second bookmark, and the main workspace on another. app.bookmark_create("develop", None).unwrap(); app.bookmark_create("feature", None).unwrap(); let mut app = app; app.bookmark_switch("feature", false).unwrap(); assert_eq!(app.config.bookmark, "feature"); // The library's origin goes away: the subrepo must come from here. std::fs::rename(root.join("lib-origin"), root.join("lib-origin-gone")).unwrap(); let heads = app.resolve_heads("develop").unwrap(); let ws = app .workspace_add_on(&root.join("ws"), Some(&heads), Some("develop")) .unwrap(); assert_eq!( ws.config.bookmark, "develop", "the new workspace is on develop" ); assert_eq!( app.config.bookmark, "feature", "and this one stays on its own" ); let sub = Repo::open(&root.join("ws/libs/lib")).unwrap(); assert_eq!( sub.config.remotes["origin"], lib_url, "its origin is the subrepo's URL" ); assert_eq!(sub.bookmark_heads().unwrap(), vec![l1], "at its pin"); assert_eq!( std::fs::read(root.join("ws/libs/lib/lib.txt")).unwrap(), b"v1\n" ); } /// Revsets (docs/revset_query_language.md): symbols, relations, set /// operators and the predicates, over a small graph with two heads and /// a rename. #[test] fn revsets_select_changes_by_relation_author_text_date_and_path() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); repo.create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); write(root, "src/a.rs", b"one\n"); let a = repo.commit("first: add a", &[]).unwrap(); write(root, "src/a.rs", b"one\ntwo\n"); let b = repo.commit("second: grow a", &[]).unwrap(); // A second head, independent of b: a new file on top of a. repo.checkout(&[a], false, "checkout a").unwrap(); write(root, "docs/notes.md", b"notes\n"); let c = repo.commit("third: notes", &[]).unwrap(); // Back to both heads, then rename a.rs: the node keeps its identity. repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main") .unwrap(); fs::remove_file(root.join("src/a.rs")).unwrap(); write(root, "src/z.rs", b"one\ntwo\n"); let d = repo.commit("fourth: rename a to z", &[]).unwrap(); let wc = repo.working_copy().unwrap().0; // A saved revset is checked when set. repo.set_user_config("revset.mine", "author(me) & ::main") .unwrap(); assert!(repo.set_user_config("revset.bad", "a &").is_err()); let set = |src: &str| -> Vec { let mut v = repo.revset(src).unwrap().hashes; v.sort(); v }; let sorted = |hs: &[ChangeHash]| -> Vec { let mut v = hs.to_vec(); v.sort(); v }; // Changes commute: a new file depends on nothing and the rename on the // file's creation, so the graph is a, b->a, d->a, c, and the working // copy on the three heads b, c, d. let heads = sorted(&repo.bookmark_heads().unwrap()); let meta = |h: &ChangeHash| repo.store().get_change_meta(h).unwrap(); assert!(heads.contains(&d) && heads.contains(&c)); // Symbols and closures. assert_eq!(set("main"), heads); assert_eq!(set("::main"), sorted(&[a, b, c, d])); assert_eq!(set("@"), vec![wc]); assert_eq!(set("::@"), sorted(&[a, b, c, d, wc])); assert_eq!(set(&format!("::#{}", b.short(12))), sorted(&[a, b])); assert_eq!(set(&format!("#{}::", a.short(12))), sorted(&[a, b, d, wc])); assert_eq!( set(&format!("#{}::#{}", a.short(12), b.short(12))), sorted(&[a, b]) ); // Ranges: what main has beyond b, and `..` forms. assert_eq!(set(&format!("#{}..main", b.short(12))), sorted(&[c, d])); assert_eq!(set(&format!("#{}..", c.short(12))), sorted(&[a, b, d, wc])); assert_eq!(set("..main"), sorted(&[a, b, c, d])); // Operators and precedence: ~ binds tighter than &, & than |. assert_eq!( set(&format!("::main ~ #{}", d.short(12))), sorted(&[a, b, c]) ); assert_eq!( set(&format!("::main & #{} | #{}", b.short(12), c.short(12))), sorted(&[b, c]) ); assert_eq!(set("all() ~ ::main"), vec![wc]); // Relations. let mut head_deps: Vec = heads.iter().flat_map(|h| meta(h).deps).collect(); head_deps.sort(); head_deps.dedup(); assert_eq!(set("deps(main)"), head_deps); let on_a: Vec = [b, c, d] .into_iter() .filter(|h| meta(h).deps.contains(&a)) .collect(); assert_eq!(set(&format!("dependents(#{})", a.short(12))), sorted(&on_a)); assert_eq!(set("heads(::main)"), heads); assert_eq!(set("roots(::main)"), sorted(&[a, c])); assert_eq!( set(&format!( "heads(#{} | #{} | #{})", a.short(12), b.short(12), c.short(12) )), sorted(&[b, c]) ); assert_eq!(set("latest(::main, 1)"), vec![d]); assert_eq!(set("kind(working-copy)"), vec![wc]); assert_eq!(set("none()"), vec![]); // Predicates: author by name, email, key prefix and `me`. let key = repo.config.author_key.unwrap(); assert_eq!(set("author(ada) & ::main"), sorted(&[a, b, c, d])); assert_eq!(set("author(\"ada@example.org\") & ::main").len(), 4); assert_eq!(set(&format!("author({}) & ::main", key.short(12))).len(), 4); assert_eq!(set("author(me) & ::main").len(), 4); assert_eq!(set("author(nobody)"), vec![]); assert_eq!(set("description(notes)"), vec![c]); assert_eq!(set("description(\"rename a\")"), vec![d]); assert_eq!(set("date(-1h..) & ::main").len(), 4); assert_eq!(set("date(..2000-01-01)"), vec![]); // touches follows the node through the rename, in the current state. assert_eq!(set("touches(\"src/z.rs\")"), sorted(&[a, b, d])); assert_eq!(set("touches(\"src/**\") & ::main"), sorted(&[a, b, d])); assert_eq!(set("touches(\"docs/*\")"), vec![c]); assert_eq!(set("touches(\"nothing/*\")"), vec![]); // Newest first in the result order. assert_eq!(repo.revset("::main").unwrap().hashes[0], d); // The saved revset, and errors that say what is wrong. assert_eq!(set("mine()").len(), 4); let e = repo.revset("nosuch()").unwrap_err().to_string(); assert!(e.contains("unknown function nosuch()"), "{e}"); let e = repo.revset("main &").unwrap_err().to_string(); assert!(e.contains("at character"), "{e}"); let e = repo.revset("zzzz").unwrap_err().to_string(); assert!(e.contains("no change matches"), "{e}"); let e = repo .revset_with_budget("::main", std::time::Duration::ZERO) .unwrap_err() .to_string(); assert!(e.contains("budget"), "{e}"); // The log entries carry the bookmark's heads and the working copy. let entries = repo.log_revset("::@").unwrap(); assert!(entries[0].is_working_copy && entries.iter().any(|e| e.is_head && e.hash == d)); // Trust predicates (§2.4): nothing is signed yet (core.sign = seal, no // push); then a signed change, a tag, and a revocation. assert_eq!(set("unsigned() & ::main"), sorted(&[a, b, c, d])); assert_eq!(set("signed_by(me)"), vec![]); repo.sign_change(&d).unwrap(); assert_eq!(set("signed_by(me)"), vec![d]); assert_eq!(set("signed_by(ada@example.org)"), vec![d]); assert_eq!(set(&format!("signed_by({})", key.short(12))), vec![d]); assert_eq!(set("unsigned() & ::main"), sorted(&[a, b, c])); assert_eq!(set("delegated()"), vec![]); assert_eq!(set("revoked()"), vec![]); assert_eq!(set("attested(tag)"), vec![]); repo.tag_create("v1", &[b], "first").unwrap(); assert_eq!(set("attested(tag)"), sorted(&[a, b])); assert_eq!(set("attested(tag) & unsigned()"), sorted(&[a, b])); assert_eq!(set("attested(receipt)"), vec![]); assert_eq!(set("built(test)"), vec![]); let e = repo.revset("attested(seal)").unwrap_err().to_string(); assert!(e.contains("tag, receipt or ref"), "{e}"); repo.revoke_identity(&key, "test").unwrap(); assert_eq!(set("revoked()"), vec![d]); assert_eq!(set("signed_by(me) ~ revoked()"), vec![]); } #[test] fn a_rotation_is_recorded_wherever_the_old_key_was_named() { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let home = root.join("home"); // Ada's identity lives in the user file; repository `a` pins the same // key in its own file, repository `b` names none. let mut a = Repo::init(&root.join("a")).unwrap().with_home(&home); let (key, _) = a .create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); a.config.set("author.key", &key.to_hex()).unwrap(); a.save_config().unwrap(); let agent = SecretKey::generate().identity("Agent", "agent@example.org", 1); let r = a .delegate_add(agent.clone(), crate::identity::Scope::CHANGES, None) .unwrap(); assert_eq!( read_config_file(&home.join("config.toml")).unwrap()["author.key"], r.new.to_hex(), "the user file follows" ); assert_eq!( read_config_file(&root.join("a/.oxy/config")).unwrap()["author.key"], r.new.to_hex(), "the pinning repository follows" ); let b = Repo::init(&root.join("b")).unwrap().with_home(&home); assert_eq!( b.config.author_key, Some(r.new), "a checkout without a pin follows the user file" ); // A repository-scoped identity leaves the user file alone. let other_home = root.join("home2"); let mut c = Repo::init(&root.join("c")).unwrap().with_home(&other_home); let (ck, _) = c .create_identity("Cy", "cy@example.org", IdentityScope::Repo) .unwrap(); assert!(!other_home.join("config.toml").exists()); let r2 = c.rotate_identity().unwrap(); assert_ne!(r2.new, ck); assert_eq!( read_config_file(&root.join("c/.oxy/config")).unwrap()["author.key"], r2.new.to_hex() ); assert!( !other_home.join("config.toml").exists(), "a repository-scoped identity stays repository-scoped" ); // Several delegates in one rotation; a repeat is refused. let (d1, d2) = ( SecretKey::generate().identity("D1", "d1@x", 1), SecretKey::generate().identity("D2", "d2@x", 1), ); let r3 = c .delegate_add_many(vec![d1.clone(), d2], crate::identity::Scope::CHANGES, None) .unwrap(); assert_eq!(r3.identity.delegates.len(), 2); assert!(c .delegate_add(d1, crate::identity::Scope::CHANGES, None) .is_err()); } /// Two unrelated histories that both add the same file, joined by two /// independent merges on two branches, then those branches merged: Git /// merges identical additions cleanly, and the import must too (#71, /// found in VS Code's history). #[test] fn git_import_joins_unrelated_histories_that_add_the_same_file() { // Batches of one: every merge renders apart from the state, where a // file whose graph the state had built came out empty (#71). crate::repo::git::set_merge_batch_for_tests(Some(1)); 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() }; let text = b"/* header */\nconst a = 1;\nconst b = 2;\n"; // History A: main. git(&["init", "-q", "-b", "main"]); write(&g, "gulpfile.js", text); write(&g, "a.txt", b"a\n"); git(&["add", "."]); git(&["commit", "-q", "-m", "A root"]); // History B: an unrelated root adding the same file, identical. git(&["checkout", "-q", "--orphan", "fork"]); git(&["rm", "-q", "-r", "--cached", "."]); for f in ["gulpfile.js", "a.txt"] { let _ = fs::remove_file(g.join(f)); } write(&g, "gulpfile.js", text); write(&g, "b.txt", b"b\n"); git(&["add", "."]); git(&["commit", "-q", "-m", "B root"]); // Two branches from main, each joining B on its own. git(&["checkout", "-q", "-b", "one", "main"]); git(&[ "merge", "-q", "--no-ff", "--allow-unrelated-histories", "-m", "join on one", "fork", ]); git(&["checkout", "-q", "-b", "two", "main"]); write(&g, "a.txt", b"a\ntwo\n"); git(&["commit", "-q", "-am", "work on two"]); git(&[ "merge", "-q", "--no-ff", "--allow-unrelated-histories", "-m", "join on two", "fork", ]); // Then the two joins meet, and the file is reformatted after. git(&["checkout", "-q", "main"]); git(&["merge", "-q", "--no-ff", "-m", "merge one", "one"]); git(&["merge", "-q", "--no-ff", "-m", "merge two", "two"]); write( &g, "gulpfile.js", b"/* header */\nconst a = 1;\nconst b = 3;\n", ); git(&["commit", "-q", "-am", "after"]); let dest = root.join("dest"); let repo = Repo::init(&dest).unwrap().with_home(root.join("home")); repo.import_git(&g, "main").unwrap(); let v = repo.verify_git_import(&g, "main").unwrap(); assert!(v.mismatches.is_empty(), "{:?}", v.mismatches); assert_eq!( fs::read(dest.join("gulpfile.js")).unwrap(), b"/* header */\nconst a = 1;\nconst b = 3;\n" ); } /// As above, but the two joins keep different copies (each branch had a /// different version when it joined), and later work edits each copy: /// the shape of VS Code's c67ef57cda90 (#71). #[test] fn git_import_joins_where_each_join_kept_a_different_copy() { // Batches of one: every merge renders apart from the state, where a // file whose graph the state had built came out empty (#71). crate::repo::git::set_merge_batch_for_tests(Some(1)); hermetic(); if std::process::Command::new("git") .arg("--version") .output() .is_err() { eprintln!("git not installed; skipping"); return; } let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let g = root.join("src.git"); fs::create_dir_all(&g).unwrap(); let run = |args: &[&str]| { std::process::Command::new("git") .arg("-C") .arg(&g) .args([ "-c", "user.name=Grace", "-c", "user.email=grace@example.org", "-c", "commit.gpgsign=false", ]) .args(args) .output() .unwrap() }; let git = |args: &[&str]| -> String { let out = run(args); 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, "gulpfile.js", b"/* header */\nconst a = 1;\nconst b = 2;\n", ); git(&["add", "."]); git(&["commit", "-q", "-m", "A root"]); write( &g, "gulpfile.js", b"/* header */\nconst a = 1;\n\nconst b = 2;\n", ); git(&["commit", "-q", "-am", "whitespace"]); git(&["checkout", "-q", "--orphan", "fork"]); git(&["rm", "-q", "-r", "--cached", "."]); let _ = fs::remove_file(g.join("gulpfile.js")); write( &g, "gulpfile.js", b"/* header */\nconst a = 1;\nconst b = 2;\nconst c = 3;\n", ); git(&["add", "."]); git(&["commit", "-q", "-m", "B root"]); // Branch one joins B keeping main's copy; branch two keeps B's. for (branch, keep) in [("one", "ours"), ("two", "theirs")] { git(&["checkout", "-q", "-b", branch, "main"]); let _ = run(&[ "merge", "-q", "--no-ff", "--allow-unrelated-histories", "-m", "join", "fork", ]); git(&["checkout", &format!("--{keep}"), "gulpfile.js"]); git(&["add", "gulpfile.js"]); git(&["commit", "-q", "-m", &format!("join on {branch}")]); } // Each branch then edits its copy. git(&["checkout", "-q", "one"]); write( &g, "gulpfile.js", b"/* header */\nconst a = 1;\n\nconst b = 2;\n// tslint\n", ); git(&["commit", "-q", "-am", "tslint on one"]); git(&["checkout", "-q", "two"]); write( &g, "gulpfile.js", b"/* header */\nconst a = 1;\nconst b = 2;\nconst c = 3;\n// fmt\n", ); write(&g, "extra.txt", b"x\n"); write(&g, "more.txt", b"m\n"); git(&["add", "."]); git(&["commit", "-q", "-am", "fmt on two"]); // They meet: resolved by hand. git(&["checkout", "-q", "one"]); let _ = run(&["merge", "-q", "--no-ff", "-m", "merge two", "two"]); write( &g, "gulpfile.js", b"/* header */\nconst a = 1;\nconst b = 2;\nconst c = 3;\n// tslint\n// fmt\n", ); git(&["add", "gulpfile.js"]); git(&["commit", "-q", "-m", "merge two into one"]); let dest = root.join("dest"); let repo = Repo::init(&dest).unwrap().with_home(root.join("home")); repo.import_git(&g, "one").unwrap(); let v = repo.verify_git_import(&g, "one").unwrap(); assert!(v.mismatches.is_empty(), "{:?}", v.mismatches); } /// oxygen#68: on a store that is not this disk's directory, the operation /// head is the store's reference record, moved by compare-and-swap. Two /// handles recording from the same head both get through, merged; a swap /// from a stale version is refused; a second repository cannot start on a /// store that already holds one. #[test] fn over_a_store_the_operation_head_moves_by_compare_and_swap() { use crate::strata::{MemoryStore, Store as _}; let backend = std::sync::Arc::new(MemoryStore::new()); crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = Some(backend.clone())); let dir = tempfile::tempdir().unwrap(); let a = Repo::init(dir.path()).unwrap(); assert!( !dir.path().join(".oxy/ops/head").exists(), "the head is not a file" ); let (h0, op0) = a.head_op().unwrap(); let record = backend .read_ref(HEAD_RECORD) .unwrap() .expect("the head is the store's record"); assert_eq!(String::from_utf8(record.0).unwrap(), h0.to_hex()); let b = Repo::open(dir.path()).unwrap(); let state = a.store.put_state(&StateObject::new(vec![])).unwrap(); let mut va = op0.view.clone(); va.bookmarks.insert("from-a".into(), state); let mut vb = op0.view.clone(); vb.bookmarks.insert("from-b".into(), state); let rb = b.record_op(&[h0], "bookmark from-b", vb).unwrap(); assert_eq!(b.head_ref().unwrap(), rb); // a recorded from h0 too: the head moved, so the two are merged. let ra = a.record_op(&[h0], "bookmark from-a", va).unwrap(); let (head, op) = Repo::open(dir.path()).unwrap().head_op().unwrap(); assert_eq!(head, ra); assert!( op.command.ends_with("(merged with a concurrent operation)"), "{}", op.command ); assert_eq!(op.parents, vec![rb, op.parents[1]]); assert!(op.view.bookmarks.contains_key("from-a") && op.view.bookmarks.contains_key("from-b")); // A swap from a version that is no longer current moves nothing. let (h, v) = a.read_head().unwrap(); assert!(b.swap_head(&rb, v.as_ref()).unwrap()); assert!(!a.swap_head(&h, v.as_ref()).unwrap(), "stale"); a.refresh().unwrap(); assert_eq!(a.head_ref().unwrap(), rb); let other = tempfile::tempdir().unwrap(); let err = Repo::init(other.path()) .err() .expect("the store already holds a log") .to_string(); assert!( err.contains("already holds a repository's operation log"), "{err}" ); crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = None); } /// oxygen#68: a repository kept on a store (`storage.url`) holds its /// objects and operation head there, with `.oxy/cache` in front: it works /// as one on disk does, reopens, and survives losing its cache. #[test] fn a_repository_runs_on_a_store_with_a_cache_in_front() { hermetic(); if std::env::var_os("OXY_STORE_BACKEND").is_some() { return; } let dir = tempfile::tempdir().unwrap(); let (root, bucket) = (dir.path().join("work"), dir.path().join("bucket")); let url = format!("dir://{}", bucket.display()); let repo = Repo::init_on(&root, [7; 16], Some(&url)).unwrap(); assert_eq!(repo.config.storage_url.as_deref(), Some(url.as_str())); write(&root, "a.txt", b"one\n"); let h1 = repo.commit("First", &[]).unwrap(); write(&root, "a.txt", b"one\ntwo\n"); let h2 = repo.commit("Second", &[]).unwrap(); assert!( !root.join(".oxy/ops/head").exists(), "the head is in the store" ); assert!(bucket.join("ops/head").is_file()); assert!(root.join(".oxy/cache").is_dir()); drop(repo); // Reopened, and again with no cache at all: everything comes from the store. for drop_cache in [false, true] { if drop_cache { std::fs::remove_dir_all(root.join(".oxy/cache")).unwrap(); } let repo = Repo::open(&root).unwrap(); let log: Vec = repo .op_log() .unwrap() .into_iter() .map(|(_, op)| op.command) .collect(); assert!(log.len() >= 3, "{log:?}"); assert_eq!(repo.store().get_change(&h1).unwrap().message, "First"); let heads = repo .store() .get_state(&repo.view().unwrap().bookmarks["main"]) .unwrap() .heads; assert_eq!(heads, vec![h2]); assert_eq!( repo.load_state(&heads).unwrap().materialize().files.len(), 1 ); } // A second repository cannot start on the same store. let err = Repo::init_on(&dir.path().join("other"), [8; 16], Some(&url)) .err() .unwrap() .to_string(); assert!( err.contains("already holds a repository's operation log"), "{err}" ); let err = Repo::init_on(&dir.path().join("third"), [9; 16], Some("nope://x")) .err() .unwrap() .to_string(); assert!(err.contains("cannot open that kind of store"), "{err}"); } /// oxygen#75: the prompt's quick check agrees with status in a repository /// with a subrepo: clean after a commit, dirty when the subrepo moves to /// another state, clean again once that pin is committed. (It compared the /// subrepo's directory as if it were a file, so it was always dirty.) #[test] fn the_quick_dirty_check_compares_a_subrepo_by_its_pin() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let _lib_origin = Repo::init(&root.join("lib-origin")).unwrap(); let lib_url = root.join("lib-origin").to_string_lossy().to_string(); let lib = Repo::clone(&lib_url, &root.join("lib")) .unwrap() .with_home(root.join("home")); write(&root.join("lib"), "lib.txt", b"v1\n"); lib.commit("lib v1", &[]).unwrap(); assert!(lib.push("origin", false).unwrap()[0].ok); let app_dir = root.join("app"); let app = Repo::init(&app_dir).unwrap().with_home(root.join("home")); write(&app_dir, "main.txt", b"app\n"); app.submodule_add(&lib_url, "libs/lib", None).unwrap(); app.commit("app with lib", &[]).unwrap(); // Out of the racy window, so the stat-only path decides. std::thread::sleep(std::time::Duration::from_millis(2100)); let reopen = || Repo::open(&app_dir).unwrap(); assert_eq!( reopen().quick_dirty().unwrap(), Some(false), "clean, as status says" ); let nested = Repo::open(&app_dir.join("libs/lib")).unwrap(); write(&app_dir.join("libs/lib"), "lib.txt", b"v2\n"); nested.commit("lib v2", &[]).unwrap(); assert_eq!( reopen().quick_dirty().unwrap(), Some(true), "the subrepo moved" ); reopen().commit("pin lib v2", &[]).unwrap(); std::thread::sleep(std::time::Duration::from_millis(2100)); assert_eq!( reopen().quick_dirty().unwrap(), Some(false), "the new pin is committed" ); } #[test] fn a_module_entry_that_was_never_added_is_reported_as_unpinned() { // oxygen#72: a .oxymodules section with no subrepo behind it. hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let _lib_origin = Repo::init(&root.join("lib-origin")).unwrap(); let lib_url = root.join("lib-origin").to_string_lossy().to_string(); let lib = Repo::clone(&lib_url, &root.join("lib")) .unwrap() .with_home(root.join("home")); write(&root.join("lib"), "lib.txt", b"v1\n"); lib.commit("lib v1", &[]).unwrap(); assert!(lib.push("origin", false).unwrap()[0].ok); let app_dir = root.join("app"); let app = Repo::init(&app_dir).unwrap().with_home(root.join("home")); write( &app_dir, ".oxymodules", format!("[libs/lib]\nurl = {lib_url}\n").as_bytes(), ); let unpinned = app.unpinned_modules().unwrap(); assert_eq!(unpinned.len(), 1); assert_eq!(unpinned[0].0, "libs/lib"); assert_eq!(unpinned[0].1.url, lib_url); assert!(app.submodule_update(true, &[]).unwrap().is_empty()); app.submodule_add(&lib_url, "libs/lib", None).unwrap(); assert!(app.unpinned_modules().unwrap().is_empty(), "added now"); } #[test] fn the_bookmark_revset_function_names_only_bookmarks() { // oxygen#73: bookmark(NAME) is a bookmark's heads, never a tag or prefix. hermetic(); let dir = tempfile::tempdir().unwrap(); let repo = Repo::init(dir.path()).unwrap(); write(dir.path(), "a.txt", b"a\n"); let a = repo.commit("A", &[]).unwrap(); write(dir.path(), "b.txt", b"b\n"); let b = repo.commit("B", &[]).unwrap(); let mut heads = repo.revset("bookmark(main)").unwrap().hashes; heads.sort(); let mut want = repo.bookmark_heads().unwrap(); want.sort(); assert_eq!(heads, want); assert!(heads.contains(&b)); assert_eq!(repo.revset("main").unwrap().hashes.len(), heads.len()); // A change prefix is a symbol, not a bookmark. let prefix = repo.store().get_change_meta(&a).unwrap().change_id.short(8); assert!(repo.revset(&prefix).is_ok()); let e = repo .revset(&format!("bookmark({prefix})")) .unwrap_err() .to_string(); assert!(e.contains("no such bookmark"), "{e}"); } /// oxygen#140: a replace conflict is settled from the working copy, as /// `oxy resolve PATH` does: the path's content on disk becomes the /// resolution, and the commit that records it is a Resolution. #[test] fn a_replace_conflict_is_settled_from_the_working_copy() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); let blob = |n: u8| { let mut v = vec![0u8; 64]; v[1] = n; v }; write(root, "img.bin", &blob(1)); write(root, "a.txt", b"a\n"); repo.commit("add img", &[]).unwrap(); repo.bookmark_create("other", None).unwrap(); write(root, "img.bin", &blob(2)); repo.commit("img two", &[]).unwrap(); repo.bookmark_switch("other", false).unwrap(); write(root, "img.bin", &blob(3)); repo.commit("img three", &[]).unwrap(); repo.bookmark_switch("main", false).unwrap(); repo.merge("other").unwrap(); // Without a choice, nothing the working copy holds settles it, and // other work cannot be committed past it. write(root, "img.bin", &blob(9)); write(root, "a.txt", b"a\nmore\n"); assert!( repo.commit("edit a", &[]).is_err(), "refused while conflicted" ); let snap = repo.snapshot("oxy resolve").unwrap(); assert!( repo.choose_resolutions(&snap.base_snapshot, &["a.txt".to_string()]) .is_err(), "a.txt has no replace conflict" ); repo.choose_resolutions(&snap.base_snapshot, &["img.bin".to_string()]) .unwrap(); let snap = repo.snapshot("oxy resolve").unwrap(); assert!(snap .statuses .iter() .any(|s| s.path == "img.bin" && s.status == ChangeStatus::Resolved)); let c = repo.commit("settle img", &[]).unwrap(); assert_eq!( repo.store().get_change(&c).unwrap().kind, ChangeKind::Resolution ); let after = repo .load_state(&repo.bookmark_heads().unwrap()) .unwrap() .materialize(); assert!(!after.is_conflicted()); assert_eq!( after.files["img.bin"].content, FileContent::Opaque { blobs: vec![Repo::blob_ref(&blob(9))] } ); assert!( matches!(&after.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nmore\n") ); // Settled: the choice is forgotten, and the working copy is clean. let snap = repo.snapshot("status").unwrap(); assert!(snap.statuses.is_empty(), "{:?}", snap.statuses); assert!(repo.resolutions().unwrap().is_empty()); } /// oxygen#140: the same for a subrepo pin moved two ways at once. #[test] fn a_pin_conflict_is_settled_from_the_working_copy() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let _lib_origin = Repo::init(&root.join("lib-origin")).unwrap(); let lib_url = root.join("lib-origin").to_string_lossy().to_string(); let lib = Repo::clone(&lib_url, &root.join("lib")) .unwrap() .with_home(root.join("home")); write(&root.join("lib"), "lib.txt", b"v1\n"); lib.commit("lib v1", &[]).unwrap(); assert!(lib.push("origin", false).unwrap()[0].ok); let app_dir = root.join("app"); let mut app = Repo::init(&app_dir).unwrap().with_home(root.join("home")); write(&app_dir, "main.txt", b"app\n"); app.submodule_add(&lib_url, "libs/lib", None).unwrap(); app.commit("pin v1", &[]).unwrap(); let nested = Repo::open(&app_dir.join("libs/lib")) .unwrap() .with_home(root.join("home")); fn pin(app: &Repo, nested: &Repo, text: &[u8], message: &str) { write(nested.root(), "lib.txt", text); nested.commit(message, &[]).unwrap(); app.commit(&format!("pin {message}"), &[]).unwrap(); } app.bookmark_create("other", None).unwrap(); pin(&app, &nested, b"v2\n", "v2"); app.bookmark_switch("other", false).unwrap(); pin(&app, &nested, b"v3\n", "v3"); app.bookmark_switch("main", false).unwrap(); app.merge("other").unwrap(); let merged = app .load_state(&app.bookmark_heads().unwrap()) .unwrap() .materialize(); assert!( matches!(&merged.files["libs/lib"].content, FileContent::Opaque { blobs } if blobs.len() == 2), "a pin conflict" ); // The nested repository moves on once more; that pin is the choice. write(&app_dir.join("libs/lib"), "lib.txt", b"v4\n"); nested.commit("v4", &[]).unwrap(); let snap = app.snapshot("oxy resolve").unwrap(); app.choose_resolutions(&snap.base_snapshot, &["libs/lib".to_string()]) .unwrap(); let c = app.commit("settle the pin", &[]).unwrap(); assert_eq!( app.store().get_change(&c).unwrap().kind, ChangeKind::Resolution ); let after = app .load_state(&app.bookmark_heads().unwrap()) .unwrap() .materialize(); assert!(!after.is_conflicted()); assert!(app.snapshot("status").unwrap().statuses.is_empty()); } #[test] fn carrying_work_over_a_subrepo_already_at_the_target_pin_is_no_conflict() { // oxygen#74: a stale state with .oxymodules but no pin, a newer one // pinning the subrepo, and the nested repository on disk at that pin. hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let _lib_origin = Repo::init(&root.join("lib-origin")).unwrap(); let lib_url = root.join("lib-origin").to_string_lossy().to_string(); let lib = Repo::clone(&lib_url, &root.join("lib")) .unwrap() .with_home(root.join("home")); write(&root.join("lib"), "lib.txt", b"v1\n"); lib.commit("lib v1", &[]).unwrap(); assert!(lib.push("origin", false).unwrap()[0].ok); let app_dir = root.join("app"); let app = Repo::init(&app_dir).unwrap().with_home(root.join("home")); write(&app_dir, "main.txt", b"app\n"); app.submodule_add(&lib_url, "libs/lib", None).unwrap(); // The stale state: registered in .oxymodules, never pinned. fs::rename(app_dir.join("libs/lib"), root.join("aside")).unwrap(); app.commit("initial", &[]).unwrap(); let stale = app.bookmark_heads().unwrap(); fs::rename(root.join("aside"), app_dir.join("libs/lib")).unwrap(); app.commit("pin lib", &[]).unwrap(); let newer = app.bookmark_heads().unwrap(); app.checkout(&stale, false, "test").unwrap(); write(&app_dir, "notes.txt", b"mine\n"); let (_, carried) = app.checkout_carry(&newer, "main", "test").unwrap(); assert_eq!(carried, vec!["notes.txt".to_string()]); assert!(app_dir.join("notes.txt").exists()); } /// oxygen#142: a new bookmark made from main sends what main lacks, not /// its whole history again. #[test] fn a_new_bookmark_sends_only_what_the_remote_lacks() { 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 one = Repo::clone(&url, &root.join("one")) .unwrap() .with_home(root.join("home")); for n in 0..5 { write(&root.join("one"), "a.txt", format!("{n}\n").as_bytes()); one.commit(&format!("main {n}"), &[]).unwrap(); } assert!(one.push("origin", false).unwrap()[0].ok); one.bookmark_create("feature", None).unwrap(); one.bookmark_switch("feature", false).unwrap(); write(&root.join("one"), "b.txt", b"feature\n"); let added = one.commit("feature", &[]).unwrap(); let heads = one.bookmark_heads().unwrap(); let refs = one .open_named_remote("origin") .unwrap() .refs() .unwrap() .refs; assert!( refs.iter().all(|r| r.name != "feature"), "new to the remote" ); let sent = one .negotiate(&one.remote_holds(&refs, &[]), &heads) .unwrap(); assert_eq!(sent, vec![added], "only the change main lacks"); assert!(one.push_bookmark("origin", "feature", false).unwrap()[0].ok); let two = Repo::clone(&url, &root.join("two")).unwrap(); assert!(two.store().has(&added)); } #[test] fn a_bookmark_only_behind_its_remote_catches_up() { // oxygen#74: switch takes the remote's state rather than a stale one. 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 one = Repo::clone(&url, &root.join("one")) .unwrap() .with_home(root.join("home")); write(&root.join("one"), "a.txt", b"a\n"); one.commit("A", &[]).unwrap(); assert!(one.push("origin", false).unwrap()[0].ok); let two = Repo::clone(&url, &root.join("two")) .unwrap() .with_home(root.join("home")); write(&root.join("two"), "b.txt", b"b\n"); two.commit("B", &[]).unwrap(); assert!(two.push("origin", false).unwrap()[0].ok); assert_eq!(one.catch_up_bookmark("main").unwrap(), None, "not fetched"); one.fetch("origin").unwrap(); assert_eq!(one.catch_up_bookmark("main").unwrap(), Some(1)); assert_eq!(one.bookmarks().unwrap()[0].behind, Some(0)); // Local work the remote lacks: left alone. write(&root.join("one"), "c.txt", b"c\n"); one.commit("C", &[]).unwrap(); write(&root.join("two"), "d.txt", b"d\n"); two.commit("D", &[]).unwrap(); assert!(two.push("origin", false).unwrap()[0].ok); one.fetch("origin").unwrap(); assert_eq!(one.catch_up_bookmark("main").unwrap(), None); } #[test] fn a_bookmark_is_compared_with_a_remote_that_has_it_when_there_is_no_origin() { // oxygen#83: remotes earth and gcs, origin removed. hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let _up = Repo::init(&root.join("up")).unwrap(); let url = root.join("up").to_string_lossy().to_string(); let other = Repo::clone(&url, &root.join("other")) .unwrap() .with_home(root.join("home")); write(&root.join("other"), "a.txt", b"a\n"); other.commit("a", &[]).unwrap(); assert!(other.push("origin", false).unwrap()[0].ok); let mut one = Repo::clone(&url, &root.join("one")) .unwrap() .with_home(root.join("home")); one.config.set("remote.earth.url", &url).unwrap(); one.config.set("remote.gcs.url", &url).unwrap(); one.config.set("remote.origin.url", "").unwrap(); one.save_config().unwrap(); write(&root.join("other"), "b.txt", b"b\n"); other.commit("b", &[]).unwrap(); assert!(other.push("origin", false).unwrap()[0].ok); one.fetch("earth").unwrap(); let b = one .bookmarks() .unwrap() .into_iter() .find(|b| b.name == "main") .unwrap(); assert_eq!( (b.remote.as_deref(), b.behind), (Some("earth"), Some(1)), "a stale origin copy is not used" ); assert_eq!( one.default_remote(), "origin", "no default chosen among several" ); let e = one.pull(&one.default_remote()).unwrap_err().to_string(); assert!(e.contains("earth, gcs") && e.contains("core.remote"), "{e}"); one.config.set("core.remote", "earth").unwrap(); assert_eq!(one.default_remote(), "earth"); } #[test] fn a_missing_author_identity_says_how_to_fix_it() { // oxygen#82: an agent home's author.key naming an identity the // repository does not hold. hermetic(); let dir = tempfile::tempdir().unwrap(); let mut repo = Repo::init(dir.path()).unwrap(); let missing = KeyId::from_hex(&"ab".repeat(32)).unwrap(); repo.config.set("author.key", &missing.to_hex()).unwrap(); let e = repo.identity(&missing).unwrap_err().to_string(); assert!( e.contains("this repository has no identity") && e.contains("oxy config author.key"), "{e}" ); assert!(e.contains("this repository's config"), "{e}"); } #[test] fn a_working_copy_behind_its_bookmark_is_flagged_and_pull_brings_it_up() { // oxygen#89 (supermarioland, 2026-10-09): the bookmark moved on without // the working copy, a pull with nothing new left it there, and status // said "clean". hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let _up = Repo::init(&root.join("up")).unwrap(); let url = root.join("up").to_string_lossy().to_string(); let other = Repo::clone(&url, &root.join("other")) .unwrap() .with_home(root.join("home")); write(&root.join("other"), "a.txt", b"a\n"); other.commit("a", &[]).unwrap(); assert!(other.push("origin", false).unwrap()[0].ok); let one = Repo::clone(&url, &root.join("one")) .unwrap() .with_home(root.join("home")); write(&root.join("other"), "b.txt", b"b\n"); other.commit("b", &[]).unwrap(); assert!(other.push("origin", false).unwrap()[0].ok); one.fetch("origin").unwrap(); // The bookmark moves; the working copy does not. let theirs = one.resolve_heads("origin/main").unwrap(); one.bookmark_set("main", &theirs).unwrap(); assert!(!root.join("one/b.txt").exists()); assert_eq!(one.working_copy_behind().unwrap(), Some(1)); let r = one.pull("origin").unwrap(); assert!( !r.changed && r.checked_out, "nothing new, but the working copy caught up" ); assert!(root.join("one/b.txt").exists()); assert_eq!(one.working_copy_behind().unwrap(), None); } #[test] fn touched_nodes_cache_answers_as_the_changes_do() { hermetic(); let dir = tempfile::tempdir().unwrap(); let repo = Repo::init(dir.path()).unwrap(); write(dir.path(), "a.txt", b"one\n"); write(dir.path(), "b.txt", b"x\n"); repo.snapshot("t").unwrap(); let c1 = repo.commit("first", &[]).unwrap(); write(dir.path(), "b.txt", b"y\n"); repo.snapshot("t").unwrap(); repo.commit("b only", &[]).unwrap(); fs::rename(dir.path().join("a.txt"), dir.path().join("c.txt")).unwrap(); write(dir.path(), "c.txt", b"one\ntwo\n"); repo.snapshot("t").unwrap(); let c3 = repo.commit("rename and edit", &[]).unwrap(); // Uncommitted work, so the working copy touches a file too. write(dir.path(), "c.txt", b"one\ntwo\nthree\n"); repo.snapshot("t").unwrap(); let path = dir.path().join(".oxy/index/touched"); let hashes = |es: Vec| es.iter().map(|e| e.hash).collect::>(); let cold = hashes(repo.log_for_path("c.txt").unwrap()); assert!(path.exists(), "the first query writes the cache"); let warm = hashes(repo.log_for_path("c.txt").unwrap()); assert_eq!(cold, warm, "the cache answers as the changes do"); let (wc, _) = repo.working_copy().unwrap(); assert_eq!( cold, vec![wc, c3, c1], "renames followed, the working copy included" ); // Working-copy changes are not kept: they are replaced by every snapshot. let cached = super::touched::read_touched_for_test(&fs::read(&path).unwrap()).unwrap(); assert!(!cached.contains(&wc), "a working-copy change is not cached"); assert!(cached.contains(&c1) && cached.contains(&c3)); // A damaged cache is ignored, not trusted. let mut bytes = fs::read(&path).unwrap(); let last = bytes.len() - 40; bytes[last] ^= 1; fs::write(&path, &bytes).unwrap(); assert_eq!(hashes(repo.log_for_path("c.txt").unwrap()), cold); let touches = repo.log_revset("touches(\"c.txt\") ~ @").unwrap(); assert_eq!(hashes(touches), vec![c3, c1]); } /// oxygen#88 (vscode 9ab2056393c8): two lines of history that each move a /// file back where it was leave two placements at one place, which the /// tree shows as one file. Removing it, and then its directory, retires /// both, so no copy is left under a directory that is gone. #[test] fn git_import_removes_every_copy_of_a_file_at_one_place() { hermetic(); if std::process::Command::new("git") .arg("--version") .output() .is_err() { eprintln!("git not installed; skipping"); return; } let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let g = root.join("src.git"); fs::create_dir_all(&g).unwrap(); let git = |args: &[&str]| { let out = std::process::Command::new("git") .arg("-C") .arg(&g) .args([ "-c", "user.name=Grace", "-c", "user.email=grace@example.org", "-c", "commit.gpgsign=false", ]) .args(args) .output() .unwrap(); assert!( out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr) ); }; git(&["init", "-q", "-b", "main"]); write(&g, "d/list.txt", b"one\ntwo\nthree\nfour\n"); write(&g, "keep.txt", b"k\n"); git(&["add", "."]); git(&["commit", "-qm", "base"]); fs::create_dir_all(g.join("d/sub")).unwrap(); git(&["mv", "d/list.txt", "d/sub/list.txt"]); git(&["commit", "-qm", "into sub"]); git(&["checkout", "-qb", "side"]); git(&["mv", "d/sub/list.txt", "d/list.txt"]); git(&["commit", "-qm", "back, on side"]); git(&["checkout", "-q", "main"]); git(&["mv", "d/sub/list.txt", "d/list.txt"]); git(&["commit", "-qm", "back, on main"]); git(&["merge", "-q", "--no-edit", "side"]); git(&["rm", "-rq", "d"]); git(&["commit", "-qm", "d goes"]); let repo = Repo::init(&root.join("o")).unwrap(); repo.import_git(&g, "main").unwrap(); let v = repo.verify_git_import(&g, "main").unwrap(); assert!(v.mismatches.is_empty(), "{:?}", v.mismatches); let st = repo .load_state_history(&repo.bookmark_heads().unwrap()) .unwrap(); let view = st.tree().view(); assert!(view.conflicts.is_empty(), "{:?}", view.conflicts); assert_eq!(view.entries.keys().collect::>(), vec!["keep.txt"]); } /// oxygen#94: a state index's file histories are read, and checked by /// their own hashes, only when a file is built. One found damaged is an /// error that says so, the index is removed, and the next read rebuilds it. #[test] fn a_damaged_file_history_in_the_state_index_is_rebuilt() { hermetic(); let dir = tempfile::tempdir().unwrap(); let repo = Repo::init(dir.path()).unwrap(); for i in 0..6 { write( dir.path(), &format!("f{i}.txt"), format!("one {i}\n").as_bytes(), ); } repo.snapshot("t").unwrap(); repo.commit("first", &[]).unwrap(); for i in 0..6 { write( dir.path(), &format!("f{i}.txt"), format!("one {i}\ntwo\n").as_bytes(), ); } repo.snapshot("t").unwrap(); repo.commit("second", &[]).unwrap(); let want: Vec> = (0..6) .map(|i| format!("one {i}\ntwo\n").into_bytes()) .collect(); // The index of the working copy's base, holding every file's history // (a few changes behind, an index is replayed onto, not rewritten). let (_, wc) = repo.working_copy().unwrap(); let base = repo.load_state_history(&wc.deps).unwrap(); repo.write_state_index(&wc.deps, &base); for (i, w) in want.iter().enumerate() { assert_eq!(&repo.file_at(&format!("f{i}.txt"), None).unwrap(), w); } let index = repo.state_indexes(); assert!(!index.is_empty(), "a state index was written"); // The histories are last in the file: damage the last of them. let mut bytes = fs::read(&index[0]).unwrap(); let last = bytes.len() - 1; bytes[last] ^= 0x40; fs::write(&index[0], &bytes).unwrap(); let reads: Vec<_> = (0..6) .map(|i| repo.file_at(&format!("f{i}.txt"), None)) .collect(); let damaged: Vec = reads .iter() .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) .collect(); assert_eq!(damaged.len(), 1, "{damaged:?}"); assert!( damaged[0].contains("state index was damaged") && damaged[0].contains("run the command again"), "{}", damaged[0] ); // Every file reads right again, from an index rebuilt from history. for (i, w) in want.iter().enumerate() { assert_eq!(&repo.file_at(&format!("f{i}.txt"), None).unwrap(), w); } } /// oxygen#95: a push longer than `push.batch-bytes` goes in parts, each /// objects alone and in dependency order, and the last moves the bookmark. /// The remote ends with every change and the same state, and says how each /// part went. #[test] fn a_long_push_goes_in_parts() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = root.join("origin"); Repo::init_bare_unbound(&origin).unwrap(); let main = root.join("main"); let mut repo = Repo::init(&main).unwrap(); repo.config .set("remote.origin.url", &origin.to_string_lossy()) .unwrap(); // One byte: every change is a part of its own. repo.config.set("push.batch-bytes", "1").unwrap(); repo.save_config().unwrap(); for i in 0..6 { write( &main, &format!("f{i}.txt"), format!("file {i}\n").as_bytes(), ); repo.commit(&format!("c{i}"), &[]).unwrap(); } let mut parts = Vec::new(); let results = repo .push_bookmark_with("origin", "main", false, &mut |m| parts.push(m.to_string())) .unwrap(); assert!( results.iter().any(|r| r.ok && r.name == "main"), "{results:?}" ); assert_eq!(parts.len(), 6, "{parts:?}"); assert_eq!(parts[0], "part 1 of 6: 1 changes"); let remote = Repo::open(&origin).unwrap(); let heads = repo.bookmark_heads().unwrap(); assert_eq!(remote.bookmark_heads().unwrap(), heads); assert_eq!( remote.log_state(&heads).unwrap().len(), repo.log_state(&heads).unwrap().len() ); // A push of objects alone reports how it went, as `objects`. let got = remote .receive_push(Bundle::default(), vec![], None, vec![]) .unwrap(); assert_eq!(got.len(), 1); assert!(got[0].ok && got[0].name == OBJECTS_RESULT, "{got:?}"); } /// oxygen#133: one change whose blobs pass `push.batch-bytes` sends them /// first, in parts of objects alone, so no request is larger than about /// that (a proxy in front of a hub refuses one past 100 MB); the change /// and the bookmark follow, and a clone has every file. #[test] fn a_heavy_change_sends_its_blobs_in_parts() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = root.join("origin"); Repo::init_bare_unbound(&origin).unwrap(); let main = root.join("main"); let mut repo = Repo::init(&main).unwrap(); repo.config .set("remote.origin.url", &origin.to_string_lossy()) .unwrap(); repo.config.set("push.batch-bytes", "50000").unwrap(); repo.save_config().unwrap(); let mut x: u64 = 0x9e3779b97f4a7c15; let mut noise = |n: usize| -> Vec { (0..n) .map(|_| { x ^= x << 13; x ^= x >> 7; x ^= x << 17; (x >> 24) as u8 }) .collect() }; let files: Vec> = (0..4).map(|_| noise(40_000)).collect(); for (i, f) in files.iter().enumerate() { write(&main, &format!("bin{i}.dat"), f); } repo.commit("four blobs", &[]).unwrap(); let mut parts = Vec::new(); let results = repo .push_bookmark_with("origin", "main", false, &mut |m| parts.push(m.to_string())) .unwrap(); assert!( results.iter().any(|r| r.ok && r.name == "main"), "{results:?}" ); assert!( parts.len() >= 3 && parts[0].starts_with("objects 1 of"), "{parts:?}" ); let back = Repo::clone(&origin.to_string_lossy(), &root.join("back")).unwrap(); for (i, f) in files.iter().enumerate() { assert_eq!(&fs::read(root.join(format!("back/bin{i}.dat"))).unwrap(), f); } drop(back); } /// oxygen#136: a bundle carries a replaced binary file's new content /// only. Its old content came with the change that added it, so removing /// or replacing a file sends no old blob again; the remote and a clone of /// it are whole. #[test] fn a_replacement_does_not_send_the_old_content_again() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin = root.join("origin"); Repo::init_bare_unbound(&origin).unwrap(); let main = root.join("main"); let mut repo = Repo::init(&main).unwrap(); repo.config .set("remote.origin.url", &origin.to_string_lossy()) .unwrap(); repo.save_config().unwrap(); let blobs = |b: &Bundle| { b.objects .iter() .filter(|(ty, _)| *ty == ObjectType::Blob) .count() }; write(&main, "bin.dat", b"\0\x01first binary"); write(&main, "gone.dat", b"\0\x02to be removed"); let added = repo.commit("add", &[]).unwrap(); assert!(repo.push("origin", false).unwrap()[0].ok); write(&main, "bin.dat", b"\0\x01second binary"); fs::remove_file(main.join("gone.dat")).unwrap(); let changed = repo.commit("replace and remove", &[]).unwrap(); assert_eq!(blobs(&repo.bundle_for(&[changed]).unwrap()), 1); assert_eq!(blobs(&repo.bundle_for(&[added, changed]).unwrap()), 3); assert!(repo.push("origin", false).unwrap()[0].ok); let origin = Repo::open(&origin).unwrap(); assert_eq!(origin.fsck().unwrap().problems, vec![]); let back = Repo::clone(&origin.root().to_string_lossy(), &root.join("back")).unwrap(); assert_eq!(back.fsck().unwrap().problems, vec![]); assert_eq!( fs::read(root.join("back/bin.dat")).unwrap(), b"\0\x01second binary" ); assert!(!root.join("back/gone.dat").exists()); } /// oxygen#97: with `git.claim`, an import authors the importing identity's /// own commits (Git author email = its email, any case) with its key, and /// leaves anyone else's with the zero key; the history verifies as before. #[test] fn git_import_claims_your_own_commits() { 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 = |who: &str, args: &[&str]| { let (name, email) = who.split_once(' ').unwrap(); let out = std::process::Command::new("git") .arg("-C") .arg(&g) .args([ "-c", &format!("user.name={name}"), "-c", &format!("user.email={email}"), "-c", "commit.gpgsign=false", ]) .args(args) .output() .unwrap(); assert!( out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr) ); }; git("Grace grace@example.org", &["init", "-q", "-b", "main"]); write(&g, "a.txt", b"a\n"); git("Grace grace@example.org", &["add", "."]); git("Grace Grace@Example.org", &["commit", "-qm", "mine"]); write(&g, "b.txt", b"b\n"); git("Ada ada@example.org", &["add", "."]); git("Ada ada@example.org", &["commit", "-qm", "someone else's"]); let mut repo = Repo::init(&root.join("o")).unwrap(); let (key, _) = repo .create_identity("Grace", "grace@example.org", IdentityScope::Repo) .unwrap(); repo.config.set("git.claim", "true").unwrap(); repo.save_config().unwrap(); let repo = Repo::open(&root.join("o")).unwrap(); repo.import_git(&g, "main").unwrap(); let v = repo.verify_git_import(&g, "main").unwrap(); assert!(v.mismatches.is_empty(), "{:?}", v.mismatches); let heads = repo.bookmark_heads().unwrap(); let by_message: std::collections::HashMap = repo .log_state(&heads) .unwrap() .into_iter() .map(|e| { ( e.change.message.lines().next().unwrap_or("").to_string(), e.change.author, ) }) .collect(); assert_eq!(by_message["mine"], key, "her own commit, any case of email"); assert_eq!(by_message["someone else's"], ObjectRef::ZERO, "not hers"); } /// oxygen#86: removing a remote removes all of it: every `remote.NAME.*` /// key (URL, token, CA), its tracking bookmarks, and nothing else. #[test] fn removing_a_remote_leaves_nothing_of_it() { hermetic(); let dir = tempfile::tempdir().unwrap(); let origin = dir.path().join("origin"); Repo::init_bare_unbound(&origin).unwrap(); let main = dir.path().join("main"); let mut repo = Repo::init(&main).unwrap(); repo.config .set("remote.origin.url", &origin.to_string_lossy()) .unwrap(); repo.config .set("remote.origin.token", "secret-ish") .unwrap(); repo.config .set("remote.keep.url", &origin.to_string_lossy()) .unwrap(); repo.save_config().unwrap(); write(&main, "a.txt", b"a\n"); repo.commit("a", &[]).unwrap(); assert!(repo.push("origin", false).unwrap()[0].ok); assert!( repo.view().unwrap().remotes.contains_key("origin"), "a tracking bookmark" ); repo.remove_remote("origin").unwrap(); let again = Repo::open(&main).unwrap(); assert!( !again .config .repo_layer() .keys() .any(|k| k.starts_with("remote.origin.")), "{:?}", again.config.repo_layer() ); assert!(!again.config.remotes.contains_key("origin")); assert!( !again.view().unwrap().remotes.contains_key("origin"), "its tracking bookmarks go" ); assert!( again.config.remotes.contains_key("keep"), "another remote stays" ); let mut again = again; assert!( again.remove_remote("origin").is_err(), "a second removal is refused" ); } /// oxygenhub#126: a blob a host stored for itself (a job's artifact) is /// unreachable from history, so gc prunes it, unless the host names it: /// then it and its chunks stay, and it reads back whole. #[test] fn gc_keeps_the_blobs_a_host_names() { hermetic(); let dir = tempfile::tempdir().unwrap(); let repo = Repo::init(dir.path()).unwrap(); write(dir.path(), "a.txt", b"a\n"); repo.commit("a", &[]).unwrap(); // Large enough to be several chunks. let body: Vec = (0..400_000u32) .flat_map(|i| (i.wrapping_mul(2654435761)).to_le_bytes()) .collect(); let kept = repo.store_blob(&body).unwrap(); let lost = repo.store_blob(b"an artifact nobody names").unwrap(); repo.gc_keeping(true, &[kept], &mut |_| {}).unwrap(); assert_eq!(repo.read_blob(&kept).unwrap(), body, "named: kept whole"); assert!(!repo.store().has(&lost), "not named: pruned"); repo.gc(true).unwrap(); assert!(!repo.store().has(&kept), "a plain gc prunes it"); } /// oxygen#99: undo in one workspace undoes that workspace's last command, /// never another workspace's, says what it undid, and goes further back /// when run again. #[test] fn undo_is_scoped_to_its_workspace() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("main"); let a = Repo::init(&root).unwrap(); write(&root, "base.txt", b"base\n"); let base = a.commit("base", &[]).unwrap(); a.bookmark_create("feature", None).unwrap(); let ws_dir = dir.path().join("ws"); let heads = a.resolve_heads("feature").unwrap(); let b = a .workspace_add_on(&ws_dir, Some(&heads), Some("feature")) .unwrap(); // A commits on main, then B commits on feature: B's is the newest. write(&root, "a.txt", b"a\n"); let ca = a.commit("from a", &[]).unwrap(); write(&ws_dir, "b.txt", b"b\n"); let cb = b.commit("from b", &[]).unwrap(); // A's undo undoes A's commit and leaves B's. let u = a.undo().unwrap(); assert_eq!(u.command, "oxy commit -m \"from a\""); assert_eq!(u.others_kept, 1, "B's newer commit is left"); assert!(!u.changes.is_empty()); let view = a.view().unwrap(); // (Independent changes commute, so a bookmark's heads may hold base too.) assert_eq!(a.heads_of(&view, "main").unwrap(), vec![base]); assert!( a.heads_of(&view, "feature").unwrap().contains(&cb), "B's commit stands" ); assert!( a.store().get_change(&ca).is_ok(), "the change itself is still in the store" ); // A's working copy was clean, so its files followed; B's are untouched. assert!(!root.join("a.txt").exists()); assert!(a.snapshot("oxy status").unwrap().statuses.is_empty()); assert!(ws_dir.join("b.txt").exists()); // B's undo still finds B's own commit, under A's undo. let u = b.undo().unwrap(); assert_eq!(u.command, "oxy commit -m \"from b\""); assert!(!b .heads_of(&b.view().unwrap(), "feature") .unwrap() .contains(&cb)); assert_eq!( a.heads_of(&a.view().unwrap(), "main").unwrap(), vec![base], "A's undo stands" ); // A's next undo goes past the commit it already undid, to adding B's // workspace: refused, since B has worked there since. let e = a.undo().unwrap_err().to_string(); assert!(e.contains("the workspace at") && e.contains("ws"), "{e}"); } /// oxygen#99: two workspaces on one bookmark: an undo takes back only its /// own commit's head, and the other workspace's head stays. #[test] fn undo_on_a_shared_bookmark_keeps_the_other_workspaces_head() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("main"); let a = Repo::init(&root).unwrap(); write(&root, "base.txt", b"base\n"); a.commit("base", &[]).unwrap(); let ws_dir = dir.path().join("ws"); let b = a.workspace_add(&ws_dir, None).unwrap(); write(&root, "a.txt", b"a\n"); let ca = a.commit("from a", &[]).unwrap(); write(&ws_dir, "b.txt", b"b\n"); let cb = b.commit("from b", &[]).unwrap(); let u = a.undo().unwrap(); assert_eq!(u.command, "oxy commit -m \"from a\""); let heads = a.heads_of(&a.view().unwrap(), "main").unwrap(); assert!(heads.contains(&cb) && !heads.contains(&ca), "{heads:?}"); } /// oxygen#99: an undo that would also take back what another workspace /// did since to the same bookmark is refused, naming it. #[test] fn undo_refuses_to_take_back_another_workspaces_work() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("main"); let a = Repo::init(&root).unwrap(); write(&root, "base.txt", b"base\n"); a.commit("base", &[]).unwrap(); let ws_dir = dir.path().join("ws"); let b = a.workspace_add(&ws_dir, None).unwrap(); // Both on main: A commits, then B builds on it, editing A's file. write(&root, "a.txt", b"a\n"); a.commit("from a", &[]).unwrap(); b.refresh().unwrap(); let heads = b.resolve_heads("main").unwrap(); b.checkout(&heads, false, "oxy checkout").unwrap(); write(&ws_dir, "a.txt", b"a, then b\n"); b.commit("from b", &[]).unwrap(); let e = a.undo().unwrap_err().to_string(); assert!( e.contains("bookmark main") && e.contains("builds on"), "{e}" ); assert!(e.contains("oxy op restore"), "{e}"); } /// oxygen#99: undoing a commit that was already pushed says so: the remote /// still has it. #[test] fn undo_says_when_the_commit_was_pushed() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); Repo::init(&root.join("origin")).unwrap(); let url = root.join("origin").to_string_lossy().to_string(); let repo = Repo::clone(&url, &root.join("work")) .unwrap() .with_home(root.join("home")); write(&root.join("work"), "a.txt", b"a\n"); repo.commit("a", &[]).unwrap(); assert!(repo.push("origin", false).unwrap()[0].ok); // The push itself is passed over: the remote keeps what it was sent. let u = repo.undo().unwrap(); assert_eq!(u.command, "oxy commit -m \"a\""); assert_eq!(u.pushed, vec![("main".to_string(), "origin".to_string())]); } /// oxygen#98: a new untracked file over snapshot.max-new-file-size is left /// out of the snapshot and named; tracked files and files already in the /// working copy are not. #[test] fn large_new_files_are_left_out_of_snapshots() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap(); repo.config.max_new_file = 1024; write(root, "small.txt", b"small\n"); write(root, "big.bin", &vec![0u8; 4096]); let s = repo.snapshot("oxy status").unwrap(); let paths: Vec<_> = s.statuses.iter().map(|p| p.path.as_str()).collect(); assert_eq!(paths, ["small.txt"]); assert_eq!(repo.skipped_large(), vec![("big.bin".to_string(), 4096)]); repo.commit("small", &[]).unwrap(); // A tracked file may grow past the limit. write(root, "small.txt", &vec![b'x'; 4096]); let s = repo.snapshot("oxy status").unwrap(); assert!(s.statuses.iter().any(|p| p.path == "small.txt")); // With no limit the big file is taken in, and stays once it is. repo.config.max_new_file = 0; let s = repo.snapshot("oxy status").unwrap(); assert!(s.statuses.iter().any(|p| p.path == "big.bin")); repo.config.max_new_file = 1024; let s = repo.snapshot("oxy status").unwrap(); assert!( s.statuses.iter().any(|p| p.path == "big.bin"), "already in the working copy" ); assert!(repo.skipped_large().is_empty()); } /// oxygen#98: a file only a past working-copy snapshot held is pruned once /// snapshots expire, and kept while they have not. #[test] fn gc_prunes_what_only_expired_snapshots_held() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap(); repo.config.gc_grace_days = 0; write(root, "a.txt", b"a\n"); repo.commit("a", &[]).unwrap(); let image: Vec = (0..200_000u32).map(|i| (i % 7) as u8).collect(); write(root, "image.tar", &image); repo.snapshot("oxy status").unwrap(); std::fs::remove_file(root.join("image.tar")).unwrap(); repo.snapshot("oxy status").unwrap(); let blob = Repo::blob_ref(&image); assert!(repo.store().has(&blob)); repo.gc(true).unwrap(); assert!(repo.store().has(&blob), "a recent snapshot keeps it"); repo.config.snapshot_retention_days = 0; repo.gc(true).unwrap(); assert!(!repo.store().has(&blob), "an expired snapshot does not"); assert_eq!( repo.file_at("a.txt", None).unwrap(), b"a\n", "committed files stay" ); } /// oxygen#100: uncommitted work byte-for-byte what the bookmark switched to /// already has is no conflict: the switch goes through, nothing is lost, /// and other carried work still comes along. #[test] fn carrying_work_the_target_already_has_is_no_conflict() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let mut repo = Repo::init(root).unwrap(); write(root, "a.txt", b"one\n"); write(root, "b.txt", b"b\n"); repo.commit("one", &[]).unwrap(); repo.bookmark_create("landed", None).unwrap(); // On landed, a.txt becomes "two" (as a proposal landing would). repo.bookmark_switch("landed", false).unwrap(); write(root, "a.txt", b"two\n"); repo.commit("two", &[]).unwrap(); repo.bookmark_switch("main", false).unwrap(); assert_eq!(std::fs::read(root.join("a.txt")).unwrap(), b"one\n"); // The same edit, uncommitted, on main; and another edit besides. write(root, "a.txt", b"two\n"); write(root, "b.txt", b"b, edited\n"); let (_, carried) = repo.bookmark_switch_carry("landed").unwrap(); assert_eq!( carried, vec!["b.txt".to_string()], "only the work landed lacks is carried" ); assert_eq!(std::fs::read(root.join("a.txt")).unwrap(), b"two\n"); assert_eq!(std::fs::read(root.join("b.txt")).unwrap(), b"b, edited\n"); let s = repo.snapshot("oxy status").unwrap(); let paths: Vec<_> = s.statuses.iter().map(|p| p.path.as_str()).collect(); assert_eq!(paths, ["b.txt"]); // A real difference is still refused. repo.bookmark_switch("main", true).unwrap(); write(root, "a.txt", b"three\n"); let e = repo .bookmark_switch_carry("landed") .unwrap_err() .to_string(); assert!(e.contains("a.txt") && e.contains("--discard"), "{e}"); } /// oxygen#103: a full change hash as `--json` prints it, without `#`, /// resolves, so JSON output feeds back into commands. #[test] fn a_full_hash_resolves_without_its_hash_sign() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); write(root, "a.txt", b"a\n"); let h = repo.commit("a", &[]).unwrap(); assert_eq!(repo.resolve_change(&h.to_hex()).unwrap(), h); assert_eq!(repo.resolve_change(&format!("#{}", h.to_hex())).unwrap(), h); assert!(repo .resolve_change(&"0".repeat(64)) .unwrap_err() .to_string() .contains("no change matches")); } #[test] fn a_pin_change_moves_only_the_pin() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let _lib_origin = Repo::init(&root.join("lib-origin")).unwrap(); let lib_url = root.join("lib-origin").to_string_lossy().to_string(); let lib = Repo::clone(&lib_url, &root.join("lib")) .unwrap() .with_home(root.join("home")); write(&root.join("lib"), "lib.txt", b"v1\n"); lib.commit("lib v1", &[]).unwrap(); assert!(lib.push("origin", false).unwrap()[0].ok); let _app_origin = Repo::init(&root.join("app-origin")).unwrap(); let app_dir = root.join("app"); let app = Repo::clone(&root.join("app-origin").to_string_lossy(), &app_dir) .unwrap() .with_home(root.join("home")); write(&app_dir, "main.txt", b"app\n"); let pin1 = app.submodule_add(&lib_url, "libs/lib", None).unwrap(); let a1 = app.commit("app with lib", &[]).unwrap(); // The library moves on. write(&root.join("lib"), "more.txt", b"v2\n"); let l2 = lib.commit("lib v2", &[]).unwrap(); let crate::subrepo::Pin::Oxy { repo, bookmark, .. } = pin1 else { panic!("an Oxygen pin") }; let pin2 = crate::subrepo::Pin::Oxy { repo, heads: vec![l2], bookmark, }; let author = app.store().get_change(&a1).unwrap().author; let change = app .build_pin_change(&[a1], "libs/lib", &pin2, "Move lib to v2", author) .unwrap(); let mut state = app.load_state(&[a1]).unwrap(); state.add(change).unwrap(); let files = state.materialize().files; assert_eq!(files["libs/lib"].kind, NodeKind::Subrepo); let FileContent::Opaque { blobs } = &files["libs/lib"].content else { panic!("a pin is opaque") }; let read = crate::subrepo::Pin::decode(&app.read_blob(&blobs[0]).unwrap()).unwrap(); assert_eq!(read, pin2, "the new pin"); assert!( matches!(&files["main.txt"].content, FileContent::Text { bytes, .. } if bytes.as_slice() == b"app\n"), "nothing else changes" ); // Only a subrepo's pin can be moved this way. let e = app.build_pin_change(&[a1], "main.txt", &pin2, "x", author); assert!(format!("{:?}", e.unwrap_err()).contains("not a subrepo")); let e = app.build_pin_change(&[a1], "libs/none", &pin2, "x", author); assert!(format!("{:?}", e.unwrap_err()).contains("does not exist")); } /// oxygen#147: a subrepo at its pinned state is unmodified whatever /// bookmark it sits on, so the parent's status is clean and a bookmark /// switch goes ahead; a new state in it is still a change. #[test] fn a_subrepo_at_its_pinned_state_on_another_bookmark_is_unmodified() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let _lib_origin = Repo::init(&root.join("lib-origin")).unwrap(); let lib_url = root.join("lib-origin").to_string_lossy().to_string(); let lib = Repo::clone(&lib_url, &root.join("lib")) .unwrap() .with_home(root.join("home")); write(&root.join("lib"), "lib.txt", b"v1\n"); lib.commit("lib v1", &[]).unwrap(); assert!(lib.push("origin", false).unwrap()[0].ok); let _app_origin = Repo::init(&root.join("app-origin")).unwrap(); let app_dir = root.join("app"); let mut app = Repo::clone(&root.join("app-origin").to_string_lossy(), &app_dir) .unwrap() .with_home(root.join("home")); write(&app_dir, "main.txt", b"app\n"); app.submodule_add(&lib_url, "libs/lib", None).unwrap(); let a1 = app.commit("app with lib", &[]).unwrap(); // The nested repository moves to another bookmark at the same heads. let mut nested = Repo::open(&app_dir.join("libs/lib")) .unwrap() .with_home(root.join("home")); let heads = nested.bookmark_heads().unwrap(); nested.bookmark_create("feature", Some(&heads)).unwrap(); nested.bookmark_switch("feature", false).unwrap(); let snap = app.snapshot("status").unwrap(); assert!( !snap.changed, "only the bookmark differs: {:?}", snap.statuses ); app.bookmark_create("other", Some(&[a1])).unwrap(); app.bookmark_switch("other", false).unwrap(); // A new state in it is a change, as before. let nested = Repo::open(&app_dir.join("libs/lib")) .unwrap() .with_home(root.join("home")); write(&app_dir.join("libs/lib"), "lib.txt", b"v2\n"); nested.commit("lib v2", &[]).unwrap(); let snap = app.snapshot("status").unwrap(); assert_eq!( snap.statuses .iter() .map(|s| (s.path.as_str(), s.status)) .collect::>(), vec![("libs/lib", ChangeStatus::Modified)] ); } /// oxygenhub#249: a handle reads the operation head once and keeps it, /// so one command or request never mixes two heads; another handle's /// operation shows only after `refresh`. Its own operations move it, and /// one recorded from the head it kept merges with what landed since, as /// any concurrent operation does. #[test] fn one_handle_keeps_one_head_until_it_refreshes() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let writer = Repo::init(root).unwrap(); write(root, "a.txt", b"one\n"); let h1 = writer.commit("one", &[]).unwrap(); let reader = Repo::open(root).unwrap(); let before = reader.view().unwrap(); assert_eq!(reader.bookmark_heads().unwrap(), vec![h1]); write(root, "a.txt", b"two\n"); let h2 = writer.commit("two", &[]).unwrap(); assert_eq!( writer.bookmark_heads().unwrap(), vec![h2], "its own operation" ); assert_eq!(reader.view().unwrap(), before, "the head it read first"); assert_eq!(reader.bookmark_heads().unwrap(), vec![h1]); reader.refresh().unwrap(); assert_eq!(reader.bookmark_heads().unwrap(), vec![h2]); // A handle recording from an older head merges with the newer one. let stale = Repo::open(root).unwrap(); assert_eq!(stale.bookmark_heads().unwrap(), vec![h2]); write(root, "a.txt", b"three\n"); let h3 = writer.commit("three", &[]).unwrap(); stale.bookmark_create("feature", Some(&[h1])).unwrap(); let view = stale.view().unwrap(); assert!(view.bookmarks.contains_key("feature")); assert_eq!( stale.bookmark_heads().unwrap(), vec![h3], "merged, not lost" ); let fresh = Repo::open(root).unwrap(); assert_eq!(fresh.view().unwrap(), view); } /// oxygenhub#249 over a store (a bucket's compare-and-swap): the same. #[test] fn over_a_store_one_handle_keeps_one_head() { hermetic(); let backend = std::sync::Arc::new(crate::strata::MemoryStore::new()); crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = Some(backend.clone())); let dir = tempfile::tempdir().unwrap(); let a = Repo::init(dir.path()).unwrap(); let (h0, _) = a.head_op().unwrap(); let b = Repo::open(dir.path()).unwrap(); assert_eq!(b.head_op().unwrap().0, h0); let state = a.store.put_state(&StateObject::new(vec![])).unwrap(); let mut view = a.view().unwrap(); view.bookmarks.insert("x".into(), state); let r = a.record_op(&[h0], "bookmark x", view).unwrap(); assert_eq!(a.head_op().unwrap().0, r); assert_eq!(b.head_op().unwrap().0, h0, "b keeps its head"); b.refresh().unwrap(); assert_eq!(b.head_op().unwrap().0, r); crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = None); } /// oxygenhub#249: gc retires what it repacks rather than deleting it; a /// reader opened before reads on, then and after the next gc deletes the /// retired files once their hour is up. Younger ones are kept. #[test] fn gc_retires_and_a_later_gc_deletes_after_the_hour() { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let repo = Repo::init(root).unwrap(); let mut heads = Vec::new(); for i in 0..4 { write(root, &format!("f{i}.txt"), format!("{i}\n").as_bytes()); heads.push(repo.commit(&format!("c{i}"), &[]).unwrap()); } let reader = Repo::open(root).unwrap(); assert_eq!(reader.log().unwrap().len(), 5); let r = repo.gc(true).unwrap(); assert_eq!(r.retired, crate::store::RetiredReport::default()); assert_eq!(repo.store().loose_count().unwrap(), 0); assert_eq!(reader.log().unwrap().len(), 5, "read on from what it had"); // Nothing is an hour old yet: a gc keeps it all. let r = repo.gc(true).unwrap(); assert_eq!((r.retired.packs, r.retired.loose), (0, 0)); assert!(r.retired.kept > 0, "{r:?}"); write(root, "f9.txt", b"9\n"); heads.push(repo.commit("c9", &[]).unwrap()); crate::store::reader_tests::age_retired( repo.store(), crate::store::RETIRED_FOR + std::time::Duration::from_secs(60), ); let r = repo.gc(true).unwrap(); assert!(r.retired.packs >= 1 && r.retired.loose > 0, "{r:?}"); assert_eq!(r.retired.kept, 0, "{r:?}"); let r = repo.gc(true).unwrap(); assert_eq!((r.retired.packs, r.retired.loose), (0, 0)); assert!(r.retired.kept > 0, "what the last gc retired is young"); // The reader opened before all of it still reads everything, from // the packs it finds when what it loaded is gone. assert_eq!(reader.log().unwrap().len(), 5); for h in &heads[..4] { reader.store().get_change(h).unwrap(); } reader.refresh().unwrap(); assert_eq!(reader.log().unwrap().len(), 6); let fresh = Repo::open(root).unwrap(); assert_eq!( fresh.store().pack_count(), 1, "retired packs are not loaded" ); } /// oxygenhub#249: a repository opens when its store's pack listing names /// an index that is not there (deleted between the listing and the read). #[test] fn a_repository_opens_when_a_listed_index_is_gone() { hermetic(); let backend = crate::store::reader_tests::racing(true); let dyn_backend: std::sync::Arc = backend.clone(); crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = Some(dyn_backend)); let dir = tempfile::tempdir().unwrap(); let repo = Repo::init(dir.path()).unwrap(); write(dir.path(), "a.txt", b"a\n"); let h = repo.commit("a", &[]).unwrap(); repo.gc(true).unwrap(); let again = Repo::open(dir.path()).unwrap(); assert_eq!(again.store().pack_count(), 1); assert_eq!(again.bookmark_heads().unwrap(), vec![h]); // And when one is deleted between the listing and the read: a copy of // the live pack, whose index goes as the store opens. use crate::strata::Store as _; let idx = backend .list("pack-") .unwrap() .into_iter() .map(|e| e.key) .find(|k| k.ends_with(".idx") && k != "pack-0000.idx") .unwrap(); let stem = idx.trim_end_matches(".idx"); for ext in ["pack", "idx"] { let bytes = backend.get(&format!("{stem}.{ext}")).unwrap(); backend .put_new(&format!("pack-copy.{ext}"), &bytes) .unwrap(); } *backend.doomed.lock().unwrap() = Some("pack-copy.idx".into()); let again = Repo::open(dir.path()).unwrap(); assert_eq!(again.store().pack_count(), 1); assert_eq!(again.bookmark_heads().unwrap(), vec![h]); assert!(!backend.has("pack-copy.idx").unwrap()); crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = None); } /// oxygen#82: a home with `author.for` authors as the identity its hub /// last named (kept in `keys/author.for`), not its stale `author.key`; a /// repository's own `author.key` pin still wins, and without `author.for` /// the note is ignored. #[test] fn author_for_follows_the_key_the_hub_named() { let dir = tempfile::tempdir().unwrap(); let home = dir.path().join("home"); fs::create_dir_all(home.join("keys")).unwrap(); let user = home.join("config.toml"); let repo_cfg = dir.path().join("config"); let (old, new, pin) = ( KeyId::from_hex(&"a".repeat(64)).unwrap(), KeyId::from_hex(&"b".repeat(64)).unwrap(), KeyId::from_hex(&"c".repeat(64)).unwrap(), ); fs::write(&user, format!("author.key = \"{}\"\n", old.to_hex())).unwrap(); fs::write( home.join("keys").join(super::config::AUTHOR_FOR_FILE), format!("{}\n", new.to_hex()), ) .unwrap(); fs::write(&repo_cfg, "").unwrap(); assert_eq!( Config::load(Some(&user), &repo_cfg).unwrap().author_key, Some(old), "no author.for: the note is ignored" ); fs::write( &user, format!( "author.key = \"{}\"\nauthor.for = \"andymccall\"\n", old.to_hex() ), ) .unwrap(); assert_eq!( Config::load(Some(&user), &repo_cfg).unwrap().author_key, Some(new) ); fs::write(&repo_cfg, format!("author.key = \"{}\"\n", pin.to_hex())).unwrap(); assert_eq!( Config::load(Some(&user), &repo_cfg).unwrap().author_key, Some(pin), "a repository's pin wins" ); } #[test] fn git_export_graph_is_deterministic_and_merges_heads() { 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"); let h1 = repo.commit("first", &[]).unwrap(); write(&r, "README.md", b"hello\nworld\n"); write(&r, "src/main.rs", b"fn main() {}\n"); let h2 = repo.commit("second", &[]).unwrap(); repo.tag_create("v1", &[h2], "the first release").unwrap(); // An independent change: main gets two heads. repo.checkout(&[h1], false, "checkout").unwrap(); write(&r, "other.txt", b"o\n"); let h3 = repo.commit("third", &[]).unwrap(); repo.bookmark_create("gone", Some(&[h1])).unwrap(); let git = |git_dir: &Path, args: &[&str]| -> String { let out = std::process::Command::new("git") .arg("-C") .arg(git_dir) .args(args) .output() .unwrap(); assert!( out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_string() }; let a = root.join("a.git"); let rep = repo.git_export_graph(&a).unwrap(); assert_eq!((rep.commits, rep.tags), (3, 1), "{rep:?}"); assert_eq!(rep.branches, 2, "main and gone: {rep:?}"); // Each change is a commit whose parents are exactly its dependencies' // commits (Oxygen's dependencies, not the order changes were made in). let by_hash = |hash: ChangeHash| { git( &a, &[ "log", "--all", "--format=%H", &format!("--grep=Oxy-Hash: {}", hash.to_hex()), ], ) }; let (c1, c2, c3) = (by_hash(h1), by_hash(h2), by_hash(h3)); for (h, c) in [(h1, &c1), (h2, &c2), (h3, &c3)] { let mut want: Vec = repo .store() .get_change_meta(&h) .unwrap() .deps .iter() .map(|d| by_hash(*d)) .collect(); want.sort(); let mut have: Vec = git(&a, &["rev-list", "--parents", "-1", c]) .split(' ') .skip(1) .map(String::from) .collect(); have.sort(); assert_eq!(have, want, "{h:?}'s commit's parents"); } assert!( !repo.store().get_change_meta(&h2).unwrap().deps.is_empty(), "second edits first's README" ); assert_eq!( git(&a, &["show", &format!("{c2}:src/main.rs")]), "fn main() {}" ); assert!( git(&a, &["ls-tree", "--name-only", &c3]) .lines() .all(|l| l != "src"), "a change's tree is its own closure, not the bookmark's" ); assert_eq!( git(&a, &["log", "-1", "--format=%an <%ae>", &c1]), "Ada Lovelace " ); // main has two heads: a merge of both, whose tree is the union. let main = git(&a, &["rev-parse", "main"]); let mut parents: Vec = git(&a, &["rev-list", "--parents", "-1", "main"]) .split(' ') .skip(1) .map(String::from) .collect(); parents.sort(); let mut want = vec![c2.clone(), c3.clone()]; want.sort(); assert_eq!(parents, want, "the merge's parents are the heads"); assert_eq!( git(&a, &["ls-tree", "--name-only", "main"]), "README.md\nother.txt\nsrc" ); assert!(git(&a, &["log", "-1", "--format=%B", "main"]).contains("Merge of 2 heads")); assert_eq!( git(&a, &["rev-parse", "v1^{commit}"]), c2, "the tag is its head's commit" ); assert_eq!(git(&a, &["symbolic-ref", "HEAD"]), "refs/heads/main"); // Again: nothing new. A bookmark deleted takes its branch with it. repo.bookmark_delete("gone").unwrap(); let rep = repo.git_export_graph(&a).unwrap(); assert_eq!(rep.commits, 0, "{rep:?}"); assert!( git(&a, &["for-each-ref", "--format=%(refname)", "refs/heads"]) .lines() .all(|r| r != "refs/heads/gone") ); assert_eq!( git(&a, &["rev-parse", "main"]), main, "the merge is made once" ); // The same changes elsewhere give the same commits. let other = Repo::clone(r.to_str().unwrap(), &root.join("clone")) .unwrap() .with_home(root.join("home")); let b = root.join("b.git"); other.git_export_graph(&b).unwrap(); assert_eq!( git(&b, &["rev-parse", "main"]), main, "deterministic across repositories" ); assert_eq!(git(&b, &["rev-parse", "v1^{commit}"]), c2); } #[test] fn a_fetch_of_many_bookmarks_takes_shared_changes_once() { // oxygen#158: every bookmark's heads are fetched in one exchange, so // changes bookmarks share are not sent again for each; each bookmark // still says how many of its changes were new, and a fetch with // nothing new imports nothing. hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); drop(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 w = root.join("a"); write(&w, "f.txt", b"one\n"); a.commit("one", &[]).unwrap(); write(&w, "f.txt", b"one\ntwo\n"); a.commit("two", &[]).unwrap(); a.push("origin", false).unwrap(); // Five bookmarks on main's history, each one change of its own. for i in 0..5 { a.bookmark_create(&format!("feat{i}"), None).unwrap(); a.bookmark_switch(&format!("feat{i}"), false).unwrap(); write(&w, &format!("feat{i}.txt"), format!("{i}\n").as_bytes()); a.commit(&format!("feat {i}"), &[]).unwrap(); a.push("origin", false).unwrap(); a.bookmark_switch("main", false).unwrap(); } let b = Repo::clone(&url, &root.join("b")) .unwrap() .with_home(root.join("home-b")); let report = b.fetch("origin").unwrap(); assert_eq!(report.refs.len(), 6, "every bookmark: {:?}", report.refs); assert!( report.refs.iter().all(|(_, _, n)| *n == 0), "the clone brought them all: {:?}", report.refs ); // One new change on one bookmark: counted there, nowhere else. a.bookmark_switch("feat3", false).unwrap(); write(&w, "feat3.txt", b"3\nmore\n"); a.commit("feat 3 again", &[]).unwrap(); a.push("origin", false).unwrap(); let fetches = || crate::protocol::LOCAL_FETCHES.with(|n| n.get()); let at = fetches(); let report = b.fetch("origin").unwrap(); assert_eq!(fetches() - at, 1, "one bundle for all six bookmarks"); let counts: Vec<(String, usize)> = report .refs .iter() .map(|(n, _, c)| (n.clone(), *c)) .collect(); assert!(counts.contains(&("feat3".to_string(), 1)), "{counts:?}"); assert_eq!( counts.iter().filter(|(_, c)| *c > 0).count(), 1, "only feat3 had anything new: {counts:?}" ); // Nothing new: no bundle at all, every bookmark still listed. let at = fetches(); let report = b.fetch("origin").unwrap(); assert_eq!(fetches() - at, 0, "nothing to fetch, no round trip"); assert_eq!(report.refs.len(), 6); assert!( report.refs.iter().all(|(_, _, n)| *n == 0), "{:?}", report.refs ); } #[test] fn a_stale_pinned_key_cannot_fork_the_identity() { // oxygen#163: a checkout pinned author.key to a key that a rotation made // elsewhere had already succeeded; delegating there built a second // successor of it, splitting the identity in two. hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let home = root.join("home"); let mut a = Repo::init(&root.join("a")).unwrap().with_home(&home); let (first, _) = a .create_identity("Ada", "ada@example.org", IdentityScope::User) .unwrap(); let r = a.rotate_identity().unwrap(); assert_eq!((r.old, a.config.author_key), (first, Some(r.new))); assert_eq!(a.successors(&first).unwrap(), vec![r.new]); assert_eq!(a.newest(&first).unwrap(), r.new); // Another checkout still pins the first key. let mut b = Repo::init(&root.join("b")).unwrap().with_home(&home); b.config.set("author.key", &first.to_hex()).unwrap(); b.save_config().unwrap(); let mut b = Repo::open(&root.join("b")).unwrap().with_home(&home); assert_eq!(b.config.author_key, Some(first)); for e in [ b.rotate_identity().unwrap_err().to_string(), b.renew_identity(None).unwrap_err().to_string(), ] { assert!(e.contains("fork") && e.contains(&r.new.short(12)), "{e}"); assert!( e.contains(&format!("oxy config author.key {}", r.new.to_hex())), "{e}" ); } assert_eq!( b.successors(&first).unwrap(), vec![r.new], "nothing was made" ); // Pointed at the newest key, it rotates as usual. b.config.set("author.key", &r.new.to_hex()).unwrap(); b.save_config().unwrap(); let mut b = Repo::open(&root.join("b")).unwrap().with_home(&home); let r2 = b.rotate_identity().unwrap(); assert_eq!(r2.old, r.new); assert_eq!(b.newest(&first).unwrap(), r2.new); }