OXYGENHUB

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