A kept order takes no line that arrives deleted 7eac74abc586
A merge in an import adds its other side's changes newest first, so a change can apply before the one whose line it deletes: the line then arrives dead. The kept order of a file graph took it in as live, and the merge's own change was diffed against that wrong rendering (two files wrong from the 11 689th vscode commit on). An order is now spliced only when every line the change inserts is live. A test applies a deletion before its insertion. oxy git verify --last N rebuilds only the last N commits, following the others, to look again at where an import went wrong: 25 s rather than 20 minutes on an 11 690-commit slice. refs #25
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-30 09:35 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
7eac74abc5869609414ae95ac9c0385f3c8894d7b64dd1cf41045f4246b2257d- Depends on
- 598009a75f6c 62794ecc7f74 79ab8528ff62 83e0677b07fe dbf06d342e1d e83354cdac22 fce44ae9ad29 ff0af1ee299d
- Depended on by
- 6f4226bfb87f 8b7986dd3cb0 f0406c3b40ed
- 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
- 19 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified crates/oxy-core/src/graph.rs +52 −5
| … | 101 unchanged lines |
||
| 102 | 102 | /// apply; `None` where that is not certain to be the graph's only |
|
| 103 | 103 | /// order: an anchor not in the order, live lines between an insert's |
|
| 104 | 104 | /// anchors, or two inserts with nothing kept between them. |
|
| 105 | - | pub fn splice( |
|
| 105 | + | fn splice( |
|
| 106 | 106 | &self, |
|
| 107 | 107 | h: ChangeHash, |
|
| 108 | 108 | inserts: &[(Vertex, Vertex, u32, u32)], |
|
| … | 465 unchanged lines |
||
| 574 | 574 | self.order.take() |
|
| 575 | 575 | } |
|
| 576 | 576 | ||
| 577 | - | /// Keep `order`, spliced by the edits just applied. |
|
| 578 | - | pub fn keep_order(&mut self, order: Option<std::sync::Arc<Order>>) { |
|
| 579 | - | *self.order.0.get_mut().unwrap_or_else(|e| e.into_inner()) = order; |
|
| 577 | + | /// Keep `order` (taken before change `h`'s edits applied) spliced by |
|
| 578 | + | /// them: `inserts` (after, before, first index, count) and `deleted`. |
|
| 579 | + | /// Changes may apply in any order, so a line may arrive already |
|
| 580 | + | /// deleted by a change applied before it: then the order is dropped. |
|
| 581 | + | pub fn keep_spliced( |
|
| 582 | + | &mut self, |
|
| 583 | + | order: std::sync::Arc<Order>, |
|
| 584 | + | h: ChangeHash, |
|
| 585 | + | inserts: &[(Vertex, Vertex, u32, u32)], |
|
| 586 | + | deleted: &[AtomKey], |
|
| 587 | + | ) { |
|
| 588 | + | let live = inserts |
|
| 589 | + | .iter() |
|
| 590 | + | .all(|&(_, _, first, count)| (first..first + count).all(|i| self.is_live(&(h, i)))); |
|
| 591 | + | let spliced = if live { |
|
| 592 | + | order.splice(h, inserts, deleted) |
|
| 593 | + | } else { |
|
| 594 | + | None |
|
| 595 | + | }; |
|
| 596 | + | *self.order.0.get_mut().unwrap_or_else(|e| e.into_inner()) = spliced; |
|
| 580 | 597 | } |
|
| 581 | 598 | ||
| 582 | 599 | /// The live atoms in order when, with the dead ones contracted away, |
|
| … | 623 unchanged lines |
||
| 1206 | 1223 | ); |
|
| 1207 | 1224 | } |
|
| 1208 | 1225 | if let Some(o) = order { |
|
| 1209 | - | g.keep_order(o.splice(h, &runs, deleted)); |
|
| 1226 | + | g.keep_spliced(o, h, &runs, deleted); |
|
| 1210 | 1227 | } |
|
| 1211 | 1228 | } |
|
| 1212 | 1229 | ||
| … | 86 unchanged lines |
||
| 1299 | 1316 | spliced > 100 && conflicted > 0, |
|
| 1300 | 1317 | "{spliced} spliced, {conflicted} conflicted" |
|
| 1301 | 1318 | ); |
|
| 1319 | + | } |
|
| 1320 | + | ||
| 1321 | + | /// A change applied before the one whose line it deletes (as a merge |
|
| 1322 | + | /// adds the other side, newest first): the line arrives dead, and the |
|
| 1323 | + | /// kept order must not take it in. |
|
| 1324 | + | #[test] |
|
| 1325 | + | fn the_kept_order_takes_no_line_that_arrives_deleted() { |
|
| 1326 | + | let mut deps = Deps(HashMap::default()); |
|
| 1327 | + | let mut g = FileGraph::new(); |
|
| 1328 | + | g.insert( |
|
| 1329 | + | h(1), |
|
| 1330 | + | 0, |
|
| 1331 | + | Vertex::Start, |
|
| 1332 | + | Vertex::End, |
|
| 1333 | + | &lines(&["a\n", "b\n", "c\n"]), |
|
| 1334 | + | ); |
|
| 1335 | + | // A deletion, so the order is kept rather than walked as a chain. |
|
| 1336 | + | apply_spliced(&mut g, h(2), &[], &[(h(1), 2)]); |
|
| 1337 | + | deps.0.insert(h(2), vec![h(1)]); |
|
| 1338 | + | let _ = g.materialize(&deps); |
|
| 1339 | + | assert!(g.order.get().is_some()); |
|
| 1340 | + | let (a, b) = (Vertex::Atom((h(1), 0)), Vertex::Atom((h(1), 1))); |
|
| 1341 | + | // h(3) inserts x between a and b; h(4) deletes it. h(4) first. |
|
| 1342 | + | deps.0.insert(h(3), vec![h(2)]); |
|
| 1343 | + | deps.0.insert(h(4), vec![h(3)]); |
|
| 1344 | + | apply_spliced(&mut g, h(4), &[], &[(h(3), 0)]); |
|
| 1345 | + | apply_spliced(&mut g, h(3), &[(a, b, 1)], &[]); |
|
| 1346 | + | let m = g.materialize(&deps); |
|
| 1347 | + | assert_eq!(m, g.materialize_general(&deps)); |
|
| 1348 | + | assert_eq!(text(&g, &m), "a\nb\n"); |
|
| 1302 | 1349 | } |
|
| 1303 | 1350 | ||
| 1304 | 1351 | /// How long rendering a long-lived file takes (run by hand, in release). |
|
| … | 495 unchanged lines |
modified crates/oxy-core/src/repo/git.rs +18 −0
| … | 1363 unchanged lines |
||
| 1364 | 1364 | #[derive(Debug, Clone, Default, PartialEq, Eq)] |
|
| 1365 | 1365 | pub struct GitVerifyReport { |
|
| 1366 | 1366 | pub commits: usize, |
|
| 1367 | + | /// Commits followed but not rebuilt (all but the last few, when asked). |
|
| 1368 | + | pub skipped: usize, |
|
| 1367 | 1369 | pub files_compared: usize, |
|
| 1368 | 1370 | /// `(commit, what differs)`; empty when every commit's tree is reproduced. |
|
| 1369 | 1371 | pub mismatches: Vec<(String, String)>, |
|
| … | 6 unchanged lines |
||
| 1376 | 1378 | /// for byte, with the same executable bits, symlink targets and |
|
| 1377 | 1379 | /// submodule pins. Reads only; stops listing after 50 differences. |
|
| 1378 | 1380 | pub fn verify_git_import(&self, git_dir: &Path, branch: &str) -> Result<GitVerifyReport> { |
|
| 1381 | + | self.verify_git_import_last(git_dir, branch, None) |
|
| 1382 | + | } |
|
| 1383 | + | ||
| 1384 | + | /// [`Repo::verify_git_import`] of the last `last` commits only (all |
|
| 1385 | + | /// when `None`): earlier ones are only followed, not rebuilt, for |
|
| 1386 | + | /// looking again at where an import went wrong. |
|
| 1387 | + | pub fn verify_git_import_last( |
|
| 1388 | + | &self, |
|
| 1389 | + | git_dir: &Path, |
|
| 1390 | + | branch: &str, |
|
| 1391 | + | last: Option<usize>, |
|
| 1392 | + | ) -> Result<GitVerifyReport> { |
|
| 1379 | 1393 | let commits = list_commits(git_dir, branch)?; |
|
| 1380 | 1394 | let imported = self.imported_commits()?; |
|
| 1381 | 1395 | let mut cat = CatFile::start(git_dir)?; |
|
| … | 26 unchanged lines |
||
| 1408 | 1422 | closure.insert(c.sha.clone(), set.clone()); |
|
| 1409 | 1423 | if rep.mismatches.len() >= 50 { |
|
| 1410 | 1424 | break; |
|
| 1425 | + | } |
|
| 1426 | + | if last.is_some_and(|n| rep.commits + n <= commits.len()) { |
|
| 1427 | + | rep.skipped += 1; |
|
| 1428 | + | continue; |
|
| 1411 | 1429 | } |
|
| 1412 | 1430 | let heads: Vec<ChangeHash> = set.iter().copied().collect(); |
|
| 1413 | 1431 | // History only: a file whose graph (by key) or blob was already |
|
| … | 175 unchanged lines |
modified crates/oxy-core/src/state.rs +1 −1
| … | 228 unchanged lines |
||
| 229 | 229 | } |
|
| 230 | 230 | } |
|
| 231 | 231 | if let Some(o) = order { |
|
| 232 | - | g.keep_order(o.splice(h, &inserts, &deleted)); |
|
| 232 | + | g.keep_spliced(o, h, &inserts, &deleted); |
|
| 233 | 233 | } |
|
| 234 | 234 | } |
|
| 235 | 235 | ||
| … | 1775 unchanged lines |
modified docs/spec.md +1 −1
| … | 862 unchanged lines |
||
| 863 | 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. |
|
| 864 | 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. |
|
| 865 | 865 | - **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. |
|
| 866 | - | - **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). |
|
| 866 | + | - **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). `--last N` rebuilds only the last N commits, following the others, to look again at where an import went wrong. |
|
| 867 | 867 | ||
| 868 | 868 | ## 24. Command-line interface |
|
| 869 | 869 | ||
| … | 175 unchanged lines |
modified src/cli/mod.rs +10 −2
| … | 806 unchanged lines |
||
| 807 | 807 | /// Branch that was imported |
|
| 808 | 808 | #[arg(long, default_value = "main")] |
|
| 809 | 809 | branch: String, |
|
| 810 | + | /// Rebuild only the last N commits (the others are followed, not checked) |
|
| 811 | + | #[arg(long, value_name = "N")] |
|
| 812 | + | last: Option<usize>, |
|
| 810 | 813 | }, |
|
| 811 | 814 | /// Import a Git branch into a new Oxygen repository at DEST |
|
| 812 | 815 | Import { |
|
| … | 333 unchanged lines |
||
| 1146 | 1149 | }, |
|
| 1147 | 1150 | } => sync::git_import(&git_dir, &dest, &branch), |
|
| 1148 | 1151 | Cmd::Git { |
|
| 1149 | - | cmd: GitCmd::Verify { git_dir, branch }, |
|
| 1150 | - | } => sync::git_verify(&git_dir, &branch), |
|
| 1152 | + | cmd: |
|
| 1153 | + | GitCmd::Verify { |
|
| 1154 | + | git_dir, |
|
| 1155 | + | branch, |
|
| 1156 | + | last, |
|
| 1157 | + | }, |
|
| 1158 | + | } => sync::git_verify(&git_dir, &branch, last), |
|
| 1151 | 1159 | Cmd::Policy { cmd } => match cmd { |
|
| 1152 | 1160 | PolicyCmd::Init => identity::policy_init(), |
|
| 1153 | 1161 | PolicyCmd::Show { json } => identity::policy_show(json), |
|
| … | 67 unchanged lines |
modified src/cli/sync.rs +18 −8
| … | 501 unchanged lines |
||
| 502 | 502 | } |
|
| 503 | 503 | ||
| 504 | 504 | /// `oxy git verify`: exit 1 if any commit's tree is not reproduced. |
|
| 505 | - | pub fn git_verify(git_dir: &std::path::Path, branch: &str) -> Result<i32> { |
|
| 505 | + | pub fn git_verify(git_dir: &std::path::Path, branch: &str, last: Option<usize>) -> Result<i32> { |
|
| 506 | 506 | let repo = open()?; |
|
| 507 | 507 | let started = std::time::Instant::now(); |
|
| 508 | 508 | let sp = ui::spinner(&format!( |
|
| 509 | 509 | "Rebuilding every commit of {} {branch}…", |
|
| 510 | 510 | git_dir.display() |
|
| 511 | 511 | )); |
|
| 512 | - | let r = repo.verify_git_import(git_dir, branch)?; |
|
| 512 | + | let r = repo.verify_git_import_last(git_dir, branch, last)?; |
|
| 513 | 513 | sp.clear(); |
|
| 514 | 514 | for (_, what) in &r.mismatches { |
|
| 515 | 515 | println!("{} {what}", ui::err(ui::glyph("✖", "x"))); |
|
| … | 1 unchanged lines |
||
| 517 | 517 | if r.mismatches.is_empty() { |
|
| 518 | 518 | println!( |
|
| 519 | 519 | "{}", |
|
| 520 | - | ui::done(format!( |
|
| 521 | - | "Every commit reproduced: {} commits, {} files compared, in {:.1}s", |
|
| 522 | - | r.commits, |
|
| 523 | - | r.files_compared, |
|
| 524 | - | started.elapsed().as_secs_f64() |
|
| 525 | - | )) |
|
| 520 | + | ui::done(if r.skipped > 0 { |
|
| 521 | + | format!( |
|
| 522 | + | "The last {} commits reproduced ({} followed), {} files compared, in {:.1}s", |
|
| 523 | + | r.commits - r.skipped, |
|
| 524 | + | r.skipped, |
|
| 525 | + | r.files_compared, |
|
| 526 | + | started.elapsed().as_secs_f64() |
|
| 527 | + | ) |
|
| 528 | + | } else { |
|
| 529 | + | format!( |
|
| 530 | + | "Every commit reproduced: {} commits, {} files compared, in {:.1}s", |
|
| 531 | + | r.commits, |
|
| 532 | + | r.files_compared, |
|
| 533 | + | started.elapsed().as_secs_f64() |
|
| 534 | + | ) |
|
| 535 | + | }) |
|
| 526 | 536 | ); |
|
| 527 | 537 | Ok(0) |
|
| 528 | 538 | } else { |
|
| … | 224 unchanged lines |