Git import verified commit by commit; two dependency bugs fixed 62794ecc7f74
oxy git verify GIT_DIR rebuilds, for every commit of an imported branch, the state of the changes imported for it and its ancestors and compares it with the commit's tree: bytes, executable bits, symlinks and submodule pins, naming the changes behind any conflict and the first differing line of any file. Run on a scratch mirror of a real repository (539 commits, 208 merges, 880 submodule entries) it found bugs that also hit ordinary commits: - Inserting a line after b and then deleting b, one commit after the other, left a delete/insert conflict: the deletion depended only on b's introducer, so it read as concurrent with the insertion. A deletion now also depends on whoever inserted the live lines right after the deleted ones. - A binary file or submodule reverted to an earlier blob lost it, and replacements depended on nothing. Replacements now depend on the change that set their old blob, and replace records are superseded causally; records from before keep the old rule. - A merge of independently replaced binaries or submodules stayed in conflict; the importer now resolves it to the merge's blob. From 50+ mismatching commits to 5, all a line placed differently in a non-merge commit, left as backlog O11. Spec Draft 0.8i (§8.1, §9.3, §23).
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 09:35 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
62794ecc7f74b0fe4cfe630382dfd79d720aa35050e9a873eeb8a6d072fd69bb- Depends on
- 28fc23d7706a 30ea9c1e70fa 387335ff7f4d 3e5772ccf9da 4dee277c327a 50a046e439e9 75167672ebcd 79ab8528ff62 83e0677b07fe 982864068a59 ba160f60219e c3069537d7c5 d82e44097323 fce44ae9ad29 ff0af1ee299d
- Depended on by
- c397a8c36a9f 6f4226bfb87f 7fc365369758 b689779e9e74 d6e0d5b8b339 8b7986dd3cb0 f0406c3b40ed ee6aa3e81bc8 54e2e6309aa3 7eac74abc586 e83354cdac22 2306a4d24779 57d8dc05c9d4 a87c7b9d563a dfb23a3a8cfa 958fb27dda7c 83d61e803ccd f7510b63cceb afaba5f1306d c863c7a7a2d1
- In bookmarks
- agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 24 atoms, 0 tree operations, 9 files
Browse the tree at this change
modified crates/oxy-core/src/graph.rs +17 −0
| … | 166 unchanged lines |
||
| 167 | 167 | self.atoms.len() |
|
| 168 | 168 | } |
|
| 169 | 169 | ||
| 170 | + | /// The changes that inserted a live atom directly after `k`. A change |
|
| 171 | + | /// that deletes `k` must depend on them (spec §8.1), or `is_ghost` |
|
| 172 | + | /// reads the deletion as concurrent with those insertions. |
|
| 173 | + | pub fn live_successor_changes(&self, k: &AtomKey) -> Vec<ChangeHash> { |
|
| 174 | + | self.succ |
|
| 175 | + | .get(&Vertex::Atom(*k)) |
|
| 176 | + | .map(|ss| { |
|
| 177 | + | ss.iter() |
|
| 178 | + | .filter_map(|s| match s { |
|
| 179 | + | Vertex::Atom(y) if self.is_live(y) => Some(y.0), |
|
| 180 | + | _ => None, |
|
| 181 | + | }) |
|
| 182 | + | .collect() |
|
| 183 | + | }) |
|
| 184 | + | .unwrap_or_default() |
|
| 185 | + | } |
|
| 186 | + | ||
| 170 | 187 | fn is_ghost(&self, k: &AtomKey, info: &AtomInfo, deps: &dyn DepOracle) -> bool { |
|
| 171 | 188 | // A deleted atom is a ghost when a live direct successor was inserted |
|
| 172 | 189 | // by a change that commutes with every deleter: neither depends on |
|
| … | 861 unchanged lines |
modified crates/oxy-core/src/repo/git.rs +213 −1
| … | 8 unchanged lines |
||
| 9 | 9 | ||
| 10 | 10 | use super::*; |
|
| 11 | 11 | use std::process::{Command, Stdio}; |
|
| 12 | + | use std::sync::Arc; |
|
| 12 | 13 | ||
| 13 | 14 | #[derive(Debug, Clone, Default, PartialEq, Eq)] |
|
| 14 | 15 | pub struct GitImportReport { |
|
| … | 681 unchanged lines |
||
| 696 | 697 | ); |
|
| 697 | 698 | } |
|
| 698 | 699 | } |
|
| 699 | - | let built = self.build_from_entries(&base, &base_snapshot, HashMap::new(), &entries)?; |
|
| 700 | + | let mut built = |
|
| 701 | + | self.build_from_entries(&base, &base_snapshot, HashMap::new(), &entries)?; |
|
| 702 | + | // A file or submodule with several live blobs (branches that |
|
| 703 | + | // replaced it independently) is resolved to the commit's blob: |
|
| 704 | + | // Git's merge is the authority here, unlike a working copy, |
|
| 705 | + | // where a candidate on disk is not yet a decision (spec §23). |
|
| 706 | + | for (path, f) in &base_snapshot.files { |
|
| 707 | + | let (FileContent::Opaque { blobs }, Some(e)) = (&f.content, entries.get(path)) |
|
| 708 | + | else { |
|
| 709 | + | continue; |
|
| 710 | + | }; |
|
| 711 | + | if blobs.len() < 2 || f.kind != e.kind { |
|
| 712 | + | continue; |
|
| 713 | + | } |
|
| 714 | + | let chosen = self.store_blob(&e.content)?; |
|
| 715 | + | for b in blobs { |
|
| 716 | + | built.edits.push(Edit::Replace { |
|
| 717 | + | node: f.node, |
|
| 718 | + | old: Some(*b), |
|
| 719 | + | new: Some(chosen), |
|
| 720 | + | }); |
|
| 721 | + | } |
|
| 722 | + | built.resolves = true; |
|
| 723 | + | built |
|
| 724 | + | .contributors |
|
| 725 | + | .extend(base.live_replaces(&f.node).into_iter().map(|(c, _)| c)); |
|
| 726 | + | } |
|
| 700 | 727 | let mut deps: BTreeSet<ChangeHash> = built.contributors.iter().copied().collect(); |
|
| 701 | 728 | let mut message = gc.message.clone(); |
|
| 702 | 729 | if !message.ends_with('\n') { |
|
| … | 226 unchanged lines |
||
| 929 | 956 | Ok(()) |
|
| 930 | 957 | } |
|
| 931 | 958 | } |
|
| 959 | + | ||
| 960 | + | /// What `verify_git_import` found (spec §23: import must reproduce every tree). |
|
| 961 | + | #[derive(Debug, Clone, Default, PartialEq, Eq)] |
|
| 962 | + | pub struct GitVerifyReport { |
|
| 963 | + | pub commits: usize, |
|
| 964 | + | pub files_compared: usize, |
|
| 965 | + | /// `(commit, what differs)`; empty when every commit's tree is reproduced. |
|
| 966 | + | pub mismatches: Vec<(String, String)>, |
|
| 967 | + | } |
|
| 968 | + | ||
| 969 | + | impl Repo { |
|
| 970 | + | /// Check an import against its Git source: for every commit of |
|
| 971 | + | /// `branch`, the state formed by the changes imported for it and all |
|
| 972 | + | /// its ancestors must materialize to exactly that commit's tree, byte |
|
| 973 | + | /// for byte, with the same executable bits, symlink targets and |
|
| 974 | + | /// submodule pins. Reads only; stops listing after 50 differences. |
|
| 975 | + | pub fn verify_git_import(&self, git_dir: &Path, branch: &str) -> Result<GitVerifyReport> { |
|
| 976 | + | let commits = list_commits(git_dir, branch)?; |
|
| 977 | + | let imported = self.imported_commits()?; |
|
| 978 | + | let mut cat = CatFile::start(git_dir)?; |
|
| 979 | + | let mut rep = GitVerifyReport::default(); |
|
| 980 | + | // Changes of each commit and all its ancestors, built up in topological order. |
|
| 981 | + | let mut closure: HashMap<String, Arc<BTreeSet<ChangeHash>>> = HashMap::new(); |
|
| 982 | + | for c in &commits { |
|
| 983 | + | rep.commits += 1; |
|
| 984 | + | let mut set: BTreeSet<ChangeHash> = BTreeSet::new(); |
|
| 985 | + | for p in &c.parents { |
|
| 986 | + | if let Some(ps) = closure.get(p) { |
|
| 987 | + | set.extend(ps.iter().copied()); |
|
| 988 | + | } |
|
| 989 | + | } |
|
| 990 | + | match imported.get(&c.sha) { |
|
| 991 | + | Some(h) => { |
|
| 992 | + | set.insert(*h); |
|
| 993 | + | } |
|
| 994 | + | None => { |
|
| 995 | + | rep.mismatches.push(( |
|
| 996 | + | c.sha.clone(), |
|
| 997 | + | "no imported change carries this commit".into(), |
|
| 998 | + | )); |
|
| 999 | + | } |
|
| 1000 | + | } |
|
| 1001 | + | let set = Arc::new(set); |
|
| 1002 | + | closure.insert(c.sha.clone(), set.clone()); |
|
| 1003 | + | if rep.mismatches.len() >= 50 { |
|
| 1004 | + | break; |
|
| 1005 | + | } |
|
| 1006 | + | let heads: Vec<ChangeHash> = set.iter().copied().collect(); |
|
| 1007 | + | let state = self.load_state(&heads)?; |
|
| 1008 | + | let snap = state.materialize(); |
|
| 1009 | + | let tree = list_tree(git_dir, &c.sha)?; |
|
| 1010 | + | let short = &c.sha[..12.min(c.sha.len())]; |
|
| 1011 | + | let mut differ = |what: String| rep.mismatches.push((c.sha.clone(), what)); |
|
| 1012 | + | if snap.is_conflicted() { |
|
| 1013 | + | // Name the changes behind each conflicted file, by their Git commits. |
|
| 1014 | + | let mut detail = Vec::new(); |
|
| 1015 | + | for (path, f) in &snap.files { |
|
| 1016 | + | if !f.is_conflicted() { |
|
| 1017 | + | continue; |
|
| 1018 | + | } |
|
| 1019 | + | let who: Vec<String> = state |
|
| 1020 | + | .conflict_regions(&f.node) |
|
| 1021 | + | .contributors |
|
| 1022 | + | .iter() |
|
| 1023 | + | .filter_map(|h| state.get(h)) |
|
| 1024 | + | .map(|m| { |
|
| 1025 | + | let sha = m |
|
| 1026 | + | .message |
|
| 1027 | + | .lines() |
|
| 1028 | + | .rev() |
|
| 1029 | + | .find_map(|l| l.strip_prefix("Git-Commit: ")) |
|
| 1030 | + | .unwrap_or("?"); |
|
| 1031 | + | format!( |
|
| 1032 | + | "{} {:?}", |
|
| 1033 | + | &sha[..12.min(sha.len())], |
|
| 1034 | + | m.message.lines().next().unwrap_or("") |
|
| 1035 | + | ) |
|
| 1036 | + | }) |
|
| 1037 | + | .collect(); |
|
| 1038 | + | detail.push(format!("{path} between {}", who.join(" and "))); |
|
| 1039 | + | } |
|
| 1040 | + | differ(format!( |
|
| 1041 | + | "{short}: the state is conflicted: {}", |
|
| 1042 | + | detail.join("; ") |
|
| 1043 | + | )); |
|
| 1044 | + | } |
|
| 1045 | + | for (path, (mode, sha)) in &tree { |
|
| 1046 | + | let Some(f) = snap.files.get(path) else { |
|
| 1047 | + | differ(format!("{short}: {path} is missing")); |
|
| 1048 | + | continue; |
|
| 1049 | + | }; |
|
| 1050 | + | rep.files_compared += 1; |
|
| 1051 | + | match mode.as_str() { |
|
| 1052 | + | "160000" => match (&f.kind, &f.content) { |
|
| 1053 | + | (NodeKind::Subrepo, FileContent::Opaque { blobs }) if blobs.len() == 1 => { |
|
| 1054 | + | let pin = crate::subrepo::Pin::decode(&self.read_blob(&blobs[0])?)?; |
|
| 1055 | + | if !matches!(&pin, crate::subrepo::Pin::Git { sha: s, .. } if s == sha) |
|
| 1056 | + | { |
|
| 1057 | + | differ(format!("{short}: {path} pins another commit")); |
|
| 1058 | + | } |
|
| 1059 | + | } |
|
| 1060 | + | (kind, content) => differ(format!( |
|
| 1061 | + | "{short}: {path} is not one submodule pin: {kind:?} with {}", |
|
| 1062 | + | match content { |
|
| 1063 | + | FileContent::Opaque { blobs } => format!("{} pins", blobs.len()), |
|
| 1064 | + | FileContent::Text { .. } => "text".into(), |
|
| 1065 | + | FileContent::Dir => "a directory".into(), |
|
| 1066 | + | } |
|
| 1067 | + | )), |
|
| 1068 | + | }, |
|
| 1069 | + | "120000" => { |
|
| 1070 | + | let target = cat.read(sha)?; |
|
| 1071 | + | match (&f.kind, &f.content) { |
|
| 1072 | + | (NodeKind::Symlink, FileContent::Opaque { blobs }) |
|
| 1073 | + | if blobs.len() == 1 && self.read_blob(&blobs[0])? == target => {} |
|
| 1074 | + | _ => differ(format!("{short}: symlink {path} differs")), |
|
| 1075 | + | } |
|
| 1076 | + | } |
|
| 1077 | + | _ => { |
|
| 1078 | + | let want = cat.read(sha)?; |
|
| 1079 | + | let have = match &f.content { |
|
| 1080 | + | FileContent::Text { bytes, .. } => bytes.clone(), |
|
| 1081 | + | FileContent::Opaque { blobs } if blobs.len() == 1 => { |
|
| 1082 | + | self.read_blob(&blobs[0])? |
|
| 1083 | + | } |
|
| 1084 | + | _ => { |
|
| 1085 | + | differ(format!("{short}: {path} is not one file")); |
|
| 1086 | + | continue; |
|
| 1087 | + | } |
|
| 1088 | + | }; |
|
| 1089 | + | if have != want { |
|
| 1090 | + | differ(format!( |
|
| 1091 | + | "{short}: {path} differs: {}", |
|
| 1092 | + | first_difference(&have, &want) |
|
| 1093 | + | )); |
|
| 1094 | + | } |
|
| 1095 | + | if f.mode.exec != (mode == "100755") { |
|
| 1096 | + | differ(format!("{short}: {path} executable bit differs")); |
|
| 1097 | + | } |
|
| 1098 | + | } |
|
| 1099 | + | } |
|
| 1100 | + | } |
|
| 1101 | + | // Import records submodules in `.oxymodules` as well (spec §23), |
|
| 1102 | + | // and export leaves it out again. |
|
| 1103 | + | let has_submodules = tree.values().any(|(m, _)| m == "160000"); |
|
| 1104 | + | for (path, f) in &snap.files { |
|
| 1105 | + | if f.kind != NodeKind::Dir |
|
| 1106 | + | && !tree.contains_key(path) |
|
| 1107 | + | && !(has_submodules && path == ".oxymodules") |
|
| 1108 | + | { |
|
| 1109 | + | differ(format!("{short}: {path} is extra")); |
|
| 1110 | + | } |
|
| 1111 | + | } |
|
| 1112 | + | if rep.mismatches.len() >= 50 { |
|
| 1113 | + | break; |
|
| 1114 | + | } |
|
| 1115 | + | } |
|
| 1116 | + | Ok(rep) |
|
| 1117 | + | } |
|
| 1118 | + | } |
|
| 1119 | + | ||
| 1120 | + | /// Where two versions of a file first part, for a verification report. |
|
| 1121 | + | fn first_difference(have: &[u8], want: &[u8]) -> String { |
|
| 1122 | + | let (h, w): (Vec<&[u8]>, Vec<&[u8]>) = ( |
|
| 1123 | + | have.split(|b| *b == b'\n').collect(), |
|
| 1124 | + | want.split(|b| *b == b'\n').collect(), |
|
| 1125 | + | ); |
|
| 1126 | + | let i = h |
|
| 1127 | + | .iter() |
|
| 1128 | + | .zip(&w) |
|
| 1129 | + | .position(|(a, b)| a != b) |
|
| 1130 | + | .unwrap_or(h.len().min(w.len())); |
|
| 1131 | + | let show = |l: Option<&&[u8]>| { |
|
| 1132 | + | l.map(|l| String::from_utf8_lossy(&l[..l.len().min(60)]).into_owned()) |
|
| 1133 | + | .unwrap_or_else(|| "(end)".into()) |
|
| 1134 | + | }; |
|
| 1135 | + | format!( |
|
| 1136 | + | "line {}: has {:?}, Git has {:?} ({} lines, Git has {})", |
|
| 1137 | + | i + 1, |
|
| 1138 | + | show(h.get(i)), |
|
| 1139 | + | show(w.get(i)), |
|
| 1140 | + | h.len(), |
|
| 1141 | + | w.len() |
|
| 1142 | + | ) |
|
| 1143 | + | } |
modified crates/oxy-core/src/repo/mod.rs +1 −1
| … | 86 unchanged lines |
||
| 87 | 87 | pub use doctor::{Check, DoctorReport, Health}; |
|
| 88 | 88 | pub use fsck::{FsckReport, Problem, Severity}; |
|
| 89 | 89 | pub use gc::GcReport; |
|
| 90 | - | pub use git::{GitExportReport, GitImportReport}; |
|
| 90 | + | pub use git::{GitExportReport, GitImportReport, GitVerifyReport}; |
|
| 91 | 91 | pub use hooks::{json_array, json_str, HookOutcome}; |
|
| 92 | 92 | pub use hosted::FileEdit; |
|
| 93 | 93 | pub use identity::{Rotation, SignatureStatus}; |
|
| … | 1276 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +28 −0
| … | 1713 unchanged lines |
||
| 1714 | 1714 | } |
|
| 1715 | 1715 | } |
|
| 1716 | 1716 | Edit::Delete { atoms, .. } => { |
|
| 1717 | + | // Deleting an atom also depends on whoever inserted the live |
|
| 1718 | + | // atoms right after it: the author saw those insertions, and |
|
| 1719 | + | // without the dependency spec §9.1 would call the deletion |
|
| 1720 | + | // concurrent with them (a delete/insert conflict). |
|
| 1721 | + | let graph = base.file(&e.node()); |
|
| 1717 | 1722 | for r in atoms { |
|
| 1718 | 1723 | if !r.change.is_zero() { |
|
| 1719 | 1724 | out.insert(r.change); |
|
| 1720 | 1725 | } |
|
| 1726 | + | if let Some(g) = graph { |
|
| 1727 | + | for i in r.from..r.to { |
|
| 1728 | + | out.extend( |
|
| 1729 | + | g.live_successor_changes(&(r.change, i)) |
|
| 1730 | + | .into_iter() |
|
| 1731 | + | .filter(|c| !c.is_zero()), |
|
| 1732 | + | ); |
|
| 1733 | + | } |
|
| 1734 | + | } |
|
| 1721 | 1735 | } |
|
| 1722 | 1736 | } |
|
| 1737 | + | // Replacing a blob depends on the change that made it current, |
|
| 1738 | + | // as deleting a line depends on the line's introducer. |
|
| 1739 | + | Edit::Replace { |
|
| 1740 | + | node, |
|
| 1741 | + | old: Some(old), |
|
| 1742 | + | .. |
|
| 1743 | + | } => { |
|
| 1744 | + | out.extend( |
|
| 1745 | + | base.live_replaces(node) |
|
| 1746 | + | .into_iter() |
|
| 1747 | + | .filter(|(c, b)| b == old && !c.is_zero()) |
|
| 1748 | + | .map(|(c, _)| c), |
|
| 1749 | + | ); |
|
| 1750 | + | } |
|
| 1723 | 1751 | Edit::Replace { .. } => {} |
|
| 1724 | 1752 | } |
|
| 1725 | 1753 | } |
|
| … | 265 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +148 −0
| … | 4299 unchanged lines |
||
| 4300 | 4300 | .message |
|
| 4301 | 4301 | .contains("not contacted")); |
|
| 4302 | 4302 | } |
|
| 4303 | + | ||
| 4304 | + | /// Spec §8.1: deleting a line depends on whoever inserted the lines right |
|
| 4305 | + | /// after it. Inserting after `b` and then deleting `b`, one commit after |
|
| 4306 | + | /// the other, must not leave a delete/insert conflict. |
|
| 4307 | + | #[test] |
|
| 4308 | + | fn deleting_a_line_after_an_insertion_below_it_is_not_a_conflict() { |
|
| 4309 | + | hermetic(); |
|
| 4310 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 4311 | + | let root = dir.path(); |
|
| 4312 | + | let repo = Repo::init(root).unwrap(); |
|
| 4313 | + | write(root, "f", b"a\nb\nc\n"); |
|
| 4314 | + | repo.commit("base", &[]).unwrap(); |
|
| 4315 | + | write(root, "f", b"a\nb\nx\nc\n"); |
|
| 4316 | + | let insert = repo.commit("insert x after b", &[]).unwrap(); |
|
| 4317 | + | write(root, "f", b"a\nx\nc\n"); |
|
| 4318 | + | let delete = repo.commit("delete b", &[]).unwrap(); |
|
| 4319 | + | assert!( |
|
| 4320 | + | repo.store() |
|
| 4321 | + | .get_change(&delete) |
|
| 4322 | + | .unwrap() |
|
| 4323 | + | .deps |
|
| 4324 | + | .contains(&insert), |
|
| 4325 | + | "the deletion depends on the insertion below the deleted line" |
|
| 4326 | + | ); |
|
| 4327 | + | let heads = repo.bookmark_heads().unwrap(); |
|
| 4328 | + | assert_eq!(heads, vec![delete]); |
|
| 4329 | + | let snap = repo.load_state(&heads).unwrap().materialize(); |
|
| 4330 | + | assert!(!snap.is_conflicted()); |
|
| 4331 | + | assert!( |
|
| 4332 | + | matches!(&snap.files["f"].content, FileContent::Text { bytes, .. } if bytes == b"a\nx\nc\n") |
|
| 4333 | + | ); |
|
| 4334 | + | } |
|
| 4335 | + | ||
| 4336 | + | /// Spec §9.3: a binary file reverted to an earlier blob keeps it, and each |
|
| 4337 | + | /// replacement depends on the change that set the blob it replaces. |
|
| 4338 | + | #[test] |
|
| 4339 | + | fn a_binary_file_reverted_to_an_earlier_blob_keeps_it() { |
|
| 4340 | + | hermetic(); |
|
| 4341 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 4342 | + | let root = dir.path(); |
|
| 4343 | + | let repo = Repo::init(root).unwrap(); |
|
| 4344 | + | write(root, "bin", b"\0A"); |
|
| 4345 | + | let v1 = repo.commit("v1", &[]).unwrap(); |
|
| 4346 | + | write(root, "bin", b"\0B"); |
|
| 4347 | + | let v2 = repo.commit("v2", &[]).unwrap(); |
|
| 4348 | + | assert!(repo.store().get_change(&v2).unwrap().deps.contains(&v1)); |
|
| 4349 | + | write(root, "bin", b"\0A"); |
|
| 4350 | + | let v3 = repo.commit("back to A", &[]).unwrap(); |
|
| 4351 | + | assert!(repo.store().get_change(&v3).unwrap().deps.contains(&v2)); |
|
| 4352 | + | assert_eq!(repo.bookmark_heads().unwrap(), vec![v3]); |
|
| 4353 | + | let state = repo.load_state(&[v3]).unwrap(); |
|
| 4354 | + | let snap = state.materialize(); |
|
| 4355 | + | let FileContent::Opaque { blobs } = &snap.files["bin"].content else { |
|
| 4356 | + | panic!("opaque") |
|
| 4357 | + | }; |
|
| 4358 | + | assert_eq!(blobs.len(), 1, "one live blob, not none and not two"); |
|
| 4359 | + | assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\0A"); |
|
| 4360 | + | } |
|
| 4361 | + | ||
| 4362 | + | /// Spec §23: a binary file changed on two branches and merged in Git to |
|
| 4363 | + | /// one side's version comes back as that version at the merge, and the |
|
| 4364 | + | /// import verifies commit by commit. |
|
| 4365 | + | #[test] |
|
| 4366 | + | fn git_import_resolves_a_binary_merged_in_git_and_verifies() { |
|
| 4367 | + | hermetic(); |
|
| 4368 | + | if std::process::Command::new("git") |
|
| 4369 | + | .arg("--version") |
|
| 4370 | + | .output() |
|
| 4371 | + | .is_err() |
|
| 4372 | + | { |
|
| 4373 | + | eprintln!("git not installed; skipping"); |
|
| 4374 | + | return; |
|
| 4375 | + | } |
|
| 4376 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 4377 | + | let root = dir.path(); |
|
| 4378 | + | let g = root.join("src.git"); |
|
| 4379 | + | fs::create_dir_all(&g).unwrap(); |
|
| 4380 | + | let git = |args: &[&str]| -> String { |
|
| 4381 | + | let out = std::process::Command::new("git") |
|
| 4382 | + | .arg("-C") |
|
| 4383 | + | .arg(&g) |
|
| 4384 | + | .args([ |
|
| 4385 | + | "-c", |
|
| 4386 | + | "user.name=Grace", |
|
| 4387 | + | "-c", |
|
| 4388 | + | "[email protected]", |
|
| 4389 | + | "-c", |
|
| 4390 | + | "commit.gpgsign=false", |
|
| 4391 | + | ]) |
|
| 4392 | + | .args(args) |
|
| 4393 | + | .output() |
|
| 4394 | + | .unwrap(); |
|
| 4395 | + | assert!( |
|
| 4396 | + | out.status.success(), |
|
| 4397 | + | "git {args:?}: {}", |
|
| 4398 | + | String::from_utf8_lossy(&out.stderr) |
|
| 4399 | + | ); |
|
| 4400 | + | String::from_utf8_lossy(&out.stdout).trim().to_string() |
|
| 4401 | + | }; |
|
| 4402 | + | git(&["init", "-q", "-b", "main"]); |
|
| 4403 | + | write(&g, "logo.bin", b"\0base"); |
|
| 4404 | + | write(&g, "notes.txt", b"a\nb\nc\n"); |
|
| 4405 | + | git(&["add", "."]); |
|
| 4406 | + | git(&["commit", "-qm", "base"]); |
|
| 4407 | + | git(&["checkout", "-qb", "side"]); |
|
| 4408 | + | write(&g, "logo.bin", b"\0side"); |
|
| 4409 | + | write(&g, "notes.txt", b"a\nb\nx\nc\n"); |
|
| 4410 | + | git(&["commit", "-qam", "side"]); |
|
| 4411 | + | git(&["checkout", "-q", "main"]); |
|
| 4412 | + | write(&g, "logo.bin", b"\0main"); |
|
| 4413 | + | git(&["commit", "-qam", "main"]); |
|
| 4414 | + | // Git cannot merge the binary; the merge takes main's and keeps side's text. |
|
| 4415 | + | let _ = std::process::Command::new("git") |
|
| 4416 | + | .arg("-C") |
|
| 4417 | + | .arg(&g) |
|
| 4418 | + | .args([ |
|
| 4419 | + | "-c", |
|
| 4420 | + | "user.name=G", |
|
| 4421 | + | "-c", |
|
| 4422 | + | "user.email=g@e", |
|
| 4423 | + | "merge", |
|
| 4424 | + | "-q", |
|
| 4425 | + | "side", |
|
| 4426 | + | ]) |
|
| 4427 | + | .output(); |
|
| 4428 | + | write(&g, "logo.bin", b"\0main"); |
|
| 4429 | + | git(&["add", "."]); |
|
| 4430 | + | git(&["commit", "-qm", "merge side"]); |
|
| 4431 | + | // Then a revert of the binary to its first version. |
|
| 4432 | + | write(&g, "logo.bin", b"\0base"); |
|
| 4433 | + | git(&["commit", "-qam", "back to base"]); |
|
| 4434 | + | ||
| 4435 | + | let repo = Repo::init(&root.join("o")).unwrap(); |
|
| 4436 | + | let rep = repo.import_git(&g, "main").unwrap(); |
|
| 4437 | + | assert_eq!(rep.merges, 1); |
|
| 4438 | + | let v = repo.verify_git_import(&g, "main").unwrap(); |
|
| 4439 | + | assert!(v.mismatches.is_empty(), "{:?}", v.mismatches); |
|
| 4440 | + | assert_eq!(v.commits, 5); |
|
| 4441 | + | let snap = repo |
|
| 4442 | + | .load_state(&repo.bookmark_heads().unwrap()) |
|
| 4443 | + | .unwrap() |
|
| 4444 | + | .materialize(); |
|
| 4445 | + | assert!(!snap.is_conflicted()); |
|
| 4446 | + | let FileContent::Opaque { blobs } = &snap.files["logo.bin"].content else { |
|
| 4447 | + | panic!("opaque") |
|
| 4448 | + | }; |
|
| 4449 | + | assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\0base"); |
|
| 4450 | + | } |
modified crates/oxy-core/src/state.rs +33 −8
| … | 457 unchanged lines |
||
| 458 | 458 | ||
| 459 | 459 | /// Live blobs of an opaque node: every `new` that no later `Replace` retired. |
|
| 460 | 460 | pub fn live_blobs(&self, node: &NodeId) -> Vec<ObjectRef> { |
|
| 461 | - | let Some(rs) = self.replaces.get(node) else { |
|
| 462 | - | return vec![]; |
|
| 463 | - | }; |
|
| 464 | - | let olds: HashSet<ObjectRef> = rs.iter().filter_map(|(_, o, _)| *o).collect(); |
|
| 465 | - | let mut live: Vec<ObjectRef> = rs |
|
| 466 | - | .iter() |
|
| 467 | - | .filter_map(|(_, _, n)| *n) |
|
| 468 | - | .filter(|n| !olds.contains(n)) |
|
| 461 | + | let mut live: Vec<ObjectRef> = self |
|
| 462 | + | .live_replaces(node) |
|
| 463 | + | .into_iter() |
|
| 464 | + | .map(|(_, n)| n) |
|
| 469 | 465 | .collect(); |
|
| 470 | 466 | live.sort(); |
|
| 471 | 467 | live.dedup(); |
|
| 472 | 468 | live |
|
| 473 | 469 | } |
|
| 474 | 470 | ||
| 471 | + | /// The replace records whose blob is live, with the change that set it |
|
| 472 | + | /// (spec §9.3). A record's blob is superseded by a later replacement of |
|
| 473 | + | /// that blob: one whose change depends on the record's change, as every |
|
| 474 | + | /// replacement depends on the change that made its old blob current |
|
| 475 | + | /// (§8.1). Two replacements that do not know each other both stay live |
|
| 476 | + | /// (a replace conflict), and a file reverted to an earlier blob keeps it. |
|
| 477 | + | /// A replacement without that dependency, from before the rule, is read |
|
| 478 | + | /// as before: it supersedes every record that did not come after it. |
|
| 479 | + | pub fn live_replaces(&self, node: &NodeId) -> Vec<(ChangeHash, ObjectRef)> { |
|
| 480 | + | let Some(rs) = self.replaces.get(node) else { |
|
| 481 | + | return vec![]; |
|
| 482 | + | }; |
|
| 483 | + | let knows_its_old = |s: &ChangeHash, old: &ObjectRef| { |
|
| 484 | + | rs.iter() |
|
| 485 | + | .any(|(q, _, n)| n.as_ref() == Some(old) && q != s && self.closure(s).contains(q)) |
|
| 486 | + | }; |
|
| 487 | + | rs.iter() |
|
| 488 | + | .filter_map(|(c, _, n)| n.map(|n| (*c, n))) |
|
| 489 | + | .filter(|(c, n)| { |
|
| 490 | + | !rs.iter().any(|(s, o, _)| { |
|
| 491 | + | s != c |
|
| 492 | + | && *o == Some(*n) |
|
| 493 | + | && (self.closure(s).contains(c) |
|
| 494 | + | || (!knows_its_old(s, n) && !self.closure(c).contains(s))) |
|
| 495 | + | }) |
|
| 496 | + | }) |
|
| 497 | + | .collect() |
|
| 498 | + | } |
|
| 499 | + | ||
| 475 | 500 | /// Short label for conflict markers: `changeid-prefix "message"`. |
|
| 476 | 501 | pub fn label(&self, h: &ChangeHash) -> String { |
|
| 477 | 502 | match self.changes.get(h) { |
|
| … | 400 unchanged lines |
modified docs/spec.md +8 −5
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.8h | |
|
| 5 | + | | **Status** | Draft 0.8i | |
|
| 6 | 6 | | **Format version** | 1 | |
|
| 7 | 7 | | **Date** | 2026-09-26 | |
|
| 8 | 8 | | **Audience** | Implementers, security reviewers, and contributors | |
|
| … | 303 unchanged lines |
||
| 312 | 312 | ||
| 313 | 313 | The **scope** of a change `C` is the set of atoms and placements it references: every atom it deletes, every atom used as an anchor, every placement it deletes or moves, and every node it operates on. |
|
| 314 | 314 | ||
| 315 | - | `deps(C)` MUST contain the ChangeHash of the introducing change of every element of `scope(C)`, excluding `C` itself, START, END, and the root node. |
|
| 315 | + | `deps(C)` MUST contain the ChangeHash of the introducing change of every element of `scope(C)`, excluding `C` itself, START, END, and the root node. It MUST also contain, for every atom `C` deletes, the introducing change of every atom live directly after it in the author's state, since without it the deletion reads as concurrent with those insertions and materializes as a delete/insert conflict (§9.1); and, for every `Replace` whose `old` is a blob, the change(s) whose replace record made that blob current. |
|
| 316 | 316 | ||
| 317 | 317 | **Explicit (semantic) dependencies.** Changes that share no atoms commute even when they are semantically coupled, for example an API change in one file and the update of its caller in another. To prevent such changes from being separated, `deps(C)` MAY contain additional hashes that the author or tooling chose to require. `oxy commit --depends-on <change>` adds one; the argument is resolved to a ChangeHash as in §24, and a divergent ChangeId is an error. Implementations SHOULD support the `semantic-deps` hook (§20) so that build tools or language servers can inject cross-file dependencies at commit time. |
|
| 318 | 318 | ||
| … | 42 unchanged lines |
||
| 361 | 361 | ||
| 362 | 362 | Two live `Replace` edits with the same `old` and different `new` are a **replace conflict**. The state exposes both candidate blobs. |
|
| 363 | 363 | ||
| 364 | + | Which blobs are live: a replace record is superseded by a later replacement of its blob, a record whose `old` is its `new` and whose change depends on its change (§8.1 makes every replacement depend on the change that made its `old` current). Replacements that do not know each other both stay live, and a file reverted to an earlier blob keeps it. A replacement recorded without that dependency (before Draft 0.8i) supersedes every record with its `new` that does not come after it, the rule until then. |
|
| 365 | + | ||
| 364 | 366 | ### 9.4 Properties |
|
| 365 | 367 | ||
| 366 | 368 | - A conflicted state is well-formed and valid. It MAY be committed, pushed, fetched, tagged, and built upon. |
|
| … | 456 unchanged lines |
||
| 823 | 825 | - **Export.** Each state along a bookmark becomes a Git commit whose tree is the materialized state. The ChangeId is written as a `Change-Id:` trailer and the ChangeHash as `Oxy-Hash:`. Conflicted states are exported with §9.5 markers and an `Oxy-Conflicted: true` trailer. Signatures are not exported; Git commits MAY be additionally signed with the Ed25519 key via SSH signature format. |
|
| 824 | 826 | - **Fetch and push against a Git remote.** `oxy git fetch URL` keeps a bare mirror under `.oxy/git/` for the remote, fetches the branch into it, and imports the commits the bookmark does not have yet, seeding the import with the commits already known: an imported commit maps to its change, and a commit this repository exported maps to the bookmark state it was made from (so a Git commit on top of exported work is based on everything that work contained, including changes Git never saw). The bookmark moves to the union of its heads and the new changes, like a pull; the working copy follows when it was clean. A fetched state whose previous state was itself a Git tree is remembered as equal to the fetched tip. `oxy git push URL` refuses until the remote's tip is known, exports the bookmark's later states as commits chained onto that tip, and pushes the branch, so the push is always a fast-forward. |
|
| 825 | 827 | - **Submodules.** A Git gitlink entry (mode `160000`) imports as a `subrepo` node with a Git pin (§7.8) and the matching `.gitmodules` entry becomes a `.oxymodules` entry with `branch` set to the submodule's configured branch when there is one. Export writes a Git pin back as a gitlink, keeps a tracked `.gitmodules` as is, and otherwise derives `.gitmodules` from `.oxymodules`; an Oxygen pin has no Git form and is omitted from the exported tree, which the export report counts. |
|
| 826 | - | - **Round trip.** Import followed by export MUST reproduce every tree hash. Export followed by import MUST reproduce every ChangeId. |
|
| 828 | + | - **Merges of opaque files.** When the union of a merge commit's parents holds several live blobs for a file or submodule, the merge's change replaces each of them with the commit's blob and is a resolution: Git's merge is the authority, unlike a working copy, where a candidate on disk is not yet a decision. |
|
| 829 | + | - **Round trip.** Import followed by export MUST reproduce every tree hash. Export followed by import MUST reproduce every ChangeId. `oxy git verify GIT_DIR` checks an import commit by commit: for each commit, the state formed by the changes imported for it and all its ancestors must materialize to exactly that commit's tree (content, executable bits, symlink targets, submodule pins; `.oxymodules` is allowed beside submodules). |
|
| 827 | 830 | ||
| 828 | 831 | ## 24. Command-line interface |
|
| 829 | 832 | ||
| … | 18 unchanged lines |
||
| 848 | 851 | oxy policy init|show|grant|revoke|root|sign oxy verify [CHANGE] |
|
| 849 | 852 | oxy seal [CHANGE].. | --proposal URL seal your unauthenticated and anonymous changes (§14.4), or a hub proposal's changes as your approval |
|
| 850 | 853 | oxy proposal show|land URL hub proposals from a client (hub spec §7) |
|
| 851 | - | oxy git import|export|fetch|push (fetch and push go through a bare mirror under .oxy/git/) |
|
| 854 | + | oxy git import|export|fetch|push|verify (fetch and push go through a bare mirror under .oxy/git/) |
|
| 852 | 855 | oxy checkpoint oxy gc [--now] oxy config [--user] KEY VALUE |
|
| 853 | 856 | oxy root oxy cat PATH [-r CHANGE] oxy version |
|
| 854 | 857 | oxy fsck [--json] check integrity (§21.1) |
|
| … | 79 unchanged lines |
||
| 934 | 937 | ||
| 935 | 938 | --- |
|
| 936 | 939 | ||
| 937 | - | *Changelog.* Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6); the operation head moves by compare-and-swap and concurrent operations are merged (§12.1); `oxy doctor` (§21.2). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 940 | + | *Changelog.* Draft 0.8i (2026-09-28): found by verifying a real Git import commit by commit: deletions depend on the inserters of the lines right after them, and replacements on the change that set their old blob (§8.1); replace records are superseded causally, so a reverted binary keeps its blob (§9.3); merges resolve opaque conflicts to the commit's blob, and `oxy git verify` (§23). Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6); the operation head moves by compare-and-swap and concurrent operations are merged (§12.1); `oxy doctor` (§21.2). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 938 | 941 | ||
| 939 | 942 | Draft 0.8a (2026-09-27): every Draft 0.8 addition is implemented in the reference implementation (§26.1). Clarifications from doing so: an unregistered nested repository is left alone and a tracked subrepo whose directory is absent keeps its pin (§7.8); Oxygen pins are omitted from Git export (§23). |
|
| 940 | 943 | ||
| … | 59 unchanged lines |
modified src/cli/mod.rs +11 −0
| … | 564 unchanged lines |
||
| 565 | 565 | #[arg(long, default_value = "main")] |
|
| 566 | 566 | branch: String, |
|
| 567 | 567 | }, |
|
| 568 | + | /// Check this repository's import of a Git branch: every commit's tree must be reproduced exactly |
|
| 569 | + | Verify { |
|
| 570 | + | /// The Git repository the import came from |
|
| 571 | + | git_dir: PathBuf, |
|
| 572 | + | /// Branch that was imported |
|
| 573 | + | #[arg(long, default_value = "main")] |
|
| 574 | + | branch: String, |
|
| 575 | + | }, |
|
| 568 | 576 | /// Import a Git branch into a new Oxygen repository at DEST |
|
| 569 | 577 | Import { |
|
| 570 | 578 | git_dir: PathBuf, |
|
| … | 228 unchanged lines |
||
| 799 | 807 | branch, |
|
| 800 | 808 | }, |
|
| 801 | 809 | } => sync::git_import(&git_dir, &dest, &branch), |
|
| 810 | + | Cmd::Git { |
|
| 811 | + | cmd: GitCmd::Verify { git_dir, branch }, |
|
| 812 | + | } => sync::git_verify(&git_dir, &branch), |
|
| 802 | 813 | Cmd::Policy { cmd } => match cmd { |
|
| 803 | 814 | PolicyCmd::Init => identity::policy_init(), |
|
| 804 | 815 | PolicyCmd::Show { json } => identity::policy_show(json), |
|
| … | 29 unchanged lines |
modified src/cli/sync.rs +34 −0
| … | 412 unchanged lines |
||
| 413 | 413 | Ok(0) |
|
| 414 | 414 | } |
|
| 415 | 415 | ||
| 416 | + | /// `oxy git verify`: exit 1 if any commit's tree is not reproduced. |
|
| 417 | + | pub fn git_verify(git_dir: &std::path::Path, branch: &str) -> Result<i32> { |
|
| 418 | + | let repo = open()?; |
|
| 419 | + | let started = std::time::Instant::now(); |
|
| 420 | + | let sp = ui::spinner(&format!( |
|
| 421 | + | "Rebuilding every commit of {} {branch}…", |
|
| 422 | + | git_dir.display() |
|
| 423 | + | )); |
|
| 424 | + | let r = repo.verify_git_import(git_dir, branch)?; |
|
| 425 | + | sp.clear(); |
|
| 426 | + | for (_, what) in &r.mismatches { |
|
| 427 | + | println!("{} {what}", ui::err(ui::glyph("✖", "x"))); |
|
| 428 | + | } |
|
| 429 | + | if r.mismatches.is_empty() { |
|
| 430 | + | println!( |
|
| 431 | + | "{}", |
|
| 432 | + | ui::done(format!( |
|
| 433 | + | "Every commit reproduced: {} commits, {} files compared, in {:.1}s", |
|
| 434 | + | r.commits, |
|
| 435 | + | r.files_compared, |
|
| 436 | + | started.elapsed().as_secs_f64() |
|
| 437 | + | )) |
|
| 438 | + | ); |
|
| 439 | + | Ok(0) |
|
| 440 | + | } else { |
|
| 441 | + | println!( |
|
| 442 | + | "{} differences in {} commits checked", |
|
| 443 | + | r.mismatches.len(), |
|
| 444 | + | r.commits |
|
| 445 | + | ); |
|
| 446 | + | Ok(1) |
|
| 447 | + | } |
|
| 448 | + | } |
|
| 449 | + | ||
| 416 | 450 | pub fn git_import(git_dir: &std::path::Path, dest: &std::path::Path, branch: &str) -> Result<i32> { |
|
| 417 | 451 | let repo = Repo::init(dest)?; |
|
| 418 | 452 | let started = std::time::Instant::now(); |
|
| … | 203 unchanged lines |