Git import: one node per directory path, and no placement out of sight a87c7b9d563a
A 1 747-commit slice of vscode imported with one file as a name conflict from a merge on (51 verify differences). Git gives a directory no identity, and the import did: - a directory deleted on one line of history and made again got a second node, so a file moved into it on another line, where the first still stood, ended up in two directories of one path. An import now brings the directory back with a move from its last placement; - a placement under a directory another line removed is out of sight, so no diff of the tree reaches it. An import's change now retires a kept node's other live placements; - a node whose only placements are under removed directories (an orphan) is out of sight entirely, and goes. Each has a test built from a Git history, and each test reproduces the vscode symptom without its fix. Spec §23 says so. The slice now reproduces every commit (3.3 million files compared). oxy git verify built and rendered every file of every commit and read every Git blob. It now loads each commit's history and builds only the files whose graph (by key) or blob it has not already found equal to that Git blob: 115 s for the slice instead of 1 400 s, and half the memory. closes #30
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-30 01:30 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
a87c7b9d563a946161b3aa4af7cde66a57352d6cebb5d433842536980050bc54- Depends on
- 1681323d1dff 1d360c91a289 2c75672fb2fe 33e77a5526fa 598009a75f6c 614541c3cebd 62794ecc7f74 6e723b81f5b9 75167672ebcd 79ab8528ff62 982864068a59 da868d5d2646 dfb23a3a8cfa f59376ea413b ff0af1ee299d
- Depended on by
- c2e1d09047db d6e0d5b8b339 360aa74a6277 05059d434aca e83354cdac22 2306a4d24779 d77e1702d770 da652d6392d8 57d8dc05c9d4 b493d993a9df
- 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
- 32 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified crates/oxy-core/src/repo/git.rs +109 −67
| … | 1110 unchanged lines |
||
| 1111 | 1111 | let mut rep = GitVerifyReport::default(); |
|
| 1112 | 1112 | // Changes of each commit and all its ancestors, built up in topological order. |
|
| 1113 | 1113 | let mut closure: HashMap<String, Arc<BTreeSet<ChangeHash>>> = HashMap::new(); |
|
| 1114 | + | // Graphs (by key) and blobs already found equal to a Git blob. |
|
| 1115 | + | let mut verified_graphs: HashMap<[u8; 32], String> = HashMap::new(); |
|
| 1116 | + | let mut verified_blobs: HashMap<ObjectRef, String> = HashMap::new(); |
|
| 1114 | 1117 | for c in &commits { |
|
| 1115 | 1118 | rep.commits += 1; |
|
| 1116 | 1119 | let mut set: BTreeSet<ChangeHash> = BTreeSet::new(); |
|
| … | 19 unchanged lines |
||
| 1136 | 1139 | break; |
|
| 1137 | 1140 | } |
|
| 1138 | 1141 | let heads: Vec<ChangeHash> = set.iter().copied().collect(); |
|
| 1139 | - | let state = self.load_state(&heads)?; |
|
| 1140 | - | let snap = state.materialize(); |
|
| 1142 | + | // History only: a file whose graph (by key) or blob was already |
|
| 1143 | + | // found equal to this Git blob is not built, rendered or read |
|
| 1144 | + | // again; the rest are. |
|
| 1145 | + | let mut state = self.load_state_history(&heads)?; |
|
| 1146 | + | let view = state.tree().view(); |
|
| 1141 | 1147 | let tree = list_tree(git_dir, &c.sha)?; |
|
| 1142 | 1148 | let short = &c.sha[..12.min(c.sha.len())]; |
|
| 1143 | 1149 | let mut differ = |what: String| rep.mismatches.push((c.sha.clone(), what)); |
|
| 1144 | - | if snap.is_conflicted() { |
|
| 1145 | - | // Name the changes behind each conflicted file, by their Git commits. |
|
| 1146 | - | let mut detail = Vec::new(); |
|
| 1147 | - | for (path, f) in &snap.files { |
|
| 1148 | - | if !f.is_conflicted() { |
|
| 1150 | + | let mut conflicted: Vec<String> = |
|
| 1151 | + | view.conflicts.iter().map(|c| format!("{c:?}")).collect(); |
|
| 1152 | + | let mut need: Vec<NodeId> = Vec::new(); |
|
| 1153 | + | for (path, (mode, sha)) in &tree { |
|
| 1154 | + | let Some(e) = view.entries.get(path) else { |
|
| 1149 | 1155 | continue; |
|
| 1156 | + | }; |
|
| 1157 | + | if mode != "160000" |
|
| 1158 | + | && mode != "120000" |
|
| 1159 | + | && e.kind == NodeKind::File |
|
| 1160 | + | && !e.mode.opaque |
|
| 1161 | + | { |
|
| 1162 | + | let known = state |
|
| 1163 | + | .graph_key(&e.node) |
|
| 1164 | + | .is_some_and(|k| verified_graphs.get(&k) == Some(sha)); |
|
| 1165 | + | if !known { |
|
| 1166 | + | need.push(e.node); |
|
| 1150 | 1167 | } |
|
| 1151 | - | let who: Vec<String> = state |
|
| 1152 | - | .conflict_regions(&f.node) |
|
| 1153 | - | .contributors |
|
| 1154 | - | .iter() |
|
| 1155 | - | .filter_map(|h| state.get(h)) |
|
| 1156 | - | .map(|m| { |
|
| 1157 | - | let sha = m |
|
| 1158 | - | .message |
|
| 1159 | - | .lines() |
|
| 1160 | - | .rev() |
|
| 1161 | - | .find_map(|l| l.strip_prefix("Git-Commit: ")) |
|
| 1162 | - | .unwrap_or("?"); |
|
| 1163 | - | format!( |
|
| 1164 | - | "{} {:?}", |
|
| 1165 | - | &sha[..12.min(sha.len())], |
|
| 1166 | - | m.message.lines().next().unwrap_or("") |
|
| 1167 | - | ) |
|
| 1168 | - | }) |
|
| 1169 | - | .collect(); |
|
| 1170 | - | detail.push(format!("{path} between {}", who.join(" and "))); |
|
| 1171 | 1168 | } |
|
| 1172 | - | differ(format!( |
|
| 1173 | - | "{short}: the state is conflicted: {}", |
|
| 1174 | - | detail.join("; ") |
|
| 1175 | - | )); |
|
| 1176 | 1169 | } |
|
| 1170 | + | // A graph's key is known only before it is built. |
|
| 1171 | + | let keys: HashMap<NodeId, [u8; 32]> = need |
|
| 1172 | + | .iter() |
|
| 1173 | + | .filter_map(|n| Some((*n, state.graph_key(n)?))) |
|
| 1174 | + | .collect(); |
|
| 1175 | + | let need_set: HashSet<NodeId> = need.iter().copied().collect(); |
|
| 1176 | + | self.load_graphs_of(&mut state, &need)?; |
|
| 1177 | 1177 | for (path, (mode, sha)) in &tree { |
|
| 1178 | - | let Some(f) = snap.files.get(path) else { |
|
| 1178 | + | let Some(e) = view.entries.get(path) else { |
|
| 1179 | 1179 | differ(format!("{short}: {path} is missing")); |
|
| 1180 | 1180 | continue; |
|
| 1181 | 1181 | }; |
|
| 1182 | 1182 | rep.files_compared += 1; |
|
| 1183 | 1183 | match mode.as_str() { |
|
| 1184 | - | "160000" => match (&f.kind, &f.content) { |
|
| 1185 | - | (NodeKind::Subrepo, FileContent::Opaque { blobs }) if blobs.len() == 1 => { |
|
| 1186 | - | let pin = crate::subrepo::Pin::decode(&self.read_blob(&blobs[0])?)?; |
|
| 1187 | - | if !matches!(&pin, crate::subrepo::Pin::Git { sha: s, .. } if s == sha) |
|
| 1188 | - | { |
|
| 1189 | - | differ(format!("{short}: {path} pins another commit")); |
|
| 1184 | + | "160000" => { |
|
| 1185 | + | let blobs = state.live_blobs(&e.node); |
|
| 1186 | + | match (e.kind, blobs.as_slice()) { |
|
| 1187 | + | (NodeKind::Subrepo, [b]) => { |
|
| 1188 | + | let pin = crate::subrepo::Pin::decode(&self.read_blob(b)?)?; |
|
| 1189 | + | if !matches!(&pin, crate::subrepo::Pin::Git { sha: s, .. } if s == sha) |
|
| 1190 | + | { |
|
| 1191 | + | differ(format!("{short}: {path} pins another commit")); |
|
| 1192 | + | } |
|
| 1190 | 1193 | } |
|
| 1194 | + | (kind, _) => differ(format!( |
|
| 1195 | + | "{short}: {path} is not one submodule pin: {kind:?} with {} pins", |
|
| 1196 | + | blobs.len() |
|
| 1197 | + | )), |
|
| 1191 | 1198 | } |
|
| 1192 | - | (kind, content) => differ(format!( |
|
| 1193 | - | "{short}: {path} is not one submodule pin: {kind:?} with {}", |
|
| 1194 | - | match content { |
|
| 1195 | - | FileContent::Opaque { blobs } => format!("{} pins", blobs.len()), |
|
| 1196 | - | FileContent::Text { .. } => "text".into(), |
|
| 1197 | - | FileContent::Dir => "a directory".into(), |
|
| 1198 | - | } |
|
| 1199 | - | )), |
|
| 1200 | - | }, |
|
| 1199 | + | } |
|
| 1201 | 1200 | "120000" => { |
|
| 1202 | 1201 | let target = cat.read(sha)?; |
|
| 1203 | - | match (&f.kind, &f.content) { |
|
| 1204 | - | (NodeKind::Symlink, FileContent::Opaque { blobs }) |
|
| 1205 | - | if blobs.len() == 1 && self.read_blob(&blobs[0])? == target => {} |
|
| 1202 | + | let blobs = state.live_blobs(&e.node); |
|
| 1203 | + | match (e.kind, blobs.as_slice()) { |
|
| 1204 | + | (NodeKind::Symlink, [b]) if self.read_blob(b)? == target => {} |
|
| 1206 | 1205 | _ => differ(format!("{short}: symlink {path} differs")), |
|
| 1207 | 1206 | } |
|
| 1208 | 1207 | } |
|
| 1209 | 1208 | _ => { |
|
| 1210 | - | let want = cat.read(sha)?; |
|
| 1211 | - | let have = match &f.content { |
|
| 1212 | - | FileContent::Text { bytes, .. } => bytes.clone(), |
|
| 1213 | - | FileContent::Opaque { blobs } if blobs.len() == 1 => { |
|
| 1214 | - | self.read_blob(&blobs[0])? |
|
| 1215 | - | } |
|
| 1216 | - | _ => { |
|
| 1217 | - | differ(format!("{short}: {path} is not one file")); |
|
| 1209 | + | if e.mode.exec != (mode == "100755") { |
|
| 1210 | + | differ(format!("{short}: {path} executable bit differs")); |
|
| 1211 | + | } |
|
| 1212 | + | if e.kind == NodeKind::File && !e.mode.opaque { |
|
| 1213 | + | if !need_set.contains(&e.node) { |
|
| 1214 | + | // Known equal: skipped when the graphs were chosen. |
|
| 1218 | 1215 | continue; |
|
| 1219 | 1216 | } |
|
| 1217 | + | let (have, m) = state.render_file(&e.node); |
|
| 1218 | + | if m.is_conflicted() { |
|
| 1219 | + | let who: Vec<String> = state |
|
| 1220 | + | .conflict_regions(&e.node) |
|
| 1221 | + | .contributors |
|
| 1222 | + | .iter() |
|
| 1223 | + | .filter_map(|h| state.get(h)) |
|
| 1224 | + | .map(|m| { |
|
| 1225 | + | let sha = m |
|
| 1226 | + | .message |
|
| 1227 | + | .lines() |
|
| 1228 | + | .rev() |
|
| 1229 | + | .find_map(|l| l.strip_prefix("Git-Commit: ")) |
|
| 1230 | + | .unwrap_or("?"); |
|
| 1231 | + | format!( |
|
| 1232 | + | "{} {:?}", |
|
| 1233 | + | &sha[..12.min(sha.len())], |
|
| 1234 | + | m.message.lines().next().unwrap_or("") |
|
| 1235 | + | ) |
|
| 1236 | + | }) |
|
| 1237 | + | .collect(); |
|
| 1238 | + | conflicted.push(format!("{path} between {}", who.join(" and "))); |
|
| 1239 | + | } |
|
| 1240 | + | let want = cat.read(sha)?; |
|
| 1241 | + | if have != want { |
|
| 1242 | + | differ(format!( |
|
| 1243 | + | "{short}: {path} differs: {}", |
|
| 1244 | + | first_difference(&have, &want) |
|
| 1245 | + | )); |
|
| 1246 | + | } else if !m.is_conflicted() { |
|
| 1247 | + | if let Some(k) = keys.get(&e.node) { |
|
| 1248 | + | verified_graphs.insert(*k, sha.clone()); |
|
| 1249 | + | } |
|
| 1250 | + | } |
|
| 1251 | + | continue; |
|
| 1252 | + | } |
|
| 1253 | + | let blobs = state.live_blobs(&e.node); |
|
| 1254 | + | let [b] = blobs.as_slice() else { |
|
| 1255 | + | differ(format!("{short}: {path} is not one file")); |
|
| 1256 | + | continue; |
|
| 1220 | 1257 | }; |
|
| 1221 | - | if have != want { |
|
| 1222 | - | differ(format!( |
|
| 1223 | - | "{short}: {path} differs: {}", |
|
| 1224 | - | first_difference(&have, &want) |
|
| 1225 | - | )); |
|
| 1258 | + | if verified_blobs.get(b) == Some(sha) { |
|
| 1259 | + | continue; |
|
| 1226 | 1260 | } |
|
| 1227 | - | if f.mode.exec != (mode == "100755") { |
|
| 1228 | - | differ(format!("{short}: {path} executable bit differs")); |
|
| 1261 | + | if self.read_blob(b)? != cat.read(sha)? { |
|
| 1262 | + | differ(format!("{short}: {path} differs")); |
|
| 1263 | + | } else { |
|
| 1264 | + | verified_blobs.insert(*b, sha.clone()); |
|
| 1229 | 1265 | } |
|
| 1230 | 1266 | } |
|
| 1231 | 1267 | } |
|
| 1232 | 1268 | } |
|
| 1269 | + | if !conflicted.is_empty() { |
|
| 1270 | + | differ(format!( |
|
| 1271 | + | "{short}: the state is conflicted: {}", |
|
| 1272 | + | conflicted.join("; ") |
|
| 1273 | + | )); |
|
| 1274 | + | } |
|
| 1233 | 1275 | // Import records submodules in `.oxymodules` as well (spec §23), |
|
| 1234 | 1276 | // and export leaves it out again. |
|
| 1235 | 1277 | let has_submodules = tree.values().any(|(m, _)| m == "160000"); |
|
| 1236 | - | for (path, f) in &snap.files { |
|
| 1237 | - | if f.kind != NodeKind::Dir |
|
| 1278 | + | for (path, e) in &view.entries { |
|
| 1279 | + | if e.kind != NodeKind::Dir |
|
| 1238 | 1280 | && !tree.contains_key(path) |
|
| 1239 | 1281 | && !(has_submodules && path == ".oxymodules") |
|
| 1240 | 1282 | { |
|
| … | 35 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +127 −3
| … | 224 unchanged lines |
||
| 225 | 225 | b.remove_path(path, expected); |
|
| 226 | 226 | } |
|
| 227 | 227 | } |
|
| 228 | + | if seed.is_some() { |
|
| 229 | + | b.retire_stray_placements(); |
|
| 230 | + | } |
|
| 228 | 231 | let mut statuses = b.statuses; |
|
| 229 | 232 | statuses.sort_by(|a, b| a.path.cmp(&b.path)); |
|
| 230 | 233 | Ok(BuiltChange { |
|
| … | 1321 unchanged lines |
||
| 1552 | 1555 | resolve: HashSet<String>, |
|
| 1553 | 1556 | /// When set, new nodes' identifiers derive from it and their paths. |
|
| 1554 | 1557 | node_seed: Option<[u8; 32]>, |
|
| 1558 | + | /// For a Git import, the base's dead directories by where they were |
|
| 1559 | + | /// (see `ensure_dir`), worked out when first needed. |
|
| 1560 | + | dead_dirs: Option<HashMap<(NodeId, String), (NodeId, crate::tree::OpKey)>>, |
|
| 1555 | 1561 | } |
|
| 1556 | 1562 | ||
| 1557 | 1563 | impl<'a> ChangeBuilder<'a> { |
|
| … | 23 unchanged lines |
||
| 1581 | 1587 | blobs: HashMap::new(), |
|
| 1582 | 1588 | resolve: HashSet::new(), |
|
| 1583 | 1589 | node_seed: None, |
|
| 1590 | + | dead_dirs: None, |
|
| 1584 | 1591 | } |
|
| 1585 | 1592 | } |
|
| 1586 | 1593 | ||
| … | 62 unchanged lines |
||
| 1649 | 1656 | .map(|(n, _)| *n) |
|
| 1650 | 1657 | } |
|
| 1651 | 1658 | ||
| 1659 | + | /// A dead directory last placed at (`parent`, `name`) in the base. |
|
| 1660 | + | fn revivable_dir( |
|
| 1661 | + | &mut self, |
|
| 1662 | + | parent: NodeId, |
|
| 1663 | + | name: &str, |
|
| 1664 | + | ) -> Option<(NodeId, crate::tree::OpKey)> { |
|
| 1665 | + | let base = self.base; |
|
| 1666 | + | self.dead_dirs |
|
| 1667 | + | .get_or_insert_with(|| base.tree().dead_dirs()) |
|
| 1668 | + | .get(&(parent, name.to_string())) |
|
| 1669 | + | .copied() |
|
| 1670 | + | } |
|
| 1671 | + | ||
| 1652 | 1672 | fn ensure_dir(&mut self, path: &str) -> NodeId { |
|
| 1653 | 1673 | if let Some(n) = self.dir_nodes.get(path) { |
|
| 1654 | 1674 | return *n; |
|
| … | 5 unchanged lines |
||
| 1660 | 1680 | prefix.push('/'); |
|
| 1661 | 1681 | } |
|
| 1662 | 1682 | prefix.push_str(comp); |
|
| 1663 | - | parent = match self.dir_nodes.get(&prefix) { |
|
| 1664 | - | Some(n) => *n, |
|
| 1665 | - | None => { |
|
| 1683 | + | let known = self.dir_nodes.get(&prefix).copied(); |
|
| 1684 | + | let revive = match known { |
|
| 1685 | + | Some(_) => None, |
|
| 1686 | + | None if self.node_seed.is_some() => self.revivable_dir(parent, comp), |
|
| 1687 | + | None => None, |
|
| 1688 | + | }; |
|
| 1689 | + | parent = match (known, revive) { |
|
| 1690 | + | (Some(n), _) => n, |
|
| 1691 | + | (None, Some((node, old))) => { |
|
| 1692 | + | // Git has no identity for a directory: one that comes |
|
| 1693 | + | // back where it was is the same directory, brought back |
|
| 1694 | + | // with a move, so lines of history that placed files in |
|
| 1695 | + | // it before and after agree (oxygen#30). |
|
| 1696 | + | self.tree_ops.push(TreeOp::Move { |
|
| 1697 | + | node, |
|
| 1698 | + | old: OpRef { |
|
| 1699 | + | change: old.0, |
|
| 1700 | + | index: old.1, |
|
| 1701 | + | }, |
|
| 1702 | + | parent, |
|
| 1703 | + | name: comp.to_string(), |
|
| 1704 | + | }); |
|
| 1705 | + | self.dir_nodes.insert(prefix.clone(), node); |
|
| 1706 | + | node |
|
| 1707 | + | } |
|
| 1708 | + | (None, None) => { |
|
| 1666 | 1709 | let node = self.new_node(&prefix, NodeKind::Dir); |
|
| 1667 | 1710 | self.tree_ops.push(TreeOp::AddNode { |
|
| 1668 | 1711 | node, |
|
| … | 75 unchanged lines |
||
| 1744 | 1787 | status: ChangeStatus::Renamed, |
|
| 1745 | 1788 | }); |
|
| 1746 | 1789 | Ok(()) |
|
| 1790 | + | } |
|
| 1791 | + | ||
| 1792 | + | /// For a Git import: a node the commit keeps is in one place, the one |
|
| 1793 | + | /// its tree names, so any other live placement it has in the base goes; |
|
| 1794 | + | /// and a node the tree cannot show at all (an orphan) goes too. Such a |
|
| 1795 | + | /// placement is out of sight, under a directory another line of history |
|
| 1796 | + | /// removed, where no diff of the tree would ever reach it (oxygen#30). |
|
| 1797 | + | fn retire_stray_placements(&mut self) { |
|
| 1798 | + | let mut retired: HashSet<(ChangeHash, u32)> = HashSet::new(); |
|
| 1799 | + | let mut placed: BTreeSet<NodeId> = BTreeSet::new(); |
|
| 1800 | + | for op in &self.tree_ops { |
|
| 1801 | + | match op { |
|
| 1802 | + | TreeOp::Move { node, old, .. } => { |
|
| 1803 | + | retired.insert((old.change, old.index)); |
|
| 1804 | + | placed.insert(*node); |
|
| 1805 | + | } |
|
| 1806 | + | TreeOp::RemoveNode { placement, .. } => { |
|
| 1807 | + | retired.insert((placement.change, placement.index)); |
|
| 1808 | + | } |
|
| 1809 | + | TreeOp::AddNode { .. } | TreeOp::SetMode { .. } => {} |
|
| 1810 | + | } |
|
| 1811 | + | } |
|
| 1812 | + | // Nodes kept where they were: what the base's tree view shows and |
|
| 1813 | + | // this change neither moves nor removes. |
|
| 1814 | + | let mut kept: HashMap<NodeId, (ChangeHash, u32)> = HashMap::new(); |
|
| 1815 | + | for e in self.base_snapshot.tree.entries.values() { |
|
| 1816 | + | if !retired.contains(&e.placement) { |
|
| 1817 | + | kept.insert(e.node, e.placement); |
|
| 1818 | + | } |
|
| 1819 | + | } |
|
| 1820 | + | // Only a node with several live placements can have a stray one, |
|
| 1821 | + | // and the tree view names each as a conflict. |
|
| 1822 | + | let multi: BTreeSet<NodeId> = self |
|
| 1823 | + | .base_snapshot |
|
| 1824 | + | .tree |
|
| 1825 | + | .conflicts |
|
| 1826 | + | .iter() |
|
| 1827 | + | .filter_map(|c| match c { |
|
| 1828 | + | crate::tree::TreeConflict::MultiPlacement { node, .. } => Some(*node), |
|
| 1829 | + | _ => None, |
|
| 1830 | + | }) |
|
| 1831 | + | .collect(); |
|
| 1832 | + | let mut extra = Vec::new(); |
|
| 1833 | + | // A node whose only placements are under a directory another line |
|
| 1834 | + | // removed (an orphan) is nowhere the tree can show, and Git's tree |
|
| 1835 | + | // has no such path: it goes. |
|
| 1836 | + | for c in &self.base_snapshot.tree.conflicts { |
|
| 1837 | + | let crate::tree::TreeConflict::Orphan { node, .. } = c else { |
|
| 1838 | + | continue; |
|
| 1839 | + | }; |
|
| 1840 | + | if kept.contains_key(node) || placed.contains(node) { |
|
| 1841 | + | continue; |
|
| 1842 | + | } |
|
| 1843 | + | for (k, _) in self.base.tree().live_placements(node) { |
|
| 1844 | + | if retired.insert(k) { |
|
| 1845 | + | extra.push(TreeOp::RemoveNode { |
|
| 1846 | + | node: *node, |
|
| 1847 | + | placement: op_ref(k), |
|
| 1848 | + | }); |
|
| 1849 | + | } |
|
| 1850 | + | } |
|
| 1851 | + | } |
|
| 1852 | + | for node in placed |
|
| 1853 | + | .iter() |
|
| 1854 | + | .chain(multi.iter().filter(|n| kept.contains_key(*n))) |
|
| 1855 | + | { |
|
| 1856 | + | for (k, _) in self.base.tree().live_placements(node) { |
|
| 1857 | + | if retired.contains(&k) || kept.get(node) == Some(&k) { |
|
| 1858 | + | continue; |
|
| 1859 | + | } |
|
| 1860 | + | retired.insert(k); |
|
| 1861 | + | extra.push(TreeOp::RemoveNode { |
|
| 1862 | + | node: *node, |
|
| 1863 | + | placement: op_ref(k), |
|
| 1864 | + | }); |
|
| 1865 | + | } |
|
| 1866 | + | } |
|
| 1867 | + | if !extra.is_empty() { |
|
| 1868 | + | self.resolves = true; |
|
| 1869 | + | self.tree_ops.extend(extra); |
|
| 1870 | + | } |
|
| 1747 | 1871 | } |
|
| 1748 | 1872 | ||
| 1749 | 1873 | fn remove_path(&mut self, path: &str, expected: &crate::state::FileState) { |
|
| … | 608 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +234 −0
| … | 5938 unchanged lines |
||
| 5939 | 5939 | assert_eq!(b.import_bundle(&bundle, false).unwrap(), vec![h]); |
|
| 5940 | 5940 | assert_eq!(b.store().get_change(&h).unwrap().message, "big"); |
|
| 5941 | 5941 | } |
|
| 5942 | + | ||
| 5943 | + | /// oxygen#30 (vscode): a directory deleted in one line of history and made |
|
| 5944 | + | /// again was a second node for one Git path, so a file moved into it on |
|
| 5945 | + | /// another line, where the first node still stood, ended up in two places. |
|
| 5946 | + | /// An import now brings the directory back rather than make a new one. |
|
| 5947 | + | #[test] |
|
| 5948 | + | fn git_import_brings_a_directory_back_rather_than_making_a_second() { |
|
| 5949 | + | hermetic(); |
|
| 5950 | + | if std::process::Command::new("git") |
|
| 5951 | + | .arg("--version") |
|
| 5952 | + | .output() |
|
| 5953 | + | .is_err() |
|
| 5954 | + | { |
|
| 5955 | + | eprintln!("git not installed; skipping"); |
|
| 5956 | + | return; |
|
| 5957 | + | } |
|
| 5958 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 5959 | + | let root = dir.path(); |
|
| 5960 | + | let g = root.join("src.git"); |
|
| 5961 | + | fs::create_dir_all(&g).unwrap(); |
|
| 5962 | + | let git = |args: &[&str]| -> String { |
|
| 5963 | + | let out = std::process::Command::new("git") |
|
| 5964 | + | .arg("-C") |
|
| 5965 | + | .arg(&g) |
|
| 5966 | + | .args([ |
|
| 5967 | + | "-c", |
|
| 5968 | + | "user.name=Grace", |
|
| 5969 | + | "-c", |
|
| 5970 | + | "[email protected]", |
|
| 5971 | + | "-c", |
|
| 5972 | + | "commit.gpgsign=false", |
|
| 5973 | + | ]) |
|
| 5974 | + | .args(args) |
|
| 5975 | + | .output() |
|
| 5976 | + | .unwrap(); |
|
| 5977 | + | assert!( |
|
| 5978 | + | out.status.success(), |
|
| 5979 | + | "git {args:?}: {}", |
|
| 5980 | + | String::from_utf8_lossy(&out.stderr) |
|
| 5981 | + | ); |
|
| 5982 | + | String::from_utf8_lossy(&out.stdout).trim().to_string() |
|
| 5983 | + | }; |
|
| 5984 | + | git(&["init", "-q", "-b", "main"]); |
|
| 5985 | + | write(&g, "d/a.txt", b"a\n"); |
|
| 5986 | + | write(&g, "c/x.txt", b"x\n"); |
|
| 5987 | + | git(&["add", "."]); |
|
| 5988 | + | git(&["commit", "-qm", "base"]); |
|
| 5989 | + | // One line: move x into d while d still stands. |
|
| 5990 | + | git(&["checkout", "-qb", "side"]); |
|
| 5991 | + | git(&["mv", "c/x.txt", "d/x.txt"]); |
|
| 5992 | + | git(&["commit", "-qm", "x into d"]); |
|
| 5993 | + | // The other: d goes, comes back, and x moves into it too. |
|
| 5994 | + | git(&["checkout", "-q", "main"]); |
|
| 5995 | + | git(&["rm", "-q", "d/a.txt"]); |
|
| 5996 | + | git(&["commit", "-qm", "d goes"]); |
|
| 5997 | + | write(&g, "d/b.txt", b"b\n"); |
|
| 5998 | + | git(&["add", "."]); |
|
| 5999 | + | git(&["commit", "-qm", "d comes back"]); |
|
| 6000 | + | git(&["mv", "c/x.txt", "d/x.txt"]); |
|
| 6001 | + | git(&["commit", "-qm", "x into d again"]); |
|
| 6002 | + | git(&["merge", "-q", "--no-edit", "side"]); |
|
| 6003 | + | ||
| 6004 | + | let repo = Repo::init(&root.join("o")).unwrap(); |
|
| 6005 | + | repo.import_git(&g, "main").unwrap(); |
|
| 6006 | + | let v = repo.verify_git_import(&g, "main").unwrap(); |
|
| 6007 | + | assert!(v.mismatches.is_empty(), "{:?}", v.mismatches); |
|
| 6008 | + | let st = repo |
|
| 6009 | + | .load_state_history(&repo.bookmark_heads().unwrap()) |
|
| 6010 | + | .unwrap(); |
|
| 6011 | + | let view = st.tree().view(); |
|
| 6012 | + | assert!(view.conflicts.is_empty(), "{:?}", view.conflicts); |
|
| 6013 | + | // The directory is the one the first commit made. |
|
| 6014 | + | // Commits made in one second tie on time: find the first by its message. |
|
| 6015 | + | let first = repo |
|
| 6016 | + | .log() |
|
| 6017 | + | .unwrap() |
|
| 6018 | + | .into_iter() |
|
| 6019 | + | .find(|e| e.change.message.starts_with("base")) |
|
| 6020 | + | .unwrap(); |
|
| 6021 | + | let base = repo |
|
| 6022 | + | .load_state_history(&[first.hash]) |
|
| 6023 | + | .unwrap() |
|
| 6024 | + | .tree() |
|
| 6025 | + | .view(); |
|
| 6026 | + | assert_eq!(view.entries["d"].node, base.entries["d"].node); |
|
| 6027 | + | } |
|
| 6028 | + | ||
| 6029 | + | /// oxygen#30, the rest: a placement left under a directory another line |
|
| 6030 | + | /// removed is out of sight, so no diff reaches it; the merge that settles |
|
| 6031 | + | /// where the file is retires it. |
|
| 6032 | + | #[test] |
|
| 6033 | + | fn git_import_merge_retires_a_placement_under_a_removed_directory() { |
|
| 6034 | + | hermetic(); |
|
| 6035 | + | if std::process::Command::new("git") |
|
| 6036 | + | .arg("--version") |
|
| 6037 | + | .output() |
|
| 6038 | + | .is_err() |
|
| 6039 | + | { |
|
| 6040 | + | eprintln!("git not installed; skipping"); |
|
| 6041 | + | return; |
|
| 6042 | + | } |
|
| 6043 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 6044 | + | let root = dir.path(); |
|
| 6045 | + | let g = root.join("src.git"); |
|
| 6046 | + | fs::create_dir_all(&g).unwrap(); |
|
| 6047 | + | let run = |args: &[&str]| { |
|
| 6048 | + | std::process::Command::new("git") |
|
| 6049 | + | .arg("-C") |
|
| 6050 | + | .arg(&g) |
|
| 6051 | + | .args([ |
|
| 6052 | + | "-c", |
|
| 6053 | + | "user.name=Grace", |
|
| 6054 | + | "-c", |
|
| 6055 | + | "[email protected]", |
|
| 6056 | + | "-c", |
|
| 6057 | + | "commit.gpgsign=false", |
|
| 6058 | + | ]) |
|
| 6059 | + | .args(args) |
|
| 6060 | + | .output() |
|
| 6061 | + | .unwrap() |
|
| 6062 | + | }; |
|
| 6063 | + | let git = |args: &[&str]| { |
|
| 6064 | + | let out = run(args); |
|
| 6065 | + | assert!( |
|
| 6066 | + | out.status.success(), |
|
| 6067 | + | "git {args:?}: {}", |
|
| 6068 | + | String::from_utf8_lossy(&out.stderr) |
|
| 6069 | + | ); |
|
| 6070 | + | }; |
|
| 6071 | + | git(&["init", "-q", "-b", "main"]); |
|
| 6072 | + | write(&g, "c/x.txt", b"x\n"); |
|
| 6073 | + | write(&g, "keep.txt", b"k\n"); |
|
| 6074 | + | git(&["add", "."]); |
|
| 6075 | + | git(&["commit", "-qm", "base"]); |
|
| 6076 | + | // One line renames the file within c; the other moves it out of c, |
|
| 6077 | + | // which goes. |
|
| 6078 | + | git(&["checkout", "-qb", "side"]); |
|
| 6079 | + | git(&["mv", "c/x.txt", "c/y.txt"]); |
|
| 6080 | + | git(&["commit", "-qm", "rename within c"]); |
|
| 6081 | + | git(&["checkout", "-q", "main"]); |
|
| 6082 | + | fs::create_dir_all(g.join("n")).unwrap(); |
|
| 6083 | + | git(&["mv", "c/x.txt", "n/x.txt"]); |
|
| 6084 | + | git(&["commit", "-qm", "move out of c"]); |
|
| 6085 | + | // Git cannot merge the two renames; the merge keeps n/x.txt. |
|
| 6086 | + | let _ = run(&["merge", "-q", "side"]); |
|
| 6087 | + | let _ = run(&[ |
|
| 6088 | + | "rm", |
|
| 6089 | + | "-q", |
|
| 6090 | + | "--cached", |
|
| 6091 | + | "--ignore-unmatch", |
|
| 6092 | + | "c/y.txt", |
|
| 6093 | + | "c/x.txt", |
|
| 6094 | + | ]); |
|
| 6095 | + | let _ = std::fs::remove_dir_all(g.join("c")); |
|
| 6096 | + | write(&g, "n/x.txt", b"x\n"); |
|
| 6097 | + | git(&["add", "-A"]); |
|
| 6098 | + | git(&["commit", "-qm", "merge: keep n/x.txt"]); |
|
| 6099 | + | ||
| 6100 | + | let repo = Repo::init(&root.join("o")).unwrap(); |
|
| 6101 | + | repo.import_git(&g, "main").unwrap(); |
|
| 6102 | + | let v = repo.verify_git_import(&g, "main").unwrap(); |
|
| 6103 | + | assert!(v.mismatches.is_empty(), "{:?}", v.mismatches); |
|
| 6104 | + | let st = repo |
|
| 6105 | + | .load_state_history(&repo.bookmark_heads().unwrap()) |
|
| 6106 | + | .unwrap(); |
|
| 6107 | + | let view = st.tree().view(); |
|
| 6108 | + | assert!(view.conflicts.is_empty(), "{:?}", view.conflicts); |
|
| 6109 | + | assert!(view.entries.contains_key("n/x.txt")); |
|
| 6110 | + | } |
|
| 6111 | + | ||
| 6112 | + | /// oxygen#30, orphans: a file added on one line into a directory the other |
|
| 6113 | + | /// line removed is placed where the tree cannot show it; Git's merge keeps |
|
| 6114 | + | /// the file, and the import's merge leaves exactly one of it. |
|
| 6115 | + | #[test] |
|
| 6116 | + | fn git_import_merge_leaves_no_orphan_behind() { |
|
| 6117 | + | hermetic(); |
|
| 6118 | + | if std::process::Command::new("git") |
|
| 6119 | + | .arg("--version") |
|
| 6120 | + | .output() |
|
| 6121 | + | .is_err() |
|
| 6122 | + | { |
|
| 6123 | + | eprintln!("git not installed; skipping"); |
|
| 6124 | + | return; |
|
| 6125 | + | } |
|
| 6126 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 6127 | + | let root = dir.path(); |
|
| 6128 | + | let g = root.join("src.git"); |
|
| 6129 | + | fs::create_dir_all(&g).unwrap(); |
|
| 6130 | + | let git = |args: &[&str]| { |
|
| 6131 | + | let out = std::process::Command::new("git") |
|
| 6132 | + | .arg("-C") |
|
| 6133 | + | .arg(&g) |
|
| 6134 | + | .args([ |
|
| 6135 | + | "-c", |
|
| 6136 | + | "user.name=Grace", |
|
| 6137 | + | "-c", |
|
| 6138 | + | "[email protected]", |
|
| 6139 | + | "-c", |
|
| 6140 | + | "commit.gpgsign=false", |
|
| 6141 | + | ]) |
|
| 6142 | + | .args(args) |
|
| 6143 | + | .output() |
|
| 6144 | + | .unwrap(); |
|
| 6145 | + | assert!( |
|
| 6146 | + | out.status.success(), |
|
| 6147 | + | "git {args:?}: {}", |
|
| 6148 | + | String::from_utf8_lossy(&out.stderr) |
|
| 6149 | + | ); |
|
| 6150 | + | }; |
|
| 6151 | + | git(&["init", "-q", "-b", "main"]); |
|
| 6152 | + | write(&g, "d/f.txt", b"f\n"); |
|
| 6153 | + | write(&g, "keep.txt", b"k\n"); |
|
| 6154 | + | git(&["add", "."]); |
|
| 6155 | + | git(&["commit", "-qm", "base"]); |
|
| 6156 | + | git(&["checkout", "-qb", "side"]); |
|
| 6157 | + | write(&g, "d/new.txt", b"new\n"); |
|
| 6158 | + | git(&["add", "."]); |
|
| 6159 | + | git(&["commit", "-qm", "add into d"]); |
|
| 6160 | + | git(&["checkout", "-q", "main"]); |
|
| 6161 | + | git(&["rm", "-q", "d/f.txt"]); |
|
| 6162 | + | git(&["commit", "-qm", "d goes"]); |
|
| 6163 | + | git(&["merge", "-q", "--no-edit", "side"]); |
|
| 6164 | + | ||
| 6165 | + | let repo = Repo::init(&root.join("o")).unwrap(); |
|
| 6166 | + | repo.import_git(&g, "main").unwrap(); |
|
| 6167 | + | let v = repo.verify_git_import(&g, "main").unwrap(); |
|
| 6168 | + | assert!(v.mismatches.is_empty(), "{:?}", v.mismatches); |
|
| 6169 | + | let st = repo |
|
| 6170 | + | .load_state_history(&repo.bookmark_heads().unwrap()) |
|
| 6171 | + | .unwrap(); |
|
| 6172 | + | let view = st.tree().view(); |
|
| 6173 | + | assert!(view.conflicts.is_empty(), "{:?}", view.conflicts); |
|
| 6174 | + | assert!(view.entries.contains_key("d/new.txt")); |
|
| 6175 | + | } |
modified crates/oxy-core/src/tree.rs +42 −0
| … | 150 unchanged lines |
||
| 151 | 151 | } |
|
| 152 | 152 | } |
|
| 153 | 153 | ||
| 154 | + | /// Directories no longer placed anywhere, by the (parent, name) they |
|
| 155 | + | /// were last placed at, with that placement: what an import can bring |
|
| 156 | + | /// back rather than make a second node for one Git path. |
|
| 157 | + | pub fn dead_dirs(&self) -> HashMap<(NodeId, String), (NodeId, OpKey)> { |
|
| 158 | + | let mut out: HashMap<(NodeId, String), (NodeId, OpKey)> = HashMap::new(); |
|
| 159 | + | for (k, p) in &self.placements { |
|
| 160 | + | if self.kind(&p.node) != Some(NodeKind::Dir) |
|
| 161 | + | || self.placement_live(k) |
|
| 162 | + | || !self.live_placements(&p.node).is_empty() |
|
| 163 | + | { |
|
| 164 | + | continue; |
|
| 165 | + | } |
|
| 166 | + | let slot = out |
|
| 167 | + | .entry((p.parent, p.name.clone())) |
|
| 168 | + | .or_insert((p.node, *k)); |
|
| 169 | + | // The newest placement, by key: the same answer in any order. |
|
| 170 | + | if *k > slot.1 { |
|
| 171 | + | *slot = (p.node, *k); |
|
| 172 | + | } |
|
| 173 | + | } |
|
| 174 | + | out |
|
| 175 | + | } |
|
| 176 | + | ||
| 177 | + | /// Every placement `node` ever had, live or not, with the changes that |
|
| 178 | + | /// retired it (none while live). |
|
| 179 | + | pub fn placement_history(&self, node: &NodeId) -> Vec<(OpKey, &Placement, Vec<ChangeHash>)> { |
|
| 180 | + | self.placements_of |
|
| 181 | + | .get(node) |
|
| 182 | + | .into_iter() |
|
| 183 | + | .flatten() |
|
| 184 | + | .filter_map(|k| { |
|
| 185 | + | let p = self.placements.get(k)?; |
|
| 186 | + | let by = self |
|
| 187 | + | .deleted_placements |
|
| 188 | + | .get(k) |
|
| 189 | + | .map(|d| d.iter().copied().collect()) |
|
| 190 | + | .unwrap_or_default(); |
|
| 191 | + | Some((*k, p, by)) |
|
| 192 | + | }) |
|
| 193 | + | .collect() |
|
| 194 | + | } |
|
| 195 | + | ||
| 154 | 196 | fn placement_live(&self, k: &OpKey) -> bool { |
|
| 155 | 197 | self.deleted_placements.get(k).is_none_or(|d| d.is_empty()) |
|
| 156 | 198 | } |
|
| … | 677 unchanged lines |
modified docs/backlog-overnight-3.md +8 −1
| … | 131 unchanged lines |
||
| 132 | 132 | ||
| 133 | 133 | ## History at scale |
|
| 134 | 134 | ||
| 135 | - | - [ ] H1 **Importer name conflict** (oxygen#30): the 5 000-commit |
|
| 135 | + | - [x] H1 **Importer name conflict** (oxygen#30): the 5 000-commit |
|
| 136 | 136 | vscode import differs from Git in 51 files. Fix the cause and add a |
|
| 137 | 137 | test. `oxy git verify` must be clean. |
|
| 138 | 138 | - [ ] H2 **Import memory**: drop graphs untouched by recent commits and |
|
| … | 55 unchanged lines |
||
| 194 | 194 | - a local fetch moves changes as stored. |
|
| 195 | 195 | - **Hub and runner**: re-pinned to oxygen 397d9f98 and deployed (oxygenhub#58, a6f006ba; oxygenflow#15, 630b7fd6). The hub reads metadata or skeletons where it lists changes. Checked: /health, and the log, file history, tree and file pages. |
|
| 196 | 196 | - **Note**: my pushes start Flow jobs on Earth, so timings under load are marked in benchmarks.md. |
|
| 197 | + | - 2026-09-30 **H1 done** (oxygen#30). Three causes, each from Git having no identity for a directory: |
|
| 198 | + | - a directory deleted and made again became a second node; |
|
| 199 | + | - a placement under a removed directory was out of reach; |
|
| 200 | + | - orphans could not be seen. |
|
| 201 | + | - The fix: an import revives the directory, and a merge retires a kept node's other placements and every orphan. There is a Git-built test for each cause; each reproduces the vscode symptom without its fix. Spec §23. |
|
| 202 | + | - The vscode slice now verifies: all 1 747 commits and 3.3 million files. |
|
| 203 | + | - `oxy git verify` is cached by graph key and blob: 115 s instead of 1 400 s. |
|
| 197 | 204 |
modified docs/benchmarks.md +26 −0
| … | 428 unchanged lines |
||
| 429 | 429 | once, compressed on every core, and refuses a change whose text blob did |
|
| 430 | 430 | not come. |
|
| 431 | 431 | ||
| 432 | + | ### Git import: the vscode slice (2026-09-30, oxygen#30) |
|
| 433 | + | ||
| 434 | + | The 1 747-commit vscode slice (to merge 0babb4299131) had one file as a |
|
| 435 | + | name conflict at every commit from that merge on: 51 verify differences. |
|
| 436 | + | There were three causes, all from Git having no identity for a directory: |
|
| 437 | + | - a directory deleted on one line and made again got a second node for |
|
| 438 | + | the same path; |
|
| 439 | + | - a placement left under a directory another line removed was out of |
|
| 440 | + | sight, so no merge could retire it; |
|
| 441 | + | - a file whose only placements were under removed directories (an |
|
| 442 | + | orphan) could not be seen either. |
|
| 443 | + | ||
| 444 | + | Now an import brings a directory back rather than making a second node, |
|
| 445 | + | and a merge retires a kept node's other placements and every orphan. |
|
| 446 | + | ||
| 447 | + | `oxy git verify` built and rendered every file of every commit and read |
|
| 448 | + | every Git blob; it now loads each commit's history and builds only the |
|
| 449 | + | files whose graph (by key) or blob it has not already found equal to |
|
| 450 | + | that Git blob. |
|
| 451 | + | ||
| 452 | + | | vscode slice, 1 747 commits | Before | After | |
|
| 453 | + | |---|---:|---:| |
|
| 454 | + | | Import | 128.6 s | 132.1 s | |
|
| 455 | + | | Differences | 51 | none: 3 327 291 files compared | |
|
| 456 | + | | Verify | 1 369 to 1 444 s, 832 MB | 114.6 s, 441 MB | |
|
| 457 | + |
modified docs/spec.md +2 −1
| … | 857 unchanged lines |
||
| 858 | 858 | `oxy git import <repo>` and `oxy git export <repo>` provide a lossless-enough bridge; `oxy git fetch/push` operate against a Git remote directly. |
|
| 859 | 859 | ||
| 860 | 860 | - **Import.** Each Git commit becomes one change whose edits are the diff between the union of its parents' states and its tree, so a merge commit becomes a resolution exactly when the parents conflict in the change model, and an ordinary change otherwise. The commit's author and hash are recorded as `Git-Author` and `Git-Commit` trailers; the author key is the zero KeyId, since nobody can sign on the original author's behalf. Submodule entries are skipped. Renames are detected on import exactly as for a snapshot (§7.1, similarity ≥ 50%), then tracked exactly. The Git commit's `Change-Id` trailer, if present and 128 bits, becomes the ChangeId; otherwise one is derived as BLAKE3-128 of the Git commit hash, so re-import is idempotent. New files' and directories' NodeIds are derived too, as BLAKE3-128 of the commit hash, the node kind and the path, so importing the same history twice, anywhere, gives the same changes. Note that a change's own closure is what it depends on, not the Git snapshot: the imported bookmark's state equals the Git tree, but checking out an individual imported change yields only the files it and its dependencies touch. |
|
| 861 | + | - **Directories.** Git gives a directory no identity: its path is all it is. An import therefore never makes a second node for a directory path: when a commit needs a directory where the parent's state has none, and the parent's history has a directory last placed at that (parent, name) and now placed nowhere, the change brings that node back with a `Move` from its last placement (the newest by operation key when there are several). Otherwise lines of history that placed files in the directory before it was deleted and after it came back would place them in two different directories, a conflict Git never had. Likewise a node the commit keeps is in exactly the one place its tree names: any other live placement it has in the parents' union (one left under a directory another line removed is out of sight, and no diff of the tree would reach it) is retired by the commit's change, and so is every live placement of a node the union cannot show at all (an orphan, whose only placements are under removed directories): Git's tree has no path for it. |
|
| 861 | 862 | - **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. |
|
| 862 | 863 | - **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. |
|
| 863 | 864 | - **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. |
|
| … | 112 unchanged lines |
||
| 976 | 977 | ||
| 977 | 978 | --- |
|
| 978 | 979 | ||
| 979 | - | *Changelog.* Draft 0.8r (2026-09-29): graph keys at commit, lazy text in history-only loads, and a clean `status` from the manifest (§12.4); a parent checkout never moves a subrepo's bookmark backwards, and the pin is read from the nested working copy (§7.8); `bookmark create --upto/--only` (§10.1); the working-copy lock covers every command that writes the working copy; a workspace changed by two concurrent operations is an error (§12.1). Draft 0.8q (2026-09-29): change format version 2 stores large inserts as text blobs, so a change does not grow with the files it adds (§6.1, §7.4); no object may exceed 1 GiB (§13.2). Draft 0.8p (2026-09-29): Git import derives new NodeIds from the commit, making imports deterministic (§23). Draft 0.8o (2026-09-29): execution receipts, the `receipt` object and attestation (§14.6). Draft 0.8n (2026-09-29): line history, `oxy log -L` (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11). |
|
| 980 | + | *Changelog.* Draft 0.8r (2026-09-29): an import brings a deleted directory back rather than making a second node for its path, and retires a kept node's other placements (§23); graph keys at commit, lazy text in history-only loads, and a clean `status` from the manifest (§12.4); a parent checkout never moves a subrepo's bookmark backwards, and the pin is read from the nested working copy (§7.8); `bookmark create --upto/--only` (§10.1); the working-copy lock covers every command that writes the working copy; a workspace changed by two concurrent operations is an error (§12.1). Draft 0.8q (2026-09-29): change format version 2 stores large inserts as text blobs, so a change does not grow with the files it adds (§6.1, §7.4); no object may exceed 1 GiB (§13.2). Draft 0.8p (2026-09-29): Git import derives new NodeIds from the commit, making imports deterministic (§23). Draft 0.8o (2026-09-29): execution receipts, the `receipt` object and attestation (§14.6). Draft 0.8n (2026-09-29): line history, `oxy log -L` (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11). |
|
| 980 | 981 | ||
| 981 | 982 | Draft 0.8i (2026-09-28): portability rules for exec bits and path names (§7.7); 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. |
|
| 982 | 983 | ||
| … | 61 unchanged lines |