OXYGENHUB

Commit without a second snapshot; the state index after a checkpoint 7505194116e7

A history-only state loaded through a checkpoint read the changes after
it whole, text included, and a state holding text cannot be written as
a state index: after any checkpoint no index was saved and every command
loaded history the long way. It now reads their skeletons, as a replayed
load does. oxy commit PATHS took a second snapshot to count the paths it
left; when it took every operation of the working copy nothing is left,
and the snapshot is skipped.

VS Code at 19,978 commits, a one-file commit: about 450 ms to 300 ms;
the first commit after a checkpoint 25 s to 0.44 s. The state index is
24 MB rather than 84 MB once built from a checkpoint.

refs #93
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-09 21:57 UTC
Signature
✔ signed by Andy McCall
Hash
7505194116e722bd1e616b2cef364299e73f621e7b1804487e75f12f8a2ce925
Depends on
135212de5370 170bb436e1d5 19303b4a06fb 252e8cb23fbc 5b4c51307b14 6caacfff1d4f 75167672ebcd 8ab265dc4a51 8fd78eb52ad2 957fee30826e 982864068a59 9aa399044d9d b30a880fa409 d4dbab97e948 fce44ae9ad29
Depended on by
b689779e9e74 79b5a90ffd43
In bookmarks
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.10.0 v0.11.0 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
14 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Faster commits, and the state index after a checkpoint (#93).** A state loaded through a checkpoint read the changes after it whole, text included, so it could never be saved as the workspace's state index and every command after a checkpoint loaded history the long way (25 s for the first commit on VS Code at 20 000 commits). It now reads them as the index needs them. `oxy commit PATHS` no longer takes a second snapshot to count what it left when it took everything. A one-file commit on VS Code went from about 450 ms to 300 ms; 70 ms of that is the store's fsync, which stays.
6 +
5 7
- **Reading one file reads one file (#92).** `oxy cat` rendered every file of the state to print one. It now builds only that file, from the workspace's state index when it reads the working copy's base or the state it is on. On VS Code at 20 000 commits, `cat package.json -r main` went from 26 s to 0.10 s and at a change from 2016 from 0.66 s to 0.16 s; on the full 166 000-change history it takes 1.9 s and 1.3 GB, where it ran for minutes and used 9 GB.
6 8
7 9
- **A file's history in a fraction of a second (#91).** `oxy log --path` rendered every file of the tree twice just to find the path, and both it and the `touches()` revset decoded every change of the history, its text included, to see what each one touched. They now read the path from the tree alone and keep the nodes each change touches in a cache beside the store (`.oxy/index/touched`, rebuilt if missing). On VS Code at 20 000 commits, `log --path package.json` went from 53.6 s to 0.16 s (Git: 0.15 s) and `touches("package.json")` from 3.4 s to 0.16 s, with the same output.
…
148 unchanged lines
modified crates/oxy-core/src/repo/checkpoint.rs +16 −5
…
191 unchanged lines
192 192
            };
193 193
            let cp_heads: BTreeSet<ChangeHash> = cp.heads.iter().copied().collect();
194 194
            let mut found: BTreeSet<ChangeHash> = BTreeSet::new();
195 -
            let mut delta: Vec<Change> = Vec::new();
195 +
            let mut delta: Vec<(ChangeHash, Change, Vec<crate::objects::TextRef>)> = Vec::new();
196 196
            let mut seen: BTreeSet<ChangeHash> = BTreeSet::new();
197 197
            let mut stack: Vec<ChangeHash> = heads.to_vec();
198 198
            while let Some(h) = stack.pop() {
…
6 unchanged lines
205 205
                    }
206 206
                    continue;
207 207
                }
208 -
                let c = self.store.get_change(&h)?;
208 +
                // For history, the skeleton: text is read only for the
209 +
                // graphs built later, and a state holding none can be
210 +
                // indexed (oxygen#93).
211 +
                let (c, texts) = if history {
212 +
                    self.store.get_change_lazy(&h)?
213 +
                } else {
214 +
                    (self.store.get_change(&h)?, Vec::new())
215 +
                };
209 216
                stack.extend(c.deps.iter().copied());
210 -
                delta.push(c);
217 +
                delta.push((h, c, texts));
211 218
            }
212 219
            if found.len() != cp_heads.len() {
213 220
                continue;
214 221
            }
215 222
            let _t = crate::timing::phase("load: apply delta and validate");
216 223
            let mut state = base;
