OXYGENHUB

Git import costs what each commit changes, not the whole tree 958fb27dda7c

Found importing microsoft/vscode (P5c). Files with the same Git blob and
mode as in the first parent's tree are marked unchanged and neither
rendered, read nor compared; paths a merge's other parents touched are
excluded. States are kept for first-parent children (up to 8; the last
child takes it without a clone) instead of being rebuilt from history.
Needed directories are one set of ancestors instead of a scan per
directory, which snapshot and commit share. OXY_TIMINGS reports the
import's phases. 5 000 commits: over 80 minutes before, 412 s now; every
commit verifies. A test covers a -s ours merge whose other side edited a
file.

refs #25
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 10:23 UTC
Signature
✔ signed by Andy McCall
Hash
958fb27dda7cb4bdc41c444b964c3cbcd23887cf3862539ab76d85a4ca010949
Depends on
03571027793e 1d360c91a289 30ea9c1e70fa 4dee277c327a 62794ecc7f74 6e723b81f5b9 7421309f5372 75167672ebcd 79ab8528ff62 8ab265dc4a51 982864068a59 c3069537d7c5
Depended on by
fd280228bdc2 fb5b6181618a e83354cdac22 2306a4d24779 b493d993a9df 2951f8023f40 dfb23a3a8cfa 314db9b4f3f8
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
27 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified crates/oxy-core/src/repo/git.rs +108 −21
…
593 unchanged lines
594 594
        let mut known = self.git_known(git_dir)?;
595 595
        let old_heads = self.bookmark_heads()?;
596 596
        let mut new_changes: Vec<Change> = Vec::new();
597 -
        // The state after the most recently imported commit, with its Git
598 -
        // tree, reused when the next commit's first parent is that commit.
599 -
        let mut last: Option<(String, State, BTreeMap<String, (String, String)>)> = None;
597 +
        // The state and Git tree of imported commits whose first-parent
598 +
        // children are still to come, so a child starts from its parent's
599 +
        // state instead of rebuilding it from history. The last child takes
600 +
        // it; earlier ones clone it. At most `MAX_KEPT` are kept (a later
601 +
        // child of an evicted one rebuilds).
602 +
        const MAX_KEPT: usize = 8;
603 +
        let mut waiting: HashMap<&str, usize> = HashMap::new();
604 +
        for gc in &commits {
605 +
            if let Some(p1) = gc.parents.first() {
606 +
                *waiting.entry(p1.as_str()).or_default() += 1;
607 +
            }
608 +
        }
609 +
        type Kept = (State, BTreeMap<String, (String, String)>);
610 +
        let mut kept: HashMap<String, Kept> = HashMap::new();
611 +
        let mut kept_order: std::collections::VecDeque<String> = std::collections::VecDeque::new();
612 +
        let mut last: Option<String> = None;
600 613
        let mut blob_cache: HashMap<String, Vec<u8>> = HashMap::new();
601 614
        let mut cache_bytes: usize = 0;
615 +
        // Time spent per phase over all commits (`OXY_TIMINGS=1`).
616 +
        let mut spent = [std::time::Duration::ZERO; 6];
617 +
        let mut clock = std::time::Instant::now();
618 +
        let mut lap = |i: usize, clock: &mut std::time::Instant| {
619 +
            let now = std::time::Instant::now();
620 +
            spent[i] += now - *clock;
621 +
            *clock = now;
622 +
        };
