OXYGENHUB

Check out against the manifest, without the base state 0554aaa7ddef

After the manifest check or a snapshot the manifest says, for every
file on disk, which graph it renders (its graph key), whether it is
executable, and its blob when opaque. A checkout therefore needs only
the target: a text file is left alone when its manifest key is the
target's, an opaque file when its blob is; removals come from the
manifest and the working copy's own changes, and a nested repository
is recognised on disk. What the new state lacks is counted by a walk
from the old heads that stops at the target. Files left as they were
keep their known stat in the new manifest instead of being statted
again. A one-file switch on the Linux tip takes 2.1 s and 455 MB (from
3.3 s and 705 MB).

refs #35
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 22:48 UTC
Signature
✔ signed by Andy McCall
Hash
0554aaa7ddef03e68e06b8ff9dfe1a2787602e26acdff916f956250a2e142287
Depends on
135212de5370 1d360c91a289 75167672ebcd 982864068a59 a4fad5b085c7 b7f102fcbb17 c5ce0367eddf cc7a00565161 d3cd7ef95da6
Depended on by
1966f5db15ee
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
21 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified crates/oxy-core/src/repo/checkout.rs +46 −43
…
40 unchanged lines
41 41
        let _wc = self.lock_working_copy()?;
42 42
        let _all = crate::timing::phase("checkout");
43 43
        // A working copy clean by the manifest needs no snapshot; otherwise a
44 -
        // status snapshot, which neither reads nor renders files the
45 -
        // manifest proves unchanged, and whose base is history only
46 -
        // (oxygen#35).
47 -
        let (base, changed_paths) = if self.clean_by_manifest()? {
48 -
            let (_, wc) = self.working_copy()?;
49 -
            (self.load_state_history(&wc.deps)?, Vec::new())
44 +
        // status snapshot records what changed, and rewrites the manifest
45 +
        // with the graph key of every file it proves unchanged. Either way
46 +
        // the manifest then says, for each file on disk, which graph it
47 +
        // renders, so the base state itself is not needed (oxygen#35).
48 +
        let changed_paths: Vec<String> = if self.clean_by_manifest()? {
49 +
            Vec::new()
50 50
        } else {
51 51
            let snap = self.snapshot_status(command)?;
52 52
            self.refuse_dirty(&snap, discard)?;
53 -
            let paths = snap.statuses.iter().map(|s| s.path.clone()).collect();
54 -
            (snap.base, paths)
53 +
            snap.statuses.iter().map(|s| s.path.clone()).collect()
55 54
        };
56 -
        let views = crate::timing::phase("checkout: manifest and base view");
55 +
        let (_, wc_now) = self.working_copy()?;
57 56
        let old_manifest = self.read_manifest()?.map(|(_, _, m)| m).unwrap_or_default();
58 -
        let bview = base.tree().view();
59 -
        drop(views);
60 57
        let changed: HashSet<&str> = changed_paths.iter().map(String::as_str).collect();
61 58
        // The target's history; its file graphs are built a batch at a time
62 59
        // for the files that must be written, and dropped after.
…
9 unchanged lines
72 69
                .problems(tview.entries.keys().map(String::as_str)),
73 70
        );
74 71
        report.conflicted = tview.is_conflicted();
75 -
        report.lacks = base.changes().filter(|(h, _)| !target.contains(h)).count();
72 +
        report.lacks = self.count_lacking(&wc_now.deps, &target)?;
76 73
77 74
        // Remove what the base or the working copy has and the target does
78 75
        // not. A nested repository is never deleted (spec §7.8).
79 76
        let mut dirs_to_prune: BTreeSet<PathBuf> = BTreeSet::new();
80 -
        let gone: BTreeSet<&str> = bview
77 +
        let gone: BTreeSet<&str> = old_manifest
81 78
            .entries
82 -
            .iter()
83 -
            .filter(|(_, f)| f.kind != NodeKind::Dir)
