OXYGENHUB

A Git import commit costs what it changes 2306a4d24779

A commit's tree is now its first parent's with the commit's diff
applied, read from one git diff-tree process fed every pair up front,
instead of a git ls-tree of the whole tree per commit. An ordinary commit
in a tree without submodules is built from the paths it changes (with
the .oxyattributes files and the directories its removals may empty):
only those are rendered, read and compared. A parent whose state is not
kept has its tree listed, so its child still renders only what changed.
Unchanged opaque files and symlinks are no longer read and stored again.

The tree keeps its live placements and modes as an index, so its view
walks what is live rather than every placement the history ever had.

The text diff sets aside lines found on one side only before running
Myers, as Git's xdiff does, and compares lines as numbers: a file
rewritten whole (line endings changed) no longer costs quadratic time.
It diffs from the base's rendering rather than rendering it again.

The first 5 000 vscode commits import in 81 s (from 312 s) and
verify clean. Repeated phases report their totals (OXY_TIMINGS).

refs #25
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-30 06:39 UTC
Signature
✔ signed by Andy McCall
Hash
2306a4d24779c0d3590b409951ad0eb8c557512a83a58a4ba412597d913449c3
Depends on
01a47cd7f0c8 03571027793e 0bdc3f91a39f 206c76cad4bf 252e8cb23fbc 30ea9c1e70fa 4dee277c327a 614541c3cebd 62794ecc7f74 6e723b81f5b9 75167672ebcd 7891e21c762f 79ab8528ff62 958fb27dda7c 982864068a59 a87c7b9d563a ab4d4e31b38d b493d993a9df c3069537d7c5 cdb5beba7f05 dfb23a3a8cfa e7ddd552b619 ff0af1ee299d
Depended on by
c2e1d09047db d6e0d5b8b339 aedfe465b611 f0406c3b40ed ce118287d9da 73d592e763ab c3d2ef6104c2 360aa74a6277 fd280228bdc2 05059d434aca 23ff1288a5dd f9575918477f 40a40bb7b798 e83354cdac22
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
63 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified crates/oxy-core/src/diff.rs +70 −63
…
6 unchanged lines
7 7
use crate::ids::NodeId;
8 8
use crate::objects::{AtomRange, AtomRef, Edit};
9 9
use similar::DiffOp;
10 +
use std::collections::HashMap;
10 11
11 12
/// Split into lines, each keeping its terminator; a final unterminated line
12 13
/// is its own atom.
…
28 unchanged lines
41 42
    out
42 43
}
43 44
45 +
/// The lines `old` and `new` keep in common, as (old index, new index) in
46 +
/// order: a longest common subsequence, found by Myers' algorithm. A line
47 +
/// found on one side only cannot be in it, so such lines are set aside
48 +
/// before the diff, as Git's xdiff does: a file rewritten whole (every
49 +
/// line ending changed, say) then costs time linear in its length rather
50 +
/// than quadratic. Lines are compared as small numbers, not bytes.
51 +
fn kept_lines<'a>(old: &[&'a [u8]], new: &[&'a [u8]]) -> Vec<(usize, usize)> {
52 +
    let mut ids: HashMap<&'a [u8], u32> = HashMap::new();
53 +
    let mut intern = |l: &'a [u8]| -> u32 {
54 +
        let next = ids.len() as u32;
55 +
        *ids.entry(l).or_insert(next)
56 +
    };
57 +
    let o: Vec<u32> = old.iter().map(|l| intern(l)).collect();
58 +
    let n: Vec<u32> = new.iter().map(|l| intern(l)).collect();
59 +
    let mut in_old = vec![false; ids.len()];
60 +
    let mut in_new = vec![false; ids.len()];
61 +
    for &x in &o {
62 +
        in_old[x as usize] = true;
63 +
    }
64 +
    for &x in &n {
65 +
        in_new[x as usize] = true;
66 +
    }
67 +
    let oi: Vec<usize> = (0..o.len()).filter(|&i| in_new[o[i] as usize]).collect();
68 +
    let ni: Vec<usize> = (0..n.len()).filter(|&j| in_old[n[j] as usize]).collect();
69 +
    let os: Vec<u32> = oi.iter().map(|&i| o[i]).collect();
70 +
    let ns: Vec<u32> = ni.iter().map(|&j| n[j]).collect();
71 +
    let mut capture = similar::algorithms::Capture::new();
72 +
    let _ = similar::algorithms::myers::diff(&mut capture, &os, 0..os.len(), &ns, 0..ns.len());
73 +
    let mut kept = Vec::new();
74 +
    for op in capture.into_ops() {
75 +
        if let DiffOp::Equal {
76 +
            old_index,
77 +
            new_index,
78 +
            len,
79 +
        } = op
80 +
        {
81 +
            kept.extend((0..len).map(|k| (oi[old_index + k], ni[new_index + k])));
82 +
        }
83 +
    }
84 +
    kept
