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. |