OXYGENHUB

Check out only what differs, a batch at a time c5ce0367eddf

A checkout built and rendered every file of the target, read the whole
working copy to compare, and walked and hashed it all again for the
manifest; on the Linux tree a bookmark switch or a clone was killed at
14 GB. It now starts from a status snapshot and the target's history:
a text file the manifest proves unchanged whose graph key is the same in
both states is neither read, rendered nor written; an opaque file or
symlink with the same blob likewise. The rest are rendered 2048 at a
time from a history-only state whose graphs are dropped after each
batch, attribute files first. Removals come from the base's tree and
the working copy's own additions, and the manifest is written from what
the checkout already knows. On the Linux tip a one-file switch now
takes about 7 s and 1.3 GB, and a local clone 98 s and 7.9 GB.

refs #35
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 22:17 UTC
Signature
✔ signed by Andy McCall
Hash
c5ce0367eddf86da8a4e69d097749b12178b4af60c616dbf91a12cb131419302
Depends on
1a3b62032ed9 1d360c91a289 4dee277c327a 696bbf29d3eb 6caacfff1d4f 7421309f5372 75167672ebcd 86e1824f2d0b 982864068a59 b7f102fcbb17 c863c7a7a2d1 cc7a00565161 cdb5beba7f05 ff0af1ee299d
Depended on by
4afe288b7946 c2e1d09047db 9b723f69b032 0389c3e82f6c 091fef80755d 638fb21420d7 1966f5db15ee e83354cdac22 2c75672fb2fe 598009a75f6c 69816d688300 0554aaa7ddef d3cd7ef95da6 a4fad5b085c7
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
20 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified crates/oxy-core/src/repo/checkout.rs +233 −108
…
10 unchanged lines
11 11
    pub notes: Vec<String>,
12 12
}
13 13
14 +
/// Text files rendered at a time by a checkout: bounds its memory.
15 +
const CHECKOUT_BATCH: usize = 2048;
16 +
14 17
impl Repo {
15 18
    /// Move the working copy to the state whose heads are `heads` (spec §12.3):
16 19
    /// the working directory is rewritten to that state and a fresh
…
17 unchanged lines
34 37
        keep_subrepos: &BTreeSet<String>,
35 38
    ) -> Result<CheckoutReport> {
36 39
        let _wc = self.lock_working_copy()?;
37 -
        let snap = self.snapshot(command)?;
40 +
        // A status snapshot: files the manifest proves unchanged are neither
41 +
        // read nor rendered, and its base is history only (oxygen#35).
42 +
        let snap = self.snapshot_status(command)?;
38 43
        if !discard && (!snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty()) {
39 44
            let list: Vec<String> = snap
40 45
                .statuses
…
7 unchanged lines
48 53
                if snap.statuses.len() > 8 { ", …" } else { "" }
49 54
            ));
50 55
        }
51 -
        let target_state = self.load_state(heads)?;
52 -
        let heads = target_state.maximal(heads);
53 -
        let target = target_state.materialize();
54 -
        let attrs = Repo::state_attributes(&target);
55 -
        let current = self.walk_working_copy()?;
56 +
        let old_manifest = self.read_manifest()?.map(|(_, _, m)| m).unwrap_or_default();
57 +
        let base = &snap.base;
58 +
        let base_files = &snap.base_snapshot.files;
59 +
        let changed: HashSet<&str> = snap.statuses.iter().map(|s| s.path.as_str()).collect();
60 +
        // The target's history; its file graphs are built a batch at a time
61 +
        // for the files that must be written, and dropped after.
62 +
        let mut target = self.load_state_history(heads)?;
63 +
        let heads = target.maximal(heads);
64 +
        let tview = target.tree().view();
56 65
        let mut report = CheckoutReport::default();
57 66
        // Paths this file system cannot hold side by side (spec §7.7).
58 67
        report.notes.extend(
59 68
            self.platform
60 -
                .problems(target.files.keys().map(String::as_str)),
69 +
                .problems(tview.entries.keys().map(String::as_str)),
61 70
        );
71 +
        report.conflicted = tview.is_conflicted();
62 72
63 -
        // Remove tracked paths that the target does not have. A nested
64 -
        // repository is never deleted (spec §7.8); it is left in place.
73 +
        // Remove what the base or the working copy has and the target does
74 +
        // not. A nested repository is never deleted (spec §7.8).
