OXYGENHUB

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 +110 −68
…
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() {
1149 -
                        continue;
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 {
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