OXYGENHUB

A snapshot builds only the paths that may have changed 23ff1288a5dd

When a snapshot finds most files unchanged by the manifest (status,
diff, commit and the other commands that take a partial snapshot), the
change is built from the paths that may differ: files read rather than
known unchanged or whose mode changed, tracked files gone from disk,
and the directories those may leave empty (kept while anything on disk
is under them). The rest of the tree is neither compared nor walked by
the builder. A test build checks every such build against the full
one. On the Linux tip with one file edited, the build takes 0.06 s
rather than 0.15 s.

refs #41
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-30 10:46 UTC
Signature
✔ signed by Andy McCall
Hash
23ff1288a5ddb61e548fbf34e65a6f0ea7bacce8a1ded054ec4ac58991c9076c
Depends on
0bdc3f91a39f 2306a4d24779 982864068a59 c3069537d7c5 dfb23a3a8cfa f7510b63cceb
Depended on by
157d02e95586 d616faf5d136
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
9 atoms, 0 tree operations, 2 files

Browse the tree at this change

modified crates/oxy-core/src/repo/mod.rs +1 −0
…
130 unchanged lines
131 131
}
132 132
133 133
/// A file as found on disk or in an imported tree.
134 +
#[derive(Clone)]
134 135
pub(crate) struct FsEntry {
135 136
    pub kind: NodeKind,
136 137
    pub exec: bool,
…
1593 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +132 −11
…
125 unchanged lines
126 126
        fs_entries: &BTreeMap<String, FsEntry>,
127 127
        resolve: &HashSet<String>,
128 128
    ) -> Result<BuiltChange> {
129 -
        self.build_from_entries_with(base, base_snapshot, reuse, fs_entries, resolve, None)
129 +
        self.build_from_entries_with(
130 +
            base,
131 +
            base_snapshot,
132 +
            reuse,
133 +
            fs_entries,
134 +
            resolve,
135 +
            Scope::default(),
136 +
        )
130 137
    }
131 138
132 139
    /// `build_from_entries` whose new nodes take identifiers derived from
…
18 unchanged lines
151 158
            HashMap::new(),
152 159
            fs_entries,
153 160
            &HashSet::new(),
154 -
            Some(Import { seed, dir_needed }),
161 +
            Scope {
162 +
                seed: Some(seed),
163 +
                dir_needed,
164 +
            },
155 165
        )
156 166
    }
157 167
168 +
    /// `build_from_entries` for a snapshot most of whose files are known
169 +
    /// unchanged (oxygen#41): only the paths that may differ are built
170 +
    /// (files read rather than known unchanged, base files gone from disk,
171 +
    /// and the directories those may leave empty), against a snapshot of
172 +
    /// just those, so a large tree with a few edits costs what they touch.
173 +
    fn build_changed_only(
174 +
        &self,
175 +
        base: &State,
176 +
        base_snapshot: &Snapshot,
177 +
        reuse: HashMap<String, (NodeId, NodeKind)>,
178 +
        fs_entries: &BTreeMap<String, FsEntry>,
179 +
    ) -> Result<BuiltChange> {
180 +
        // A file known unchanged may still have changed mode: that alters
181 +
        // neither its size nor its time.
182 +
        let changed: BTreeMap<String, FsEntry> = fs_entries
183 +
            .iter()
184 +
            .filter(|(p, e)| {
185 +
                !e.unchanged
186 +
                    || base_snapshot
187 +
                        .files
188 +
                        .get(*p)
189 +
                        .is_none_or(|f| f.kind != e.kind || f.mode.exec != e.exec)
190 +
            })
191 +
            .map(|(p, e)| (p.clone(), e.clone()))
192 +
            .collect();
193 +
        let mut files: BTreeMap<String, crate::state::FileState> = BTreeMap::new();
194 +
        for p in changed.keys() {
195 +
            if let Some(f) = base_snapshot.files.get(p) {
196 +
                files.insert(p.clone(), f.clone());
197 +
            }
198 +
        }
199 +
        for (p, f) in &base_snapshot.files {
200 +
            if f.kind == NodeKind::Dir || fs_entries.contains_key(p) {
201 +
                continue;
202 +
            }
203 +
            files.insert(p.clone(), f.clone());
204 +
            let mut end = p.len();
205 +
            while let Some(i) = p[..end].rfind('/') {
206 +
                let dir = &p[..i];
207 +
                if files.contains_key(dir) {
208 +
                    break;
209 +
                }
210 +
                if let Some(d) = base_snapshot.files.get(dir) {
211 +
                    files.insert(dir.to_string(), d.clone());
212 +
                }
213 +
                end = i;
214 +
            }
215 +
        }
216 +
        let part = Snapshot {
217 +
            tree: base_snapshot.tree.clone(),
218 +
            files,
219 +
            unrendered: BTreeSet::new(),
220 +
        };
221 +
        // Whether anything on disk is under `dir`.
222 +
        let dir_needed = |dir: &str| {
223 +
            let prefix = format!("{dir}/");
224 +
            fs_entries
225 +
                .range(prefix.clone()..)
226 +
                .next()
227 +
                .is_some_and(|(k, _)| k.starts_with(&prefix))
228 +
        };
229 +
        self.build_from_entries_with(
230 +
            base,
231 +
            &part,
232 +
            reuse,
233 +
            &changed,
234 +
            &HashSet::new(),
235 +
            Scope {
236 +
                seed: None,
237 +
                dir_needed: Some(&dir_needed),
238 +
            },
239 +
        )
240 +
    }