65 75
        let mut dirs_to_prune: BTreeSet<PathBuf> = BTreeSet::new();
66 -
        for (path, entry) in &current {
67 -
            if !target.files.contains_key(path) {
68 -
                if entry.kind == NodeKind::Subrepo {
69 -
                    report
70 -
                        .notes
71 -
                        .push(format!("{path}: nested repository left in place"));
76 +
        let gone: BTreeSet<&str> = base_files
77 +
            .iter()
78 +
            .filter(|(_, f)| f.kind != NodeKind::Dir)
79 +
            .map(|(p, _)| p.as_str())
80 +
            .chain(changed.iter().copied())
81 +
            .filter(|p| !tview.entries.contains_key(*p))
82 +
            .collect();
83 +
        for path in gone {
84 +
            if base_files
85 +
                .get(path)
86 +
                .is_some_and(|f| f.kind == NodeKind::Subrepo)
87 +
            {
88 +
                report
89 +
                    .notes
90 +
                    .push(format!("{path}: nested repository left in place"));
91 +
                continue;
92 +
            }
93 +
            let full = self.safe_path(path)?;
94 +
            if fs::symlink_metadata(&full).is_err() {
95 +
                continue;
96 +
            }
97 +
            fs::remove_file(&full)?;
98 +
            report.removed += 1;
99 +
            if let Some(parent) = full.parent() {
100 +
                dirs_to_prune.insert(parent.to_path_buf());
101 +
            }
102 +
        }
103 +
104 +
        // The manifest after the checkout, built from what is known.
105 +
        let mut entries: BTreeMap<String, FsEntry> = BTreeMap::new();
106 +
        let hex = |b: &[u8]| blake3::hash(b).to_hex().to_string();
107 +
        // Text files to render, attribute files first (the others need them).
108 +
        let mut texts: Vec<(String, NodeId, Mode)> = Vec::new();
109 +
        for (path, e) in &tview.entries {
110 +
            if e.kind == NodeKind::Dir || !self.in_scope(path) {
111 +
                continue;
112 +
            }
113 +
            let untouched = !changed.contains(path.as_str())
114 +
                && base_files
115 +
                    .get(path)
116 +
                    .is_some_and(|b| b.node == e.node && b.kind == e.kind && b.mode == e.mode);
117 +
            if e.kind == NodeKind::File && !e.mode.opaque {
118 +
                // Unchanged on disk (unrendered by the snapshot) and the same
119 +
                // graph in both states: nothing to read, render or write.
120 +
                let same_graph = untouched
121 +
                    && snap.base_snapshot.unrendered.contains(path)
122 +
                    && !is_attributes(path)
123 +
                    && base.graph_key(&e.node).is_some()
124 +
                    && base.graph_key(&e.node) == target.graph_key(&e.node);
125 +
                match old_manifest.entries.get(path).filter(|_| same_graph) {
126 +
                    Some(m) => {
127 +
                        entries.insert(
128 +
                            path.clone(),
129 +
                            FsEntry {
130 +
                                content_hash: Some(m.hash.clone()),
131 +
                                graph_key: m.graph.clone(),
132 +
                                unchanged: true,
133 +
                                ..FsEntry::new(NodeKind::File, e.mode.exec, Vec::new())
134 +
                            },
135 +
                        );
136 +
                    }
137 +
                    None => texts.push((path.clone(), e.node, e.mode)),
138 +
                }
139 +
                continue;
140 +
            }
141 +
            // Opaque files, symlinks and subrepos: their blobs.
142 +
            let blobs = target.live_blobs(&e.node);
143 +
            if blobs.len() > 1 {
144 +
                report.conflicted = true;
145 +
            }
146 +
            let Some(blob) = blobs.first().copied() else {
147 +
                continue;
148 +
            };
149 +
            if e.kind == NodeKind::Subrepo {
150 +
                if keep_subrepos.contains(path) {
72 151
                    continue;
73 152
                }
74 -
                let full = self.safe_path(path)?;
75 -
                fs::remove_file(&full)?;
76 -
                report.removed += 1;
77 -
                if let Some(parent) = full.parent() {
78 -
                    dirs_to_prune.insert(parent.to_path_buf());
153 +
                let pin = crate::subrepo::Pin::decode(&self.read_blob(&blob)?)?;
154 +
                let sync = self.sync_subrepo(path, &pin, false)?;
155 +
                if matches!(
156 +
                    sync,
157 +
                    SubrepoSync::Updated | SubrepoSync::KeptBookmark { .. }
158 +
                ) {
159 +
                    report.written += 1;
79 160
                }
161 +
                report.notes.extend(sync.note(path));
162 +
                continue;
80 163
            }
81 -
        }
82 -
        // Write target files that differ (only inside the sparse filter).
83 -
        for (path, f) in &target.files {
84 -
            if !self.in_scope(path) {
164 +
            let same_blob = untouched && base.live_blobs(&e.node) == blobs;
165 +
            if let Some(m) = old_manifest
166 +
                .entries
167 +
                .get(path)
168 +
                .filter(|_| same_blob && blobs.len() == 1)
169 +
            {
170 +
                entries.insert(
171 +
                    path.clone(),
172 +
                    FsEntry {
173 +
                        content_hash: Some(m.hash.clone()),
174 +
                        blob: (e.kind == NodeKind::File).then_some(blob),
175 +
                        ..FsEntry::new(e.kind, e.mode.exec, Vec::new())
176 +
                    },
177 +
                );
85 178
                continue;
86 179
            }
87 -
            match &f.content {
88 -
                FileContent::Dir => continue,
89 -
                FileContent::Text { bytes, .. } => {
90 -
                    let a = attrs.lookup(path);
91 -
                    let on_disk: std::borrow::Cow<[u8]> =
92 -
                        if a.eol == Some(crate::attributes::Eol::Crlf) {
93 -
                            crate::attributes::to_crlf(bytes).into()
94 -
                        } else {
95 -
                            bytes.as_slice().into()
96 -
                        };
97 -
                    let same = current.get(path).is_some_and(|c| {
98 -
                        c.kind == NodeKind::File
99 -
                            && c.content == *on_disk
100 -
                            && (!self.platform.exec_bits || c.exec == f.mode.exec)
101 -
                    });
102 -
                    if !same {
103 -
                        self.write_file(path, &on_disk, f.mode.exec)?;
104 -
                        report.written += 1;
105 -
                    }
106 -
                    if a.lockable && !self.is_locked(path) {
107 -
                        let _ = set_readonly(&self.root.join(path), true);
108 -
                    }
180 +
            let bytes = self.read_blob(&blob)?;
181 +
            let full = self.root.join(path);
182 +
            if e.kind == NodeKind::Symlink {
183 +
                let same = fs::read_link(&full)
184 +
                    .is_ok_and(|t| t.to_string_lossy().as_bytes() == bytes.as_slice());
185 +
                if !same {
186 +
                    self.write_symlink(path, &bytes)?;
187 +
                    report.written += 1;
109 188
                }
110 -
                FileContent::Opaque { blobs } => {
111 -
                    let Some(blob) = blobs.first() else { continue };
112 -
                    let bytes = self.read_blob(blob)?;
113 -
                    if f.kind == NodeKind::Subrepo {
114 -
                        let pin = crate::subrepo::Pin::decode(&bytes)?;
115 -
                        if keep_subrepos.contains(path) {
116 -
                            continue;
117 -
                        }
118 -
                        let sync = self.sync_subrepo(path, &pin, false)?;
119 -
                        if matches!(
120 -
                            sync,
121 -
                            SubrepoSync::Updated | SubrepoSync::KeptBookmark { .. }
122 -
                        ) {
123 -
                            report.written += 1;
124 -
                        }
125 -
                        report.notes.extend(sync.note(path));
126 -
                        continue;
127 -
                    }
128 -
                    if f.kind == NodeKind::Symlink {
129 -
                        let same = current
130 -
                            .get(path)
131 -
                            .is_some_and(|c| c.kind == NodeKind::Symlink && c.content == bytes);
132 -
                        if !same {
133 -
                            self.write_symlink(path, &bytes)?;
134 -
                            report.written += 1;
135 -
                        }
136 -
                    } else {
137 -
                        let same = current.get(path).is_some_and(|c| {
138 -
                            c.kind == NodeKind::File
139 -
                                && c.content == bytes
140 -
                                && (!self.platform.exec_bits || c.exec == f.mode.exec)
141 -
                        });
142 -
                        if !same {
143 -
                            self.write_file(path, &bytes, f.mode.exec)?;
144 -
                            report.written += 1;
145 -
                        }
146 -
                        if attrs.lookup(path).lockable && !self.is_locked(path) {
147 -
                            let _ = set_readonly(&self.root.join(path), true);
148 -
                        }
149 -
                    }
150 -
                    if blobs.len() > 1 {
151 -
                        for (i, b) in blobs.iter().enumerate().skip(1) {
152 -
                            self.write_file(
153 -
                                &format!("{path}.oxy-conflict-{}", i + 1),
154 -
                                &self.read_blob(b)?,
155 -
                                f.mode.exec,
156 -
                            )?;
157 -
                        }
158 -
                    }
189 +
                entries.insert(
190 +
                    path.clone(),
191 +
                    FsEntry {
192 +
                        content_hash: Some(hex(&bytes)),
193 +
                        ..FsEntry::new(NodeKind::Symlink, false, Vec::new())
194 +
                    },
195 +
                );
196 +
            } else {
197 +
                if !self.same_on_disk(&full, &bytes, e.mode.exec) {
198 +
                    self.write_file(path, &bytes, e.mode.exec)?;
199 +
                    report.written += 1;
200 +
                }
201 +
                entries.insert(
202 +
                    path.clone(),
203 +
                    FsEntry {
204 +
                        content_hash: Some(hex(&bytes)),
205 +
                        blob: (blobs.len() == 1).then_some(blob),
206 +
                        ..FsEntry::new(NodeKind::File, e.mode.exec, Vec::new())
207 +
                    },
208 +
                );
209 +
            }
210 +
            for (i, b) in blobs.iter().enumerate().skip(1) {
211 +
                self.write_file(
212 +
                    &format!("{path}.oxy-conflict-{}", i + 1),
213 +
                    &self.read_blob(b)?,
214 +
                    e.mode.exec,
215 +
                )?;
216 +
            }
217 +
        }
218 +
219 +
        // Render and write text files a batch at a time; attribute files
220 +
        // come first, since the others' line endings and locks follow them.
221 +
        texts.sort_by_key(|(p, _, _)| !is_attributes(p));
222 +
        let keys: HashMap<NodeId, [u8; 32]> = texts
223 +
            .iter()
224 +
            .filter_map(|(_, n, _)| Some((*n, target.graph_key(n)?)))
225 +
            .collect();
226 +
        let mut attrs = crate::attributes::Attributes::default();
227 +
        let attr_count = texts.iter().filter(|(p, _, _)| is_attributes(p)).count();
228 +
        let mut done = 0usize;
229 +
        let mut batches: Vec<&[(String, NodeId, Mode)]> = Vec::new();
230 +
        if attr_count > 0 {
231 +
            batches.push(&texts[..attr_count]);
232 +
        }
233 +
        batches.extend(texts[attr_count..].chunks(CHECKOUT_BATCH));
234 +
        for batch in batches {
235 +
            use rayon::prelude::*;
236 +
            let nodes: Vec<NodeId> = batch.iter().map(|(_, n, _)| *n).collect();
237 +
            self.load_graphs_of(&mut target, &nodes)?;
238 +
            let rendered: Vec<(Vec<u8>, bool)> = nodes
239 +
                .par_iter()
240 +
                .map(|n| {
241 +
                    let (bytes, m) = target.render_file(n);
242 +
                    (bytes, m.is_conflicted())
243 +
                })
244 +
                .collect();
245 +
            target.drop_graphs(&nodes);
246 +
            if done == 0 && attr_count > 0 {
247 +
                attrs = crate::attributes::Attributes::from_files(
248 +
                    batch
249 +
                        .iter()
250 +
                        .zip(&rendered)
251 +
                        .map(|((p, _, _), (b, _))| (p.as_str(), b.as_slice())),
252 +
                );
253 +
            }
254 +
            done += batch.len();
255 +
            for ((path, node, mode), (bytes, conflicted)) in batch.iter().zip(rendered) {
256 +
                let a = attrs.lookup(path);
257 +
                let crlf = a.eol == Some(crate::attributes::Eol::Crlf);
258 +
                let on_disk: Vec<u8> = if crlf {
259 +
                    crate::attributes::to_crlf(&bytes)
260 +
                } else {
261 +
                    bytes
262 +
                };
263 +
                if !self.same_on_disk(&self.root.join(path), &on_disk, mode.exec) {
264 +
                    self.write_file(path, &on_disk, mode.exec)?;
265 +
                    report.written += 1;
159 266
                }
267 +
                report.conflicted |= conflicted;
268 +
                entries.insert(
269 +
                    path.clone(),
270 +
                    FsEntry {
271 +
                        content_hash: Some(hex(&on_disk)),
272 +
                        graph_key: (!conflicted && a.eol.is_none())
273 +
                            .then(|| keys.get(node).map(|k| ObjectRef(*k).to_hex()))
274 +
                            .flatten(),
275 +
                        ..FsEntry::new(NodeKind::File, mode.exec, Vec::new())
276 +
                    },
277 +
                );
160 278
            }
161 279
        }
280 +
        // Lockable files are read-only until locked (spec §18), whether
281 +
        // written now or left as they were.
282 +
        for (path, e) in &entries {
283 +
            if e.kind == NodeKind::File && attrs.lookup(path).lockable && !self.is_locked(path) {
284 +
                let _ = set_readonly(&self.root.join(path), true);
285 +
            }
286 +
        }
162 287
        // Prune directories left empty, deepest first.
163 288
        for d in dirs_to_prune.iter().rev() {
164 289
            let mut d = d.clone();
…
14 unchanged lines
179 304
        let mut view = op.view.clone();
180 305
        view.workspaces.insert(self.workspace, wc_hash);
181 306
        self.record_op(&[op_ref], command, view)?;
182 -
        report.conflicted = target.is_conflicted();
183 -
        let blobs: HashMap<String, ObjectRef> = target
184 -
            .files
185 -
            .iter()
186 -
            .filter_map(|(p, f)| match &f.content {
187 -
                FileContent::Opaque { blobs } if f.kind == NodeKind::File && blobs.len() == 1 => {
188 -
                    Some((p.clone(), blobs[0]))
189 -
                }
190 -
                _ => None,
191 -
            })
192 -
            .collect();
193 -
        let entries = self.walk_working_copy_with(None, &Self::tracked_kinds(&target), None)?;
194 -
        self.write_manifest_entries(report.conflicted, false, &entries, &blobs)?;
307 +
        self.write_manifest_entries(report.conflicted, false, &entries, &HashMap::new())?;
195 308
        let _ = self.run_hook(
196 309
            "post-checkout",
197 310
            &format!(
…
4 unchanged lines
202 315
            ),
203 316
        );
204 317
        Ok(report)
318 +
    }
319 +
320 +
    /// Whether the file at `full` already holds `bytes` with this executable
321 +
    /// bit, so writing it again can be skipped.
322 +
    fn same_on_disk(&self, full: &Path, bytes: &[u8], exec: bool) -> bool {
323 +
        match fs::symlink_metadata(full) {
324 +
            Ok(m) if m.is_file() && m.len() == bytes.len() as u64 => {
325 +
                (!self.platform.exec_bits || is_executable(&m) == exec)
326 +
                    && fs::read(full).is_ok_and(|b| b == bytes)
327 +
            }
328 +
            _ => false,
329 +
        }
205 330
    }
206 331
207 332
    /// The absolute path for a repository path, refusing anything that would
…
79 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +62 −0
…
5849 unchanged lines
5850 5850
    assert!(!snap.changed && snap.statuses.is_empty());
5851 5851
    assert_eq!(snap.base_snapshot.unrendered.len(), files.len());
5852 5852
}
5853 +
5854 +
/// oxygen#35: a checkout reads, renders and writes only the files whose
5855 +
/// graphs differ, and renders the rest a batch at a time.
5856 +
#[test]
5857 +
fn checkout_touches_only_what_differs_and_renders_in_batches() {
5858 +
    use std::os::unix::fs::PermissionsExt;
5859 +
    hermetic();
5860 +
    let dir = tempfile::tempdir().unwrap();
5861 +
    let root = dir.path();
5862 +
    let origin = root.join("origin");
5863 +
    let mut repo = Repo::init(&origin).unwrap();
5864 +
    // More files than one batch holds.
5865 +
    let n = 2100;
5866 +
    let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30);
5867 +
    for i in 0..n {
5868 +
        let p = format!("f/{i:04}.txt");
5869 +
        write(&origin, &p, format!("file {i}\n").as_bytes());
5870 +
        fs::File::options()
5871 +
            .write(true)
5872 +
            .open(origin.join(&p))
5873 +
            .unwrap()
5874 +
            .set_modified(old)
5875 +
            .unwrap();
5876 +
    }
5877 +
    repo.commit("many", &[]).unwrap();
5878 +
    repo.bookmark_create("feature", None).unwrap();
5879 +
    repo.bookmark_switch("feature", false).unwrap();
5880 +
    write(&origin, "f/0007.txt", b"changed\n");
5881 +
    repo.commit("one change", &[]).unwrap();
5882 +
5883 +
    // Switching back to main must not read the unchanged files.
5884 +
    for i in 0..n {
5885 +
        if i != 7 {
5886 +
            let p = origin.join(format!("f/{i:04}.txt"));
5887 +
            fs::set_permissions(&p, fs::Permissions::from_mode(0o000)).unwrap();
5888 +
        }
5889 +
    }
5890 +
    let r = repo.bookmark_switch("main", false).unwrap();
5891 +
    for i in 0..n {
5892 +
        let p = origin.join(format!("f/{i:04}.txt"));
5893 +
        fs::set_permissions(&p, fs::Permissions::from_mode(0o644)).unwrap();
5894 +
    }
5895 +
    assert_eq!((r.written, r.removed), (1, 0), "{r:?}");
5896 +
    assert_eq!(fs::read(origin.join("f/0007.txt")).unwrap(), b"file 7\n");
5897 +
    assert!(repo.snapshot_status("status").unwrap().statuses.is_empty());
5898 +
5899 +
    // A clone renders every file, across batches.
5900 +
    let c = root.join("clone");
5901 +
    Repo::clone(&origin.to_string_lossy(), &c).unwrap();
5902 +
    for i in [0, 2047, 2048, n - 1] {
5903 +
        assert_eq!(
5904 +
            fs::read(c.join(format!("f/{i:04}.txt"))).unwrap(),
5905 +
            format!("file {i}\n").as_bytes()
5906 +
        );
5907 +
    }
5908 +
    let cloned = Repo::open(&c).unwrap();
5909 +
    assert!(cloned
5910 +
        .snapshot_status("status")
5911 +
        .unwrap()
5912 +
        .statuses
5913 +
        .is_empty());
5914 +
}
modified crates/oxy-core/src/state.rs +9 −0
…
367 unchanged lines
368 368
        Ok(())
369 369
    }
370 370
371 +
    /// Drop the built graphs of `nodes`, for a caller rendering a large
372 +
    /// tree a batch at a time. The state can no longer render those files,
373 +
    /// so it must not be used for them again.
374 +
    pub fn drop_graphs(&mut self, nodes: &[NodeId]) {
375 +
        for n in nodes {
376 +
            self.files.remove(n);
377 +
        }
378 +
    }
379 +
371 380
    /// Build every graph a history-only state has not built, making it full.
372 381
    pub fn load_all_graphs(
373 382
        &mut self,
…
1484 unchanged lines
modified docs/benchmarks.md +21 −0
…
301 unchanged lines
302 302
Still to do: status to the 0.4 s and 300 MB target; `fsck` is killed at
303 303
14 GB; the first commit takes 57 s and 10.2 GB.
304 304
305 +
### Checkout and clone (2026-09-29, oxygen#35)
306 +
307 +
A checkout built and rendered every file of the target, read the whole
308 +
working copy, and after writing walked and hashed it all again for the
309 +
manifest. Switching bookmarks or cloning the Linux tree was killed at
310 +
14 GB. Now:
311 +
- a file is skipped when the manifest shows it unchanged and its graph is
312 +
  the same in both states;
313 +
- the rest are rendered 2 048 at a time, and each batch's graphs are
314 +
  dropped once written;
315 +
- the manifest is built from what the checkout already knows.
316 +
317 +
| Linux tip | oxy before | oxy now | Git |
318 +
|---|---:|---:|---:|
319 +
| Switch bookmark (one file differs) | killed at 14 GB | 7.1 to 7.4 s, 1.3 GB | 0.18 to 0.22 s, 39 MB |
320 +
| Local clone | killed at 14 GB | 98 s, 7.9 GB | 9.8 s, 469 MB |
321 +
322 +
Most of a switch is now loading history: the base's and the target's
323 +
history each decode the one huge first change (1.2 s and 364 MB each).
324 +
A clone holds the whole bundle in memory.
325 +