85 +
}
86 +
44 87
/// Edits that transform the live lines `old` of `node` into `new`.
45 88
pub fn text_edits(node: NodeId, old: &[(AtomKey, Vec<u8>)], new: &[u8]) -> Vec<Edit> {
89 +
    let keys: Vec<AtomKey> = old.iter().map(|(k, _)| *k).collect();
46 90
    let old_lines: Vec<&[u8]> = old.iter().map(|(_, l)| l.as_slice()).collect();
91 +
    text_edits_of(node, &keys, &old_lines, new)
92 +
}
93 +
94 +
/// `text_edits` for live lines given as their atoms and, apart, their
95 +
/// text (`old[i]` is the text of `keys[i]`).
96 +
pub fn text_edits_of(node: NodeId, keys: &[AtomKey], old_lines: &[&[u8]], new: &[u8]) -> Vec<Edit> {
47 97
    let new_lines = split_lines(new);
48 -
    // Myers ops in file order. `capture_diff_slices` also runs the `Compact`
49 -
    // hook, which slides deletions along runs of equal lines and can leave
50 -
    // an insertion with the old index of a line before an equal one: the
51 -
    // insertion was then anchored a line too early and landed on the wrong
52 -
    // side of it (found by verifying a real Git import commit by commit).
53 -
    let mut hook = similar::algorithms::Replace::new(similar::algorithms::Capture::new());
54 -
    let _ = similar::algorithms::diff_deadline(
55 -
        similar::Algorithm::Myers,
56 -
        &mut hook,
57 -
        &old_lines,
58 -
        0..old_lines.len(),
59 -
        &new_lines,
60 -
        0..new_lines.len(),
61 -
        None,
62 -
    );
63 -
    let ops = hook.into_inner().into_ops();
64 98
65 99
    let anchor = |i: usize| -> (AtomRef, AtomRef) {
66 100
        let after = if i == 0 {
67 101
            AtomRef::Start
68 102
        } else {
69 103
            AtomRef::Atom {
70 -
                change: old[i - 1].0 .0,
71 -
                index: old[i - 1].0 .1,
104 +
                change: keys[i - 1].0,
105 +
                index: keys[i - 1].1,
72 106
            }
73 107
        };
74 -
        let before = if i >= old.len() {
108 +
        let before = if i >= keys.len() {
75 109
            AtomRef::End
76 110
        } else {
77 111
            AtomRef::Atom {
78 -
                change: old[i].0 .0,
79 -
                index: old[i].0 .1,
112 +
                change: keys[i].0,
113 +
                index: keys[i].1,
80 114
            }
81 115
        };
82 116
        (after, before)
83 117
    };
84 118
119 +
    // Each run of lines between two kept ones is deleted, and the new
120 +
    // lines there are inserted where the first deleted line was.
85 121
    let mut deleted: Vec<AtomKey> = Vec::new();
86 122
    let mut inserts: Vec<Edit> = Vec::new();
87 -
    for op in ops {
88 -
        match op {
89 -
            DiffOp::Equal { .. } => {}
90 -
            DiffOp::Delete {
91 -
                old_index, old_len, ..
92 -
            } => {
93 -
                deleted.extend(old[old_index..old_index + old_len].iter().map(|(k, _)| *k));
94 -
            }
95 -
            DiffOp::Insert {
96 -
                old_index,
97 -
                new_index,
98 -
                new_len,
99 -
            } => {
100 -
                let (after, before) = anchor(old_index);
101 -
                inserts.push(Edit::Insert {
102 -
                    node,
103 -
                    after,
104 -
                    before,
105 -
                    lines: new_lines[new_index..new_index + new_len]
106 -
                        .iter()
107 -
                        .copied()
108 -
                        .collect(),
109 -
                });
110 -
            }
111 -
            DiffOp::Replace {
112 -
                old_index,
113 -
                old_len,
114 -
                new_index,
115 -
                new_len,
116 -
            } => {
117 -
                deleted.extend(old[old_index..old_index + old_len].iter().map(|(k, _)| *k));
118 -
                let (after, before) = anchor(old_index);
119 -
                inserts.push(Edit::Insert {
120 -
                    node,
121 -
                    after,
122 -
                    before,
123 -
                    lines: new_lines[new_index..new_index + new_len]
124 -
                        .iter()
125 -
                        .copied()
126 -
                        .collect(),
127 -
                });
128 -
            }
123 +
    let (mut i, mut j) = (0, 0);
124 +
    let end = (old_lines.len(), new_lines.len());
125 +
    for (ki, kj) in kept_lines(old_lines, &new_lines).into_iter().chain([end]) {
126 +
        deleted.extend_from_slice(&keys[i..ki]);
127 +
        if kj > j {
128 +
            let (after, before) = anchor(i);
129 +
            inserts.push(Edit::Insert {
130 +
                node,
131 +
                after,
132 +
                before,
133 +
                lines: new_lines[j..kj].iter().copied().collect(),
134 +
            });
129 135
        }
136 +
        (i, j) = (ki + 1, kj + 1);
130 137
    }
131 138
    let mut edits = Vec::new();
132 139
    if !deleted.is_empty() {
…
198 unchanged lines
modified crates/oxy-core/src/repo/git.rs +264 −28
…
74 unchanged lines
75 75
    }
76 76
}
77 77
78 +
/// One path a commit changes against its first parent: its new mode and
79 +
/// blob, or `None` when the commit deletes it.
80 +
type TreeDiff = Vec<(String, Option<(String, String)>)>;
81 +
82 +
/// A running `git diff-tree --stdin`, fed every `commit parent` pair up
83 +
/// front, whose diffs are read in the same order as they are needed: a
84 +
/// commit's tree is its first parent's plus its diff, at a cost that
85 +
/// follows what it changes rather than the whole tree (oxygen#25).
86 +
struct DiffTree {
87 +
    child: std::process::Child,
88 +
    stdout: std::io::BufReader<std::process::ChildStdout>,
89 +
    feeder: Option<std::thread::JoinHandle<()>>,
90 +
    /// The header read at the end of the previous diff.
91 +
    next: Option<String>,
92 +
}
93 +
94 +
impl DiffTree {
95 +
    fn start(git_dir: &Path, pairs: Vec<(String, String)>) -> Result<DiffTree> {
96 +
        let mut child = Command::new("git")
97 +
            .arg("-C")
98 +
            .arg(git_dir)
99 +
            .args([
100 +
                "diff-tree",
101 +
                "--stdin",
102 +
                "--always",
103 +
                "-r",
104 +
                "-z",
105 +
                "--no-renames",
106 +
            ])
107 +
            .stdin(Stdio::piped())
108 +
            .stdout(Stdio::piped())
109 +
            .stderr(Stdio::null())
110 +
            .spawn()
111 +
            .map_err(|e| RepoError::Msg(format!("running git diff-tree: {e}")))?;
112 +
        let mut stdin = child.stdin.take().unwrap();
113 +
        let stdout = std::io::BufReader::new(child.stdout.take().unwrap());
114 +
        // A thread writes, so git never waits on a full pipe for us to read.
115 +
        let feeder = std::thread::spawn(move || {
116 +
            let mut w = std::io::BufWriter::new(&mut stdin);
117 +
            for (c, p) in pairs {
118 +
                if writeln!(w, "{c} {p}").is_err() {
119 +
                    return;
120 +
                }
121 +
            }
122 +
            let _ = w.flush();
123 +
        });
124 +
        Ok(DiffTree {
125 +
            child,
126 +
            stdout,
127 +
            feeder: Some(feeder),
128 +
            next: None,
129 +
        })
130 +
    }
131 +
132 +
    fn field(&mut self) -> Result<Option<String>> {
133 +
        use std::io::BufRead;
134 +
        let mut buf = Vec::new();
135 +
        if self.stdout.read_until(0, &mut buf)? == 0 {
136 +
            return Ok(None);
137 +
        }
138 +
        if buf.last() == Some(&0) {
139 +
            buf.pop();
140 +
        }
141 +
        Ok(Some(String::from_utf8_lossy(&buf).into_owned()))
142 +
    }
143 +
144 +
    /// The diff of `sha` against the parent it was fed with.
145 +
    fn diff(&mut self, sha: &str) -> Result<TreeDiff> {
146 +
        let header = match self.next.take() {
147 +
            Some(h) => Some(h),
148 +
            None => self.field()?,
149 +
        };
150 +
        if header.as_deref() != Some(sha) {
151 +
            return fail(format!("git diff-tree: expected {sha}, got {header:?}"));
152 +
        }
153 +
        let mut out = Vec::new();
154 +
        while let Some(f) = self.field()? {
155 +
            let Some(meta) = f.strip_prefix(':') else {
156 +
                self.next = Some(f);
157 +
                break;
158 +
            };
159 +
            let Some(path) = self.field()? else {
160 +
                return fail("git diff-tree: record without a path");
161 +
            };
162 +
            // `oldmode newmode oldsha newsha status`
163 +
            let m: Vec<&str> = meta.split(' ').collect();
164 +
            if m.len() != 5 {
165 +
                return fail(format!("git diff-tree: bad record {f:?}"));
166 +
            }
167 +
            let entry = if m[4].starts_with('D') {
168 +
                None
169 +
            } else {
170 +
                Some((m[1].to_string(), m[3].to_string()))
171 +
            };
172 +
            out.push((path, entry));
173 +
        }
174 +
        Ok(out)
175 +
    }
176 +
}
177 +
178 +
impl Drop for DiffTree {
179 +
    fn drop(&mut self) {
180 +
        let _ = self.child.kill();
181 +
        let _ = self.child.wait();
182 +
        if let Some(f) = self.feeder.take() {
183 +
            let _ = f.join();
184 +
        }
185 +
    }
186 +
}
187 +
78 188
/// One commit as `git log` describes it.
79 189
struct GitCommit {
80 190
    sha: String,
…
534 unchanged lines
615 725
        }
616 726
        type Kept = (State, BTreeMap<String, (String, String)>);
617 727
        let mut kept: HashMap<String, Kept> = HashMap::new();
728 +
        // Every commit to import that has a parent, diffed against its first.
729 +
        let pairs: Vec<(String, String)> = commits
730 +
            .iter()
731 +
            .filter(|gc| {
732 +
                !known.imported.contains_key(&gc.sha) && !known.exported.contains_key(&gc.sha)
733 +
            })
734 +
            .filter_map(|gc| Some((gc.sha.clone(), gc.parents.first()?.clone())))
735 +
            .collect();
736 +
        let mut diffs = DiffTree::start(git_dir, pairs)?;
737 +
        let mut rebuilt = 0usize;
618 738
        let mut kept_order: std::collections::VecDeque<String> = std::collections::VecDeque::new();
619 739
        let mut last: Option<String> = None;
620 740
        let mut blob_cache: HashMap<String, Vec<u8>> = HashMap::new();
…
34 unchanged lines
655 775
                            // not the whole tree.
656 776
                            let mut st = State::new_history_only();
657 777
                            self.add_git_ancestors(&mut st, git_dir, &[p1.as_str()], &known)?;
658 -
                            (st, None)
778 +
                            // The parent's tree, so this commit still
779 +
                            // renders only what it changes.
780 +
                            rebuilt += 1;
781 +
                            (st, Some(list_tree(git_dir, p1)?))
659 782
                        }
660 783
                    }
661 784
                }
