OXYGENHUB

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