217 -
            for c in delta {
218 -
                state.add_unchecked(c);
224 +
            for (h, c, texts) in delta {
225 +
                if history {
226 +
                    state.add_known(h, c, texts);
227 +
                } else {
228 +
                    state.add_unchecked(c);
229 +
                }
219 230
            }
220 231
            state.validate()?;
221 232
            return Ok(Some(state));
…
4 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +16 −2
…
843 unchanged lines
844 844
        extra_deps: &[ChangeHash],
845 845
        selection: &Selection,
846 846
    ) -> Result<ChangeHash> {
847 +
        Ok(self
848 +
            .commit_selection_partial(message, extra_deps, selection)?
849 +
            .0)
850 +
    }
851 +
852 +
    /// As [`Repo::commit_selection`], and whether the commit left part of
853 +
    /// the working copy uncommitted: when it did not, nothing is left, and
854 +
    /// no snapshot is needed to say so.
855 +
    pub fn commit_selection_partial(
856 +
        &self,
857 +
        message: &str,
858 +
        extra_deps: &[ChangeHash],
859 +
        selection: &Selection,
860 +
    ) -> Result<(ChangeHash, bool)> {
847 861
        // The commit's objects are written together, made durable at once
848 862
        // before the operation that names them (spec §13.6).
849 863
        let batch = self.store.begin_batch()?;
…
30 unchanged lines
880 894
        message: &str,
881 895
        extra_deps: &[ChangeHash],
882 896
        selection: &Selection,
883 -
    ) -> Result<ChangeHash> {
897 +
    ) -> Result<(ChangeHash, bool)> {
884 898
        let _wc = self.lock_working_copy()?;
885 899
        let mut snap = match self.snapshot_from_cache()? {
886 900
            Some(snap) => snap,
…
224 unchanged lines
1111 1125
                json_str(message)
1112 1126
            ),
1113 1127
        );
1114 -
        Ok(hash)
1128 +
        Ok((hash, partial))
1115 1129
    }
1116 1130
}
1117 1131
…
1915 unchanged lines
modified src/cli/workspace.rs +3 −2
…
313 unchanged lines
314 314
        oxy_core::repo::Selection::Paths(paths.to_vec())
315 315
    };
316 316
    let sp = ui::spinner("Committing…");
317 -
    let h = repo.commit_selection(message, &deps, &selection)?;
317 +
    let (h, partial) = repo.commit_selection_partial(message, &deps, &selection)?;
318 318
    // The metadata only: a first commit's change holds the whole tree.
319 319
    let meta = repo.store().get_change_meta(&h)?;
320 320
    sp.done(&format!(
…
4 unchanged lines
325 325
        ui::subject(subject_of(message))
326 326
    ));
327 327
    print_untracked_nested(&repo, &repo.untracked_nested());
328 -
    if !matches!(selection, oxy_core::repo::Selection::All) {
328 +
    // Only a commit that took part of the working copy leaves anything.
329 +
    if partial {
329 330
        let rest = repo.snapshot_status("oxy commit")?;
330 331
        if !rest.statuses.is_empty() {
331 332
            println!(
…
2372 unchanged lines
modified tests/cli_strata.rs +22 −0
…
119 unchanged lines
120 120
    assert_ne!(code, 0);
121 121
    assert!(err.contains("different repository"), "{err}");
122 122
}
123 +
124 +
#[test]
125 +
fn commit_with_paths_says_what_it_left() {
126 +
    let tmp = tempfile::tempdir().unwrap();
127 +
    let home = tmp.path().join("home");
128 +
    std::fs::create_dir_all(&home).unwrap();
129 +
    let a = tmp.path().join("a");
130 +
    ok(tmp.path(), &home, &["init", a.to_str().unwrap()]);
131 +
    std::fs::write(a.join("f.txt"), "one\n").unwrap();
132 +
    std::fs::write(a.join("g.txt"), "one\n").unwrap();
133 +
    ok(&a, &home, &["commit", "-m", "first"]);
134 +
135 +
    std::fs::write(a.join("f.txt"), "two\n").unwrap();
136 +
    std::fs::write(a.join("g.txt"), "two\n").unwrap();
137 +
    let part = ok(&a, &home, &["commit", "-m", "f only", "f.txt"]);
138 +
    assert!(part.contains("1 path left uncommitted"), "{part}");
139 +
140 +
    let rest = ok(&a, &home, &["commit", "-m", "g", "g.txt"]);
141 +
    assert!(!rest.contains("left uncommitted"), "{rest}");
142 +
    let status = ok(&a, &home, &["status"]);
143 +
    assert!(status.contains("working copy clean"), "{status}");
144 +
}