…
10 unchanged lines
672 795
                self.add_git_ancestors_touching(&mut base, git_dir, &refs, &known, &mut touched)?;
673 796
            }
674 797
            lap(0, &mut clock);
675 -
            let tree = list_tree(git_dir, &gc.sha)?;
798 +
            // The commit's tree: its first parent's with the diff applied,
799 +
            // or listed whole when that tree is not kept.
800 +
            let diff = match gc.parents.first() {
801 +
                Some(_) => Some(diffs.diff(&gc.sha)?),
802 +
                None => None,
803 +
            };
804 +
            let (tree, changed) = match (prev_tree, diff) {
805 +
                (Some(mut t), Some(diff)) => {
806 +
                    let mut changed: HashSet<String> = HashSet::new();
807 +
                    for (path, entry) in diff {
808 +
                        match entry {
809 +
                            Some(v) => {
810 +
                                t.insert(path.clone(), v);
811 +
                            }
812 +
                            None => {
813 +
                                t.remove(&path);
814 +
                            }
815 +
                        }
816 +
                        changed.insert(path);
817 +
                    }
818 +
                    (t, Some(changed))
819 +
                }
820 +
                _ => (list_tree(git_dir, &gc.sha)?, None),
821 +
            };
676 822
            lap(1, &mut clock);