602 623
603 624
        for gc in &commits {
604 625
            if known.imported.contains_key(&gc.sha) || known.exported.contains_key(&gc.sha) {
605 626
                continue;
606 627
            }
607 -
            // Base: union of every parent's state. When the first parent is
608 -
            // the commit just imported, its state moves on, and its tree
609 -
            // tells which paths this commit leaves alone.
610 -
            let (mut base, mut prev_tree) = match (gc.parents.first(), last.take()) {
611 -
                (Some(p1), Some((sha, st, tree))) if *p1 == sha => (st, Some(tree)),
612 -
                (Some(p1), _) => {
613 -
                    let mut st = State::new();
614 -
                    self.add_git_ancestors(&mut st, git_dir, &[p1.as_str()], &known)?;
615 -
                    (st, None)
628 +
            // Base: union of every parent's state, starting from the first
629 +
            // parent's kept state when there is one; its tree tells which
630 +
            // paths this commit leaves alone.
631 +
            let (mut base, prev_tree) = match gc.parents.first() {
632 +
                Some(p1) => {
633 +
                    let left = waiting.get_mut(p1.as_str()).map(|n| {
634 +
                        *n -= 1;
635 +
                        *n
636 +
                    });
637 +
                    let found = if left == Some(0) {
638 +
                        kept_order.retain(|k| k != p1);
639 +
                        kept.remove(p1.as_str())
640 +
                    } else {
641 +
                        kept.get(p1.as_str()).cloned()
642 +
                    };
643 +
                    match found {
644 +
                        Some((st, tree)) => (st, Some(tree)),
645 +
                        None => {
646 +
                            let mut st = State::new();
647 +
                            self.add_git_ancestors(&mut st, git_dir, &[p1.as_str()], &known)?;
648 +
                            (st, None)
649 +
                        }
650 +
                    }
616 651
                }
617 -
                (None, _) => (State::new(), None),
652 +
                None => (State::new(), None),
618 653
            };
654 +
            // Nodes the other parents' changes touch: the first parent's
655 +
            // tree cannot vouch for them.
656 +
            let mut touched: HashSet<NodeId> = HashSet::new();
619 657
            if gc.parents.len() > 1 {
620 -
                // The other parents bring changes to paths the first
621 -
                // parent's tree cannot vouch for: compare every file.
622 -
                prev_tree = None;
623 658
                report.merges += 1;
624 659
                let mut args: Vec<String> = gc.parents[1..].to_vec();
625 660
                args.push(format!("^{}", gc.parents[0]));
626 661
                let refs: Vec<&str> = args.iter().map(String::as_str).collect();
627 -
                self.add_git_ancestors(&mut base, git_dir, &refs, &known)?;
662 +
                self.add_git_ancestors_touching(&mut base, git_dir, &refs, &known, &mut touched)?;
628 663
            }
664 +
            lap(0, &mut clock);
629 665
            let tree = list_tree(git_dir, &gc.sha)?;
666 +
            lap(1, &mut clock);
630 667
            // Text files with the same blob and mode as in the first
631 668
            // parent's tree equal the base's rendering: they are neither
632 669
            // rendered nor read nor compared (spec §12.3, as for a clean
…
7 unchanged lines
640 677
                        continue;
641 678
                    }
642 679
                    if let Some(e) = view.entries.get(path) {
643 -
                        if e.kind == NodeKind::File && !e.mode.opaque {
680 +
                        if e.kind == NodeKind::File && !e.mode.opaque && !touched.contains(&e.node)
681 +
                        {
644 682
                            same.insert(path.as_str(), e.node);
645 683
                        }
646 684
                    }
…
5 unchanged lines
652 690
                base.materialize_except(&same.values().copied().collect())
653 691
            };
654 692
693 +
            lap(2, &mut clock);
655 694
            // The commit's tree as in-memory entries.
656 695
            let mut entries: BTreeMap<String, FsEntry> = BTreeMap::new();
657 696
            let mut gitlinks: Vec<(String, String)> = Vec::new();
…
75 unchanged lines
733 772
                    );
734 773
                }
735 774
            }
775 +
            lap(3, &mut clock);
736 776
            let mut built =
737 777
                self.build_from_entries(&base, &base_snapshot, HashMap::new(), &entries)?;
778 +
            lap(4, &mut clock);
738 779
            // A file or submodule with several live blobs (branches that
739 780
            // replaced it independently) is resolved to the commit's blob:
740 781
            // Git's merge is the authority here, unlike a working copy,
…
52 unchanged lines
793 834
            new_changes.push(change.clone());
794 835
            base.add_unchecked(change);
795 836
            known.imported.insert(gc.sha.clone(), hash);
796 -
            last = Some((gc.sha.clone(), base, tree));
837 +
            if waiting.get(gc.sha.as_str()).is_some_and(|n| *n > 0) {
838 +
                kept.insert(gc.sha.clone(), (base, tree));
839 +
                kept_order.push_back(gc.sha.clone());
840 +
                if kept_order.len() > MAX_KEPT {
841 +
                    if let Some(old) = kept_order.pop_front() {
842 +
                        kept.remove(&old);
843 +
                    }
844 +
                }
845 +
            }
846 +
            last = Some(gc.sha.clone());
797 847
            report.commits += 1;
848 +
            lap(5, &mut clock);
849 +
        }