84 -
            .map(|(p, _)| p.as_str())
79 +
            .keys()
80 +
            .map(String::as_str)
85 81
            .chain(changed.iter().copied())
86 82
            .filter(|p| !tview.entries.contains_key(*p))
87 83
            .collect();
88 84
        for path in gone {
89 -
            if bview
90 -
                .entries
91 -
                .get(path)
92 -
                .is_some_and(|f| f.kind == NodeKind::Subrepo)
93 -
            {
85 +
            let dir = self.root.join(path);
86 +
            if dir.join(OXY_DIR).exists() || dir.join(".git").exists() {
94 87
                report
95 88
                    .notes
96 89
                    .push(format!("{path}: nested repository left in place"));
…
20 unchanged lines
117 110
            if e.kind == NodeKind::Dir || !self.in_scope(path) {
118 111
                continue;
119 112
            }
120 -
            let untouched = !changed.contains(path.as_str())
121 -
                && bview
122 -
                    .entries
123 -
                    .get(path)
124 -
                    .is_some_and(|b| b.node == e.node && b.kind == e.kind && b.mode == e.mode);
113 +
            // The manifest entry of a file the working copy has not changed,
114 +
            // with the right executable bit.
115 +
            let exec_ok =
116 +
                |m: &prompt::ManifestEntry| !self.platform.exec_bits || m.exec == e.mode.exec;
117 +
            let entry = old_manifest
118 +
                .entries
119 +
                .get(path)
120 +
                .filter(|m| !changed.contains(path.as_str()) && exec_ok(m));
125 121
            if e.kind == NodeKind::File && !e.mode.opaque {
126 -
                // The manifest proves the file on disk equal to the base's
127 -
                // rendering (its key is the base graph's, and its stat was
128 -
                // checked just now), and the graph is the same in both
129 -
                // states: nothing to read, render or write.
130 -
                let key = base.graph_key(&e.node);
131 -
                let same_graph = untouched
132 -
                    && !is_attributes(path)
133 -
                    && key.is_some()
134 -
                    && key == target.graph_key(&e.node);
122 +
                // The manifest proves the file on disk equal to the rendering
123 +
                // of a graph whose key is the target's: nothing to read,
124 +
                // render or write.
125 +
                let key = target.graph_key(&e.node).map(|k| ObjectRef(k).to_hex());
135 126
                let proven = |m: &&prompt::ManifestEntry| {
136 -
                    same_graph
137 -
                        && m.graph.as_deref() == key.map(|k| ObjectRef(k).to_hex()).as_deref()
127 +
                    !is_attributes(path) && key.is_some() && m.graph == key
138 128
                };
139 -
                match old_manifest.entries.get(path).filter(proven) {
129 +
                match entry.filter(proven) {
140 130
                    Some(m) => {
141 131
                        entries.insert(
142 132
                            path.clone(),
…
1 unchanged lines
144 134
                                content_hash: Some(m.hash.clone()),
145 135
                                graph_key: m.graph.clone(),
146 136
                                unchanged: true,
137 +
                                stat: Some((m.size, m.mtime_ns)),
147 138
                                ..FsEntry::new(NodeKind::File, e.mode.exec, Vec::new())
148 139
                            },
149 140
                        );
…
25 unchanged lines
175 166
                report.notes.extend(sync.note(path));
176 167
                continue;
177 168
            }
178 -
            let same_blob = untouched && base.live_blobs(&e.node) == blobs;
179 -
            if let Some(m) = old_manifest
180 -
                .entries
181 -
                .get(path)
182 -
                .filter(|_| same_blob && blobs.len() == 1)
169 +
            if let Some(m) = entry
170 +
                .filter(|m| e.kind == NodeKind::File && blobs.len() == 1 && m.blob == Some(blob))
183 171
            {
184 172
                entries.insert(
185 173
                    path.clone(),
186 174
                    FsEntry {
187 175
                        content_hash: Some(m.hash.clone()),
188 176
                        blob: (e.kind == NodeKind::File).then_some(blob),
177 +
                        stat: Some((m.size, m.mtime_ns)),
189 178
                        ..FsEntry::new(e.kind, e.mode.exec, Vec::new())
190 179
                    },
191 180
                );
…
141 unchanged lines
333 322
            ),
334 323
        );
335 324
        Ok(report)
325 +
    }
326 +
327 +
    /// How many changes of the state at `from` the state `to` lacks: a walk
328 +
    /// down from `from` that stops at what `to` holds.
329 +
    fn count_lacking(&self, from: &[ChangeHash], to: &State) -> Result<usize> {
330 +
        let mut seen: HashSet<ChangeHash> = HashSet::new();
331 +
        let mut stack: Vec<ChangeHash> = from.to_vec();
332 +
        while let Some(h) = stack.pop() {
333 +
            if to.contains(&h) || !seen.insert(h) {
334 +
                continue;
335 +
            }
336 +
            stack.extend(self.store.get_change_lazy(&h)?.0.deps);
337 +
        }
338 +
        Ok(seen.len())
336 339
    }
337 340
338 341
    /// Refuse to leave uncommitted work behind unless `discard`.
…
109 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +4 −0
…
148 unchanged lines
149 149
    pub unchanged: bool,
150 150
    /// The base graph key this file equals, for the next manifest.
151 151
    pub graph_key: Option<String>,
152 +
    /// Size and mtime already known to be current (a checkout leaving the
153 +
    /// file as it was), so the manifest need not stat it again.
154 +
    pub stat: Option<(u64, u128)>,
152 155
}
153 156
154 157
impl FsEntry {
…
6 unchanged lines
161 164
            content_hash: None,
162 165
            unchanged: false,
163 166
            graph_key: None,
167 +
            stat: None,
164 168
        }
165 169
    }
166 170
}
…
1505 unchanged lines
modified crates/oxy-core/src/repo/prompt.rs +6 −3
…
217 unchanged lines
218 218
        let mut newest_ns: u128 = 0;