677 823
            // Text files with the same blob and mode as in the first
678 824
            // parent's tree equal the base's rendering: they are neither
679 825
            // rendered nor read nor compared (spec §12.3, as for a clean
680 826
            // working copy), so a commit costs what it changes.
681 827
            let mut same: HashMap<&str, NodeId> = HashMap::new();
682 -
            if let Some(prev) = &prev_tree {
828 +
            // Opaque files and symlinks likewise, when the base has one
829 +
            // blob for them: they are neither read nor stored again.
830 +
            let mut same_opaque: HashSet<&str> = HashSet::new();
831 +
            // An ordinary commit in a tree without submodules is built from
832 +
            // the paths it changes alone (oxygen#25): with the `.oxyattributes`
833 +
            // files that decide how they are stored, and the directories its
834 +
            // removals may leave empty. A merge, or a commit whose parent's
835 +
            // tree is not at hand, is built from its whole tree.
836 +
            let partial = changed.is_some()
837 +
                && gc.parents.len() == 1
838 +
                && !tree.values().any(|(mode, _)| mode == "160000");
839 +
            let mut rel: BTreeSet<&str> = BTreeSet::new();
840 +
            let base_snapshot = if let (true, Some(changed)) = (partial, &changed) {
683 841
                let view = base.tree().view();
684 -
                for (path, v) in &tree {
685 -
                    // Submodule handling reads these two whatever changed.
686 -
                    if prev.get(path) != Some(v) || path == ".gitmodules" || path == ".oxymodules" {
687 -
                        continue;
842 +
                rel.extend(changed.iter().map(String::as_str));
843 +
                rel.extend(
844 +
                    tree.keys()
845 +
                        .map(String::as_str)
846 +
                        .filter(|p| *p == ".oxyattributes" || p.ends_with("/.oxyattributes")),
847 +
                );
848 +
                let mut dirs: BTreeSet<&str> = BTreeSet::new();
849 +
                for p in changed.iter().filter(|p| !tree.contains_key(*p)) {
850 +
                    let mut end = p.len();
851 +
                    while let Some(i) = p[..end].rfind('/') {
852 +
                        if !dirs.insert(&p[..i]) {
853 +
                            break;
854 +
                        }
855 +
                        end = i;
688 856
                    }
689 -
                    if let Some(e) = view.entries.get(path) {
690 -
                        if e.kind == NodeKind::File && !e.mode.opaque && !touched.contains(&e.node)
857 +
                }
858 +
                let need: Vec<NodeId> = rel
859 +
                    .iter()
860 +
                    .filter_map(|p| view.entries.get(*p))
861 +
                    .filter(|e| e.kind == NodeKind::File && !e.mode.opaque)
862 +
                    .map(|e| e.node)
863 +
                    .collect();
864 +
                crate::timing::add("render: same and need", clock.elapsed());
865 +
                let t = std::time::Instant::now();
866 +
                self.load_graphs_of(&mut base, &need)?;
867 +
                crate::timing::add("render: load graphs", t.elapsed());
868 +
                let t = std::time::Instant::now();
869 +
                let snap = base.materialize_paths(view, rel.iter().chain(dirs.iter()).copied());
870 +
                crate::timing::add("render: materialize", t.elapsed());
871 +
                snap
872 +
            } else {
873 +
                if let Some(changed) = &changed {
874 +
                    let view = base.tree().view();
875 +
                    for path in tree.keys() {
876 +
                        // Submodule handling reads these two whatever changed.
877 +
                        if changed.contains(path) || path == ".gitmodules" || path == ".oxymodules"
691 878
                        {
692 -
                            same.insert(path.as_str(), e.node);
879 +
                            continue;
880 +
                        }
881 +
                        if let Some(e) = view.entries.get(path) {
882 +
                            if touched.contains(&e.node) {
883 +
                                continue;
884 +
                            }
885 +
                            let opaque = e.kind == NodeKind::Symlink
886 +
                                || (e.kind == NodeKind::File && e.mode.opaque);
887 +
                            if e.kind == NodeKind::File && !e.mode.opaque {
888 +
                                same.insert(path.as_str(), e.node);
889 +
                            } else if opaque && base.live_blobs(&e.node).len() == 1 {
890 +
                                same_opaque.insert(path.as_str());
891 +
                            }
693 892
                        }
694 893
                    }
695 894
                }
696 -
            }
697 -
            // Build the graphs of every text file the commit may change.
698 -
            let skip: HashSet<NodeId> = same.values().copied().collect();
699 -
            let need: Vec<NodeId> = base
700 -
                .tree()
701 -
                .view()
702 -
                .entries
703 -
                .values()
704 -
                .filter(|e| e.kind == NodeKind::File && !e.mode.opaque && !skip.contains(&e.node))
705 -
                .map(|e| e.node)
706 -
                .collect();
707 -
            self.load_graphs_of(&mut base, &need)?;
708 -
            let base_snapshot = base.materialize_except(&skip);
895 +
                // Build the graphs of every text file the commit may change.
896 +
                let skip: HashSet<NodeId> = same.values().copied().collect();
897 +
                let need: Vec<NodeId> = base
898 +
                    .tree()
899 +
                    .view()
900 +
                    .entries
901 +
                    .values()
902 +
                    .filter(|e| {
903 +
                        e.kind == NodeKind::File && !e.mode.opaque && !skip.contains(&e.node)
904 +
                    })
905 +
                    .map(|e| e.node)
906 +
                    .collect();
907 +
                crate::timing::add("render: same and need", clock.elapsed());
908 +
                let t = std::time::Instant::now();
909 +
                self.load_graphs_of(&mut base, &need)?;
910 +
                crate::timing::add("render: load graphs", t.elapsed());
911 +
                let t = std::time::Instant::now();
912 +
                let snap = base.materialize_except(&skip);
913 +
                crate::timing::add("render: materialize", t.elapsed());
914 +
                snap
915 +
            };
709 916
710 917
            lap(2, &mut clock);
711 918
            // The commit's tree as in-memory entries.
712 919
            let mut entries: BTreeMap<String, FsEntry> = BTreeMap::new();
713 920
            let mut gitlinks: Vec<(String, String)> = Vec::new();
714 -
            for (path, (mode, sha)) in &tree {
921 +
            let listed: Box<dyn Iterator<Item = (&String, &(String, String))>> = if partial {
922 +
                Box::new(rel.iter().filter_map(|p| tree.get_key_value(*p)))
923 +
            } else {
924 +
                Box::new(tree.iter())
925 +
            };
926 +
            for (path, (mode, sha)) in listed {
715 927
                let (path, sha) = (path.clone(), sha.clone());
716 928
                let (kind, exec) = match mode.as_str() {
717 929
                    "100644" | "100664" => (NodeKind::File, false),
…
5 unchanged lines
723 935
                    }
724 936
                    _ => continue,
725 937
                };
726 -
                if same.contains_key(path.as_str()) {
938 +
                if same.contains_key(path.as_str()) || same_opaque.contains(path.as_str()) {
727 939
                    let mut e = FsEntry::new(kind, exec, Vec::new());
728 940
                    e.unchanged = true;
729 941
                    entries.insert(path, e);
…
63 unchanged lines
793 1005
            // New nodes' identifiers derive from the commit, so importing the
794 1006
            // same Git history twice gives the same changes (spec §23).
795 1007
            let seed = *blake3::hash(format!("oxy git import\0{}", gc.sha).as_bytes()).as_bytes();
796 -
            let mut built =
797 -
                self.build_from_entries_seeded(&base, &base_snapshot, &entries, seed)?;
1008 +
            let mut built = if partial {
1009 +
                // Whether the new tree has anything under `dir`.
1010 +
                let dir_needed = |dir: &str| {
1011 +
                    let prefix = format!("{dir}/");
1012 +
                    tree.range(prefix.clone()..)
1013 +
                        .next()
1014 +
                        .is_some_and(|(k, _)| k.starts_with(&prefix))
1015 +
                };
1016 +
                self.build_from_entries_seeded(
1017 +
                    &base,
1018 +
                    &base_snapshot,
1019 +
                    &entries,
1020 +
                    seed,
1021 +
                    Some(&dir_needed),
1022 +
                )?
1023 +
            } else {
1024 +
                self.build_from_entries_seeded(&base, &base_snapshot, &entries, seed, None)?
1025 +
            };
798 1026
            lap(4, &mut clock);
799 1027
            // A file or submodule with several live blobs (branches that
800 1028
            // replaced it independently) is resolved to the commit's blob:
…
47 unchanged lines
848 1076
                    ChangeKind::Normal
849 1077
                },
850 1078
            };
1079 +
            let t = std::time::Instant::now();
851 1080
            deps.extend(referenced_changes(&change, &base));
1081 +
            crate::timing::add("store: referenced", t.elapsed());
852 1082
            change.deps = deps.into_iter().collect();
853 1083
            change.normalize();
854 1084
            let hash = self.store.put_change(&change)?;
…
3 unchanged lines
858 1088
            heads_now.insert(hash);
859 1089
            // Built graphs take its edits whole; files not built keep only
860 1090
            // where its text is, read back from the store.
1091 +
            let t = std::time::Instant::now();
861 1092
            let (skeleton, texts) = self.store.get_change_lazy(&hash)?;
862 1093
            base.add_known_mixed(hash, change, skeleton, texts);
1094 +
            crate::timing::add("store: add to state", t.elapsed());
863 1095
            known.imported.insert(gc.sha.clone(), hash);
864 1096
            if waiting.get(gc.sha.as_str()).is_some_and(|n| *n > 0) {
865 1097
                kept.insert(gc.sha.clone(), (base, tree));
…
10 unchanged lines
876 1108
        }
877 1109
        for (name, d) in [
878 1110
            "git import: bases",
879 -
            "git import: ls-tree",
1111 +
            "git import: tree",
880 1112
            "git import: render base",
881 1113
            "git import: read blobs",
882 1114
            "git import: build change",
…
3 unchanged lines
886 1118
        .zip(spent)
887 1119
        {
888 1120
            crate::timing::record(name, d);
1121 +
        }
1122 +
        crate::timing::report_totals();
1123 +
        if crate::timing::enabled() {
1124 +
            eprintln!("timing git import: {rebuilt} bases rebuilt from history");
889 1125
        }
890 1126
891 1127
        // The bookmark: heads of the old state plus every new change.
…
439 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +43 −9
…
131 unchanged lines
132 132
    /// `build_from_entries` whose new nodes take identifiers derived from
133 133
    /// `seed` and their paths instead of random ones, so the same input
134 134
    /// always builds the same change (a Git import, spec §23).
135 +
    ///
136 +
    /// With `dir_needed`, the snapshot and entries hold only the paths the
137 +
    /// import touches (and the directories a removal may leave empty); a
138 +
    /// directory stays while `dir_needed` says the new tree has something
139 +
    /// under it.
135 140
    pub(crate) fn build_from_entries_seeded(
136 141
        &self,
137 142
        base: &State,
138 143
        base_snapshot: &Snapshot,
139 144
        fs_entries: &BTreeMap<String, FsEntry>,
140 145
        seed: [u8; 32],
146 +
        dir_needed: Option<&dyn Fn(&str) -> bool>,
141 147
    ) -> Result<BuiltChange> {
142 148
        self.build_from_entries_with(
143 149
            base,
…
1 unchanged lines
145 151
            HashMap::new(),
146 152
            fs_entries,
147 153
            &HashSet::new(),
148 -
            Some(seed),
154 +
            Some(Import { seed, dir_needed }),
149 155
        )
150 156
    }
151 157
…
4 unchanged lines
156 162
        reuse: HashMap<String, (NodeId, NodeKind)>,
157 163
        fs_entries: &BTreeMap<String, FsEntry>,
158 164
        resolve: &HashSet<String>,
159 -
        seed: Option<[u8; 32]>,
165 +
        import: Option<Import>,
160 166
    ) -> Result<BuiltChange> {
167 +
        let seed = import.as_ref().map(|i| i.seed);
168 +
        let dir_needed = import.and_then(|i| i.dir_needed);
169 +
        let mut laps = crate::timing::Laps::start();
161 170
        // Attributes come from the files being committed, so a new
162 171
        // `.oxyattributes` applies to the same change that adds it.
163 172
        let attrs = crate::attributes::Attributes::from_files(
…
5 unchanged lines
169 178
        let mut b = ChangeBuilder::new(base, base_snapshot, reuse).with_attributes(attrs);
170 179
        b.resolve = resolve.clone();
171 180
        b.node_seed = seed;
181 +
        laps.lap("build: builder");
172 182
        // Rename detection (spec §7.1, §23): a removed file and an added file
173 183
        // of the same kind whose contents are at least half alike become one
174 184
        // move of the same node, so identity survives across the rename.
…
6 unchanged lines
181 191
            .iter()
182 192
            .map(|(old, new)| (old.as_str(), new.as_str()))
183 193
            .collect();
194 +
        laps.lap("build: renames");
184 195
        for (path, fs_entry) in fs_entries {
185 196
            match base_snapshot.files.get(path) {
186 197
                None => match renamed_from.get(path.as_str()) {
…
5 unchanged lines
192 203
                Some(expected) => b.update_path(self, path, fs_entry, expected)?,
193 204
            }
194 205
        }
206 +
        laps.lap("build: paths");
195 207
        // A directory stays while anything under it exists or is out of
196 208
        // scope: the directories above each such path, gathered once.
197 209
        let mut needed_dirs: HashSet<&str> = HashSet::new();
…
21 unchanged lines
219 231
            {
220 232
                continue;
221 233
            }
222 -
            let still_needed =
223 -
                expected.kind == NodeKind::Dir && needed_dirs.contains(path.as_str());
234 +
            let still_needed = expected.kind == NodeKind::Dir
235 +
                && match dir_needed {
236 +
                    Some(f) => f(path),
237 +
                    None => needed_dirs.contains(path.as_str()),
238 +
                };
224 239
            if !still_needed {
225 240
                b.remove_path(path, expected);
226 241
            }
227 242
        }
243 +
        laps.lap("build: removals");
228 244
        if seed.is_some() {
229 245
            b.retire_stray_placements();
230 246
        }
247 +
        laps.lap("build: stray placements");
231 248
        let mut statuses = b.statuses;
232 249
        statuses.sort_by(|a, b| a.path.cmp(&b.path));
233 250
        Ok(BuiltChange {
…
1336 unchanged lines
1570 1587
    ) -> Self {
1571 1588
        let mut dir_nodes: HashMap<String, NodeId> = HashMap::new();
1572 1589
        dir_nodes.insert(String::new(), NodeId::ROOT);
1573 -
        for (p, f) in &base_snapshot.files {
1574 -
            if f.kind == NodeKind::Dir {
1575 -
                dir_nodes.insert(p.clone(), f.node);
1590 +
        for (p, e) in &base_snapshot.tree.entries {
1591 +
            if e.kind == NodeKind::Dir {
1592 +
                dir_nodes.insert(p.clone(), e.node);
1576 1593
            }
1577 1594
        }
1578 1595
        ChangeBuilder {
…
434 unchanged lines
2013 2030
                    });
2014 2031
                    status = Some(ChangeStatus::Modified);
2015 2032
                } else if bytes != &disk_text {
2016 -
                    let old = self.base.live_lines(&node);
2017 -
                    let edits = text_edits(node, &old, &disk_text);
2033 +
                    let mut laps = crate::timing::Laps::start();
2034 +
                    // The base's rendering holds its live atoms in order,
2035 +
                    // one per line (it is not conflicted here).
2036 +
                    let keys = materialized.live_atoms();
2037 +
                    let lines = crate::diff::split_lines(bytes);
2038 +
                    let edits = if keys.len() == lines.len() {
2039 +
                        crate::diff::text_edits_of(node, &keys, &lines, &disk_text)
2040 +
                    } else {
2041 +
                        text_edits(node, &self.base.live_lines(&node), &disk_text)
2042 +
                    };
2043 +
                    laps.lap("build: text diff");
2018 2044
                    if !edits.is_empty() {
2019 2045
                        self.edits.extend(edits);
2020 2046
                        status = Some(ChangeStatus::Modified);
…
356 unchanged lines
2377 2403
/// `RENAME_LIMIT` candidate pairs only identical contents are paired.
2378 2404
const RENAME_THRESHOLD: u32 = 50;
2379 2405
const RENAME_LIMIT: usize = 10_000;
2406 +
2407 +
/// What a Git import adds to building a change: the seed its new nodes'
2408 +
/// identifiers derive from, and, when it builds from part of the tree,
2409 +
/// whether the new tree has anything under a directory.
2410 +
struct Import<'a> {
2411 +
    seed: [u8; 32],
2412 +
    dir_needed: Option<&'a dyn Fn(&str) -> bool>,
2413 +
}
2380 2414
2381 2415
fn detect_renames(
2382 2416
    base_snapshot: &Snapshot,
…
101 unchanged lines
modified crates/oxy-core/src/state.rs +19 −0
…
1224 unchanged lines
1225 1225
        }
1226 1226
    }
1227 1227
1228 +
    /// A snapshot of only `paths` (those `view` has), `view` being this
1229 +
    /// state's tree view: for a caller that works from what changed. The
1230 +
    /// graphs of those text files must be loaded.
1231 +
    pub fn materialize_paths<'p>(
1232 +
        &self,
1233 +
        view: crate::tree::TreeView,
1234 +
        paths: impl IntoIterator<Item = &'p str>,
1235 +
    ) -> Snapshot {
1236 +
        let files = paths
1237 +
            .into_iter()
1238 +
            .filter_map(|p| Some((p.to_string(), self.file_state(view.entries.get(p)?))))
1239 +
            .collect();
1240 +
        Snapshot {
1241 +
            tree: view,
1242 +
            files,
1243 +
            unrendered: BTreeSet::new(),
1244 +
        }
1245 +
    }
1246 +
1228 1247
    fn file_state(&self, e: &crate::tree::TreeEntry) -> FileState {
1229 1248
        {
1230 1249
            let content = match e.kind {
…
750 unchanged lines
modified crates/oxy-core/src/timing.rs +44 −0
…
41 unchanged lines
42 42
        }
43 43
    }
44 44
}
45 +
46 +
static TOTALS: std::sync::Mutex<Vec<(&'static str, std::time::Duration)>> =
47 +
    std::sync::Mutex::new(Vec::new());
48 +
49 +
/// Add `d` to the running total for `name`, for a phase repeated many
50 +
/// times (once per imported commit, say); `report_totals` prints them.
51 +
pub fn add(name: &'static str, d: std::time::Duration) {
52 +
    if !enabled() {
53 +
        return;
54 +
    }
55 +
    let mut t = TOTALS.lock().unwrap_or_else(|e| e.into_inner());
56 +
    match t.iter_mut().find(|(n, _)| *n == name) {
57 +
        Some((_, total)) => *total += d,
58 +
        None => t.push((name, d)),
59 +
    }
60 +
}
61 +
62 +
/// Print and clear the totals `add` gathered.
63 +
pub fn report_totals() {
64 +
    if !enabled() {
65 +
        return;
66 +
    }
67 +
    let t = std::mem::take(&mut *TOTALS.lock().unwrap_or_else(|e| e.into_inner()));
68 +
    for (name, d) in t {
69 +
        record(name, d);
70 +
    }
71 +
}
72 +
73 +
/// Times from one point to the next, adding each interval to a total.
74 +
pub struct Laps(Option<Instant>);
75 +
76 +
impl Laps {
77 +
    pub fn start() -> Laps {
78 +
        Laps(enabled().then(Instant::now))
79 +
    }
80 +
    /// Add the time since the last lap to `name`'s total.
81 +
    pub fn lap(&mut self, name: &'static str) {
82 +
        if let Some(t) = &mut self.0 {
83 +
            let now = Instant::now();
84 +
            add(name, now - *t);
85 +
            *t = now;
86 +
        }
87 +
    }
88 +
}
modified crates/oxy-core/src/tree.rs +59 −12
…
28 unchanged lines
29 29
    deleted_modes: HashMap<OpKey, BTreeSet<ChangeHash>>,
30 30
    placements_of: HashMap<NodeId, BTreeSet<OpKey>>,
31 31
    modes_of: HashMap<NodeId, BTreeSet<OpKey>>,
32 +
    /// The live subsets of `placements_of` and `modes_of`, kept as changes
33 +
    /// apply, so the view walks what is live rather than all history.
34 +
    live_placements_of: HashMap<NodeId, BTreeSet<OpKey>>,
35 +
    live_modes_of: HashMap<NodeId, BTreeSet<OpKey>>,
32 36
    /// The view, once computed; cleared by every change to the graph.
33 37
    view_cache: std::sync::OnceLock<TreeView>,
34 38
}
…
77 unchanged lines
112 116
                    },
113 117
                );
114 118
                self.placements_of.entry(*node).or_default().insert(key);
119 +
                self.index_placement(key);
115 120
                if *kind != NodeKind::Dir {
116 121
                    self.modes.insert(key, (*node, *mode));
117 122
                    self.modes_of.entry(*node).or_default().insert(key);
123 +
                    self.index_mode(key);
118 124
                }
119 125
            }
120 126
            TreeOp::RemoveNode { placement, .. } => {
…
1 unchanged lines
122 128
                    .entry(op_key(*placement))
123 129
                    .or_default()
124 130
                    .insert(change);
131 +
                self.index_placement(op_key(*placement));
125 132
            }
126 133
            TreeOp::Move {
127 134
                node,
…
5 unchanged lines
133 140
                    .entry(op_key(*old))
134 141
                    .or_default()
135 142
                    .insert(change);
143 +
                self.index_placement(op_key(*old));
136 144
                self.placements.insert(
137 145
                    key,
138 146
                    Placement {
…
3 unchanged lines
142 150
                    },
143 151
                );
144 152
                self.placements_of.entry(*node).or_default().insert(key);
153 +
                self.index_placement(key);
145 154
            }
146 155
            TreeOp::SetMode { node, old, mode } => {
147 156
                self.deleted_modes
148 157
                    .entry(op_key(*old))
149 158
                    .or_default()
150 159
                    .insert(change);
160 +
                self.index_mode(op_key(*old));
151 161
                self.modes.insert(key, (*node, *mode));
152 162
                self.modes_of.entry(*node).or_default().insert(key);
163 +
                self.index_mode(key);
164 +
            }
165 +
        }
166 +
    }
167 +
168 +
    /// Bring placement `k` in the live index up to date.
169 +
    fn index_placement(&mut self, k: OpKey) {
170 +
        let Some(node) = self.placements.get(&k).map(|p| p.node) else {
171 +
            return;
172 +
        };
173 +
        if self.placement_live(&k) {
174 +
            self.live_placements_of.entry(node).or_default().insert(k);
175 +
        } else if let Some(set) = self.live_placements_of.get_mut(&node) {
176 +
            set.remove(&k);
177 +
            if set.is_empty() {
178 +
                self.live_placements_of.remove(&node);
153 179
            }
154 180
        }
155 181
    }
156 182
183 +
    /// Bring mode atom `k` in the live index up to date.
184 +
    fn index_mode(&mut self, k: OpKey) {
185 +
        let Some(node) = self.modes.get(&k).map(|(n, _)| *n) else {
186 +
            return;
187 +
        };
188 +
        if self.mode_live(&k) {
189 +
            self.live_modes_of.entry(node).or_default().insert(k);
190 +
        } else if let Some(set) = self.live_modes_of.get_mut(&node) {
191 +
            set.remove(&k);
192 +
            if set.is_empty() {
193 +
                self.live_modes_of.remove(&node);
194 +
            }
195 +
        }
196 +
    }
197 +
157 198
    /// Directories no longer placed anywhere, by the (parent, name) they
158 199
    /// were last placed at, with that placement: what an import can bring
159 200
    /// back rather than make a second node for one Git path.
…
61 unchanged lines
221 262
    }
222 263
223 264
    pub fn live_placements(&self, node: &NodeId) -> Vec<(OpKey, &Placement)> {
224 -
        self.placements_of
265 +
        self.live_placements_of
225 266
            .get(node)
226 267
            .into_iter()
227 268
            .flat_map(|ks| ks.iter())
228 -
            .filter(|k| self.placement_live(k))
229 269
            .filter_map(|k| self.placements.get(k).map(|p| (*k, p)))
230 270
            .collect()
231 271
    }
232 272
233 273
    pub fn live_modes(&self, node: &NodeId) -> Vec<(OpKey, Mode)> {
234 -
        self.modes_of
274 +
        self.live_modes_of
235 275
            .get(node)
236 276
            .into_iter()
237 277
            .flat_map(|ks| ks.iter())
238 -
            .filter(|k| self.mode_live(k))
239 278
            .filter_map(|k| self.modes.get(k).map(|(_, m)| (*k, *m)))
240 279
            .collect()
241 280
    }
242 281
243 282
    /// Every node that has at least one live placement.
244 283
    pub fn placed_nodes(&self) -> impl Iterator<Item = NodeId> + '_ {
245 -
        self.placements_of
246 -
            .keys()
247 -
            .copied()
248 -
            .filter(|n| !self.live_placements(n).is_empty())
284 +
        self.live_placements_of.keys().copied()
249 285
    }