850 +
        for (name, d) in [
851 +
            "git import: bases",
852 +
            "git import: ls-tree",
853 +
            "git import: render base",
854 +
            "git import: read blobs",
855 +
            "git import: build change",
856 +
            "git import: store change",
857 +
        ]
858 +
        .iter()
859 +
        .zip(spent)
860 +
        {
861 +
            crate::timing::record(name, d);
798 862
        }
799 863
800 864
        // The bookmark: heads of the old state plus every new change.
…
43 unchanged lines
844 908
    /// an imported commit's change, or the whole state an exported commit
845 909
    /// was made from (which may hold changes Git never saw).
846 910
    fn add_git_ancestors(
911 +
        &self,
912 +
        st: &mut State,
913 +
        git_dir: &Path,
914 +
        args: &[&str],
915 +
        known: &GitKnown,
916 +
    ) -> Result<()> {
917 +
        self.add_git_ancestors_touching(st, git_dir, args, known, &mut HashSet::new())
918 +
    }
919 +
920 +
    /// `add_git_ancestors`, also collecting in `touched` every node the
921 +
    /// changes it adds edit, place or remove.
922 +
    fn add_git_ancestors_touching(
847 923
        &self,
848 924
        st: &mut State,
849 925
        git_dir: &Path,
850 926
        args: &[&str],
851 927
        known: &GitKnown,
928 +
        touched: &mut HashSet<NodeId>,
852 929
    ) -> Result<()> {
853 930
        let mut full = vec!["rev-list"];
854 931
        full.extend(args);
855 932
        let out = git(git_dir, &full)?;
933 +
        let mut add = |st: &mut State, h: &ChangeHash| -> Result<()> {
934 +
            if st.contains(h) {
935 +
                return Ok(());
936 +
            }
937 +
            let c = self.store.get_change(h)?;
938 +
            touched.extend(c.edits.iter().map(|e| e.node()));
939 +
            touched.extend(c.tree_ops.iter().map(|o| o.node()));
940 +
            st.add_unchecked(c);
941 +
            Ok(())
942 +
        };
856 943
        for sha in out.lines() {
857 944
            if let Some(h) = known.imported.get(sha) {
858 -
                st.add_unchecked(self.store.get_change(h)?);
945 +
                add(st, h)?;
859 946
            } else if let Some(state_ref) = known.exported.get(sha) {
860 947
                let heads = self.store.get_state(state_ref)?.heads;
861 948
                for h in self.negotiate(&[], &heads)? {
862 -
                    st.add_unchecked(self.store.get_change(&h)?);
949 +
                    add(st, &h)?;
863 950
                }
864 951
            }
865 952
        }
…
314 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +22 −9
…
158 unchanged lines
159 159
                Some(expected) => b.update_path(self, path, fs_entry, expected)?,
160 160
            }
161 161
        }
162 +
        // A directory stays while anything under it exists or is out of
163 +
        // scope: the directories above each such path, gathered once.
164 +
        let mut needed_dirs: HashSet<&str> = HashSet::new();
165 +
        fn parents_of<'a>(p: &'a str, set: &mut HashSet<&'a str>) {
166 +
            let mut end = p.len();
167 +
            while let Some(i) = p[..end].rfind('/') {
168 +
                if !set.insert(&p[..i]) {
169 +
                    break;
170 +
                }
171 +
                end = i;
172 +
            }
173 +
        }
174 +
        for p in fs_entries.keys() {
175 +
            parents_of(p, &mut needed_dirs);
176 +
        }
177 +
        for p in base_snapshot.files.keys() {
178 +
            if !self.in_scope(p) {
179 +
                parents_of(p, &mut needed_dirs);
180 +
            }
181 +
        }
162 182
        for (path, expected) in &base_snapshot.files {
163 183
            if fs_entries.contains_key(path)
164 184
                || !self.in_scope(path)
…
1 unchanged lines
166 186
            {
167 187
                continue;
168 188
            }
169 -
            // A directory stays while anything under it exists or is out of scope.
170 -
            let still_needed = expected.kind == NodeKind::Dir
171 -
                && (fs_entries
172 -
                    .keys()
173 -
                    .any(|p| p.starts_with(&format!("{path}/")))
174 -
                    || base_snapshot
175 -
                        .files
176 -
                        .keys()
177 -
                        .any(|p| p.starts_with(&format!("{path}/")) && !self.in_scope(p)));
189 +
            let still_needed =
190 +
                expected.kind == NodeKind::Dir && needed_dirs.contains(path.as_str());
178 191
            if !still_needed {
179 192
                b.remove_path(path, expected);
180 193
            }
…
2049 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +13 −1
…
1520 unchanged lines
1521 1521
    );