241 +
158 242
    fn build_from_entries_with(
159 243
        &self,
160 244
        base: &State,
…
1 unchanged lines
162 246
        reuse: HashMap<String, (NodeId, NodeKind)>,
163 247
        fs_entries: &BTreeMap<String, FsEntry>,
164 248
        resolve: &HashSet<String>,
165 -
        import: Option<Import>,
249 +
        scope: Scope,
166 250
    ) -> Result<BuiltChange> {
167 -
        let seed = import.as_ref().map(|i| i.seed);
168 -
        let dir_needed = import.and_then(|i| i.dir_needed);
251 +
        let Scope { seed, dir_needed } = scope;
169 252
        let mut laps = crate::timing::Laps::start();
170 253
        // Attributes come from the files being committed, so a new
171 254
        // `.oxyattributes` applies to the same change that adds it.
…
200 unchanged lines
372 455
        self.platform_normalize(&mut fs_entries, &base_snapshot);
373 456
        let built = {
374 457
            let _t = crate::timing::phase("snapshot: diff against base");
375 -
            self.build_from_entries(&base, &base_snapshot, reuse, &fs_entries)?
458 +
            if partial && self.config.sparse_paths.is_empty() {
459 +
                #[cfg(test)]
460 +
                let full =
461 +
                    self.build_from_entries(&base, &base_snapshot, reuse.clone(), &fs_entries)?;
462 +
                let built = self.build_changed_only(&base, &base_snapshot, reuse, &fs_entries)?;
463 +
                #[cfg(test)]
464 +
                {
465 +
                    // New nodes take random identifiers: compare them by
466 +
                    // order of appearance.
467 +
                    let norm = |b: &BuiltChange| {
468 +
                        let text = format!("{:?} {:?} {:?}", b.tree_ops, b.edits, b.statuses);
469 +
                        let mut seen: Vec<String> = Vec::new();
470 +
                        let mut out = String::new();
471 +
                        let mut rest = text.as_str();
472 +
                        while let Some(i) = rest.find("NodeId(") {
473 +
                            out.push_str(&rest[..i]);
474 +
                            let end = i + rest[i..].find(')').unwrap() + 1;
475 +
                            let id = rest[i..end].to_string();
476 +
                            let n = seen.iter().position(|x| *x == id).unwrap_or_else(|| {
477 +
                                seen.push(id);
478 +
                                seen.len() - 1
479 +
                            });
480 +
                            out.push_str(&format!("N{n}"));
481 +
                            rest = &rest[end..];
482 +
                        }
483 +
                        out.push_str(rest);
484 +
                        out
485 +
                    };
486 +
                    assert_eq!(
487 +
                        norm(&built),
488 +
                        norm(&full),
489 +
                        "a changed-only build is the full one"
490 +
                    );
491 +
                }
492 +
                built
493 +
            } else {
494 +
                self.build_from_entries(&base, &base_snapshot, reuse, &fs_entries)?
495 +
            }
376 496
        };
377 497
        // Text files found equal to an unconflicted base rendering keep its
378 498
        // graph key, so the next snapshot need not read or render them.
…
2025 unchanged lines
2404 2524
const RENAME_THRESHOLD: u32 = 50;
2405 2525
const RENAME_LIMIT: usize = 10_000;
2406 2526
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],
2527 +
/// How a change is built beyond its entries: for a Git import, the seed
2528 +
/// its new nodes' identifiers derive from; and, when it is built from part
2529 +
/// of the tree, whether the new tree has anything under a directory.
2530 +
#[derive(Default)]
2531 +
struct Scope<'a> {
2532 +
    seed: Option<[u8; 32]>,
2412 2533
    dir_needed: Option<&'a dyn Fn(&str) -> bool>,
2413 2534
}
2414 2535
…
103 unchanged lines