250 286
251 287
    /// Resolve the tree: assign paths, find conflicts.
…
8 unchanged lines
260 296
261 297
        // Live placements grouped by parent.
262 298
        let mut children: HashMap<NodeId, Vec<(OpKey, &Placement)>> = HashMap::new();
263 -
        for (k, p) in &self.placements {
264 -
            if self.placement_live(k) {
265 -
                children.entry(p.parent).or_default().push((*k, p));
299 +
        for ks in self.live_placements_of.values() {
300 +
            for k in ks {
301 +
                if let Some(p) = self.placements.get(k) {
302 +
                    children.entry(p.parent).or_default().push((*k, p));
303 +
                }
266 304
            }
267 305
        }
268 306
        for c in children.values_mut() {
…
1 unchanged lines
270 308
        }
271 309
272 310
        // Multi-placement and mode conflicts.
273 -
        let mut nodes: Vec<NodeId> = self.placements_of.keys().copied().collect();
311 +
        // Only placed nodes: one with no live placement is not in the tree.
312 +
        let mut nodes: Vec<NodeId> = self.live_placements_of.keys().copied().collect();
274 313
        nodes.sort();
275 314
        let mut multi: HashSet<NodeId> = HashSet::new();
276 315
        for n in &nodes {
…
276 unchanged lines
553 592
            g.modes_of.entry(n).or_default().insert(k);
554 593
        }
555 594
        g.deleted_modes = deleters(field(m, 4, "deleted modes")?, "deleted mode")?;
595 +
        let keys: Vec<OpKey> = g.placements.keys().copied().collect();
596 +
        for k in keys {
597 +
            g.index_placement(k);
598 +
        }
599 +
        let keys: Vec<OpKey> = g.modes.keys().copied().collect();
600 +
        for k in keys {
601 +
            g.index_mode(k);
602 +
        }
556 603
        Ok(g)
557 604
    }
558 605
}
…
326 unchanged lines