1522 1522
    git(&["add", "."]);
1523 1523
    git(&["commit", "-q", "-m", "rename lib"]);
1524 +
    // A branch that edits run.sh, merged keeping main's tree (`-s ours`):
1525 +
    // run.sh is the same as in the first parent's tree, but the merged
1526 +
    // change touched it, so the importer must compare it, not skip it.
1527 +
    git(&["checkout", "-q", "-b", "discarded"]);
1528 +
    write(&g, "run.sh", b"#!/bin/sh\necho discarded\n");
1529 +
    git(&["commit", "-q", "-am", "discarded edit"]);
1530 +
    git(&["checkout", "-q", "main"]);
1531 +
    git(&["merge", "-q", "-s", "ours", "-m", "merge discarded, keeping ours", "discarded"]);
1524 1532
    let expected_count: usize = git(&["rev-list", "--count", "main"]).parse().unwrap();
1525 1533
1526 1534
    let dest = root.join("oxy");
1527 1535
    let repo = Repo::init(&dest).unwrap().with_home(root.join("home"));
1528 1536
    let rep = repo.import_git(&g, "main").unwrap();
1529 1537
    assert_eq!(rep.commits, expected_count);
1530 -
    assert_eq!(rep.merges, 2);
1538 +
    assert_eq!(rep.merges, 3);
1539 +
    assert_eq!(fs::read(dest.join("run.sh")).unwrap(), b"#!/bin/sh\n", "the ours merge kept main's run.sh");
1540 +
    let v = repo.verify_git_import(&g, "main").unwrap();
1541 +
    assert_eq!(v.commits, expected_count, "every commit's tree reproduced");
1542 +
    assert!(v.mismatches.is_empty(), "{:?}", v.mismatches);
1531 1543
    // The working copy equals the Git tree at main.
1532 1544
    assert_eq!(
1533 1545
        fs::read(dest.join("src/lib2.rs")).unwrap(),
…
3922 unchanged lines
modified docs/benchmarks.md +41 −0
…
211 unchanged lines
212 212
213 213
Caching materialized states, planned alongside, is not needed now that
214 214
these commands render almost nothing.
215 +
216 +
## 2026-09-29: P5c, importing microsoft/vscode, first part
217 +
218 +
`oxy git import` on slices of microsoft/vscode's `main` (166 412 commits in
219 +
total), taken in the importer's own order (`git log --reverse
220 +
--topo-order`). The first 5 000 commits reach May 2016 with about 4 700
221 +
files.
222 +
223 +
The importer did work proportional to the whole tree for every commit:
224 +
- It cloned the previous state.
225 +
- It rebuilt a merge's first parent from history.
226 +
- It rendered, read and compared every file.
227 +
- It scanned every entry, with an allocation each, to decide whether each
228 +
  directory was still needed.
229 +
230 +
Now:
231 +
- Files with the same Git blob and mode as in the first parent's tree are
232 +
  marked unchanged and skipped, as a clean working copy's are. Paths that a
233 +
  merge's other parents touched are excluded.
234 +
- A commit's state is kept while first-parent children remain, up to 8,
235 +
  and the last child takes it without a clone.
236 +
- Needed directories come from one set of ancestors. That code is shared
237 +
  by snapshot and commit.
238 +
239 +
| Slice | Before | After |
240 +
|---|---:|---:|
241 +
| 300 commits (35 merges) | 65.1 s | 13.6 s |
242 +
| 1 000 commits (95 merges) | 271 s | 120 s, 3.3 GB |
243 +
| 5 000 commits (360 merges) | not finished after 80 min | 412 s, 6.3 GB, 17 MB store |
244 +
245 +
Where the 5 000-commit import spends its time (summed per phase): render
246 +
base 172 s, build change 99 s, bases 65 s, `git ls-tree` 56 s, read blobs
247 +
10 s, store 5 s.
248 +
249 +
Correctness: `oxy git verify` reproduces every commit of the 300 and
250 +
1 000-commit imports (1.9 million file comparisons for 1 000). A test
251 +
covers the case that must not be skipped: a `-s ours` merge whose other
252 +
side edited a file.
253 +
254 +
Next: rendering the base is still O(files) per commit, and memory grows
255 +
with the kept states. The full import follows.