219 219
        for (path, e) in entries {
220 220
            let full = self.root.join(path);
221 -
            let (size, mtime_ns) = match fs::symlink_metadata(&full) {
222 -
                Ok(m) => (m.len(), mtime_ns(&m)),
223 -
                Err(_) => (e.content.len() as u64, 0),
221 +
            let (size, mtime_ns) = match e.stat {
222 +
                Some(known) => known,
223 +
                None => match fs::symlink_metadata(&full) {
224 +
                    Ok(m) => (m.len(), mtime_ns(&m)),
225 +
                    Err(_) => (e.content.len() as u64, 0),
226 +
                },
224 227
            };
225 228
            let hash = match &e.content_hash {
226 229
                Some(h) => h.clone(),
…
323 unchanged lines
modified docs/benchmarks.md +9 −0
…
339 unchanged lines
340 340
the target's history 0.5 s each, their tree views 0.3 s each, planning
341 341
0.2 s, and writing the manifest 0.6 s.
342 342
343 +
A checkout now needs no base state at all: after the manifest check or a
344 +
snapshot, the manifest says which graph each file on disk renders. A
345 +
file is skipped when its manifest key is the target's, and a file left
346 +
as it was keeps its stat in the new manifest instead of being statted
347 +
again. What the new state lacks is found by a walk from the old heads
348 +
that stops at the target. A one-file switch takes 2.1 to 2.2 s and 455
349 +
MB: the manifest check 0.4 s, the target's history 0.5 s and tree view
350 +
0.3 s, planning 0.16 s, and writing the manifest 0.4 s.
351 +
343 352
Before that, most of a switch was loading history: the base's and the target's
344 353
history each decode the one huge first change (1.2 s and 364 MB each).
345 354
A clone holds the whole bundle in memory.
…
1 unchanged lines