OXYGENHUB

Status with work in progress from a status cache: ahead of Git on the Linux tree (refs #41) 3604179679eb

The manifest check proved only a clean working copy, so with uncommitted
work every status listed and snapshotted the whole tree again. A snapshot
now writes a status cache (workspaces/<ws>/status) keyed by the
working-copy change and the manifest's checksum, and rewrites the manifest
when an edited file's stat moved, so it describes the files as they are.
status_by_manifest answers from the cache when every file still matches
the manifest; test builds check the answer against a snapshot.

On the Linux tree with MAINTAINERS edited: 0.16 s (from 0.44 s; Git
0.20 s), 120 to 155 MB (from 330 MB). The racy-window and listing checks
are shared with the clean check.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-01 18:40 UTC
Signature
✔ signed by Andy McCall
Hash
3604179679eb1325790f3f0a41d2124c5f87e40612837a80ef0101cbea7992cf
Depends on
135212de5370 1966f5db15ee 1d360c91a289 50a046e439e9 638fb21420d7 75167672ebcd 76740263089c 86e1824f2d0b 8fd78eb52ad2 94084087a876 982864068a59 98a1620a3840 992467d31d26 9ac1fbe211b4 c07406bcc237 cd19603782bf d3cd7ef95da6 d4dbab97e948 da868d5d2646 eaf3e9068180 f9466021286a fce44ae9ad29
Depended on by
232aabb5452d f6dccc4533c8 80677b7a3513 236f35c0baba 0389c3e82f6c e96a430c78f3 e226a6cc2f71 1ec3f008e96a 77445673174b
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.10.0 v0.11.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, 6 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Status with work in progress ahead of Git (#41).** With uncommitted work, `oxy status` answers from a status cache while every file is as the last snapshot left it: one edited file on the Linux tree is 0.16 s (from 0.44 s; Git 0.20 s), in half the memory.
6 +
5 7
- **Quieter notes (#55).** Notes start with a dimmed `›` instead of `ℹ`, which many terminal fonts draw as a boxed or emoji-style "i". A spinner's final line no longer shows two ticks or runs into the next line.
6 8
7 9
## 0.2.0 (2026-10-01)
…
52 unchanged lines
modified crates/oxy-core/src/repo/prompt.rs +174 −23
…
258 unchanged lines
259 259
    ))
260 260
}
261 261
262 +
/// The status cache: a magic number, the working-copy change, the
263 +
/// manifest checksum, then each status line (a status byte and a path).
264 +
const STATUS_MAGIC: &[u8; 8] = b"oxystat1";
265 +
266 +
fn status_byte(s: ChangeStatus) -> u8 {
267 +
    match s {
268 +
        ChangeStatus::Added => 0,
269 +
        ChangeStatus::Modified => 1,
270 +
        ChangeStatus::Removed => 2,
271 +
        ChangeStatus::Renamed => 3,
272 +
        ChangeStatus::ModeChanged => 4,
273 +
        ChangeStatus::ConflictEdited => 5,
274 +
        ChangeStatus::Resolved => 6,
275 +
    }
276 +
}
277 +
278 +
fn encode_status_cache(wc: &ChangeHash, stamp: &[u8; 32], statuses: &[PathStatus]) -> Vec<u8> {
279 +
    let mut out = Vec::with_capacity(80 + statuses.len() * 40);
280 +
    out.extend_from_slice(STATUS_MAGIC);
281 +
    out.extend_from_slice(&wc.0);
282 +
    out.extend_from_slice(stamp);
283 +
    out.extend_from_slice(&(statuses.len() as u32).to_le_bytes());
284 +
    for s in statuses {
285 +
        out.push(status_byte(s.status));
286 +
        out.extend_from_slice(&(s.path.len() as u32).to_le_bytes());
287 +
        out.extend_from_slice(s.path.as_bytes());
288 +
    }
289 +
    out
290 +
}
291 +
292 +
fn decode_status_cache(b: &[u8]) -> Option<(ChangeHash, [u8; 32], Vec<PathStatus>)> {
293 +
    let rest = b.strip_prefix(STATUS_MAGIC)?;
294 +
    let wc = ObjectRef(rest.get(..32)?.try_into().ok()?);
295 +
    let stamp: [u8; 32] = rest.get(32..64)?.try_into().ok()?;
296 +
    let n = u32::from_le_bytes(rest.get(64..68)?.try_into().ok()?) as usize;
297 +
    let mut at = 68;
298 +
    let mut out = Vec::with_capacity(n.min(1 << 16));
299 +
    for _ in 0..n {
300 +
        let status = match *rest.get(at)? {
301 +
            0 => ChangeStatus::Added,
302 +
            1 => ChangeStatus::Modified,
303 +
            2 => ChangeStatus::Removed,
304 +
            3 => ChangeStatus::Renamed,
305 +
            4 => ChangeStatus::ModeChanged,
306 +
            5 => ChangeStatus::ConflictEdited,
307 +
            6 => ChangeStatus::Resolved,
308 +
            _ => return None,
309 +
        };
310 +
        let len = u32::from_le_bytes(rest.get(at + 1..at + 5)?.try_into().ok()?) as usize;
311 +
        let path = std::str::from_utf8(rest.get(at + 5..at + 5 + len)?)
312 +
            .ok()?
313 +
            .to_string();
314 +
        out.push(PathStatus { path, status });
315 +
        at += 5 + len;
316 +
    }
317 +
    (at == rest.len()).then_some((wc, stamp, out))
318 +
}
319 +
262 320
impl Repo {
263 321
    pub(crate) fn manifest_path(&self) -> PathBuf {
264 322
        self.oxy
…
166 unchanged lines
431 489
        if conflicted || uncommitted || self.root.join(".oxymodules").exists() {
432 490
            return not("conflicted, uncommitted or subrepos");
433 491
        }
434 -
        // Entries in the racy window are hashed, in parallel (a checkout
435 -
        // just wrote them all).
492 +
        if let Some(rel) = self.racy_change(&m) {
493 +
            return not(&format!("{rel} changed within the racy window"));
494 +
        }
495 +
        if self.disk_differs(&m, true)? {
496 +
            return not("a file differs");
497 +
        }
498 +
        Ok(true)
499 +
    }
500 +
501 +
    /// A manifest entry written within the racy window whose file no
502 +
    /// longer matches it. Such entries are hashed, in parallel (a checkout
503 +
    /// just wrote them all).
504 +
    fn racy_change(&self, m: &Manifest) -> Option<String> {
436 505
        use rayon::prelude::*;
437 506
        let racy: Vec<(&String, &ManifestEntry)> = m
438 507
            .entries
…
1 unchanged lines
440 509
            .filter(|(_, e)| e.mtime_ns + 2_000_000_000 >= m.written_ns)
441 510
            .collect();
442 511
        let root = &self.root;
443 -
        let changed = racy.par_iter().find_any(|(rel, e)| {
444 -
            let path = root.join(rel.as_str());
445 -
            let same = match fs::symlink_metadata(&path) {
446 -
                Ok(md) if md.file_type().is_symlink() => fs::read_link(&path).is_ok_and(|t| {
447 -
                    *blake3::hash(t.to_string_lossy().as_bytes()).as_bytes() == e.hash
448 -
                }),
449 -
                Ok(md) if md.is_file() => {
450 -
                    md.len() == e.size
451 -
                        && fs::read(&path).is_ok_and(|b| *blake3::hash(&b).as_bytes() == e.hash)
452 -
                }
453 -
                _ => false,
454 -
            };
455 -
            !same
456 -
        });
457 -
        if let Some((rel, _)) = changed {
458 -
            return not(&format!("{rel} changed within the racy window"));
512 +
        racy.par_iter()
513 +
            .find_any(|(rel, e)| {
514 +
                let path = root.join(rel.as_str());
515 +
                let same = match fs::symlink_metadata(&path) {
516 +
                    Ok(md) if md.file_type().is_symlink() => fs::read_link(&path).is_ok_and(|t| {
517 +
                        *blake3::hash(t.to_string_lossy().as_bytes()).as_bytes() == e.hash
518 +
                    }),
519 +
                    Ok(md) if md.is_file() => {
520 +
                        md.len() == e.size
521 +
                            && fs::read(&path).is_ok_and(|b| *blake3::hash(&b).as_bytes() == e.hash)
522 +
                    }
523 +
                    _ => false,
524 +
                };
525 +
                !same
526 +
            })
527 +
            .map(|(rel, _)| rel.to_string())
528 +
    }
529 +
530 +
    /// The status lines of the last snapshot, when the working copy is as
531 +
    /// that snapshot left it: the cache it wrote names the working-copy
532 +
    /// change and the manifest it recorded, and every file still matches
533 +
    /// that manifest. `None` when a snapshot must decide (no cache, a file
534 +
    /// changed, conflicts or subrepos). An empty list is a clean working
535 +
    /// copy.
536 +
    pub fn status_by_manifest(&self) -> Result<Option<Vec<PathStatus>>> {
537 +
        // A listing left by an earlier check describes the files as they
538 +
        // were then; this check makes its own.
539 +
        self.listing_cache.take();
540 +
        let Some((conflicted, uncommitted, m)) = self.read_manifest()? else {
541 +
            return Ok(None);
542 +
        };
543 +
        if !uncommitted {
544 +
            return Ok(self.clean_by_manifest()?.then(Vec::new));
459 545
        }
460 -
        if self.quick_dirty_keeping(true)? != Some(false) {
461 -
            return not("a file differs");
546 +
        let _t = crate::timing::phase("status: by the status cache");
547 +
        if conflicted || self.root.join(".oxymodules").exists() {
548 +
            return Ok(None);
549 +
        }
550 +
        let Some((wc, stamp, statuses)) = fs::read(self.status_cache_path())
551 +
            .ok()
552 +
            .and_then(|b| decode_status_cache(&b))
553 +
        else {
554 +
            return Ok(None);
555 +
        };
556 +
        let now = self.view()?.workspaces.get(&self.workspace).copied();
557 +
        if now != Some(wc) || self.manifest_stamp() != Some(stamp) {
558 +
            return Ok(None);
559 +
        }
560 +
        if self.racy_change(&m).is_some() || self.disk_differs(&m, true)? {
561 +
            return Ok(None);
562 +
        }
563 +
        // Test builds check the cache against a snapshot of the files.
564 +
        #[cfg(test)]
565 +
        {
566 +
            let snap = self.snapshot_status("status cache check")?;
567 +
            let key = |v: &[PathStatus]| {
568 +
                let mut k: Vec<(String, u8)> = v
569 +
                    .iter()
570 +
                    .map(|p| (p.path.clone(), status_byte(p.status)))
571 +
                    .collect();
572 +
                k.sort();
573 +
                k
574 +
            };
575 +
            assert_eq!(
576 +
                key(&statuses),
577 +
                key(&snap.statuses),
578 +
                "status cache disagrees with a snapshot"
579 +
            );
462 580
        }
463 -
        Ok(true)
581 +
        Ok(Some(statuses))
582 +
    }
583 +
584 +
    fn status_cache_path(&self) -> PathBuf {
585 +
        self.manifest_path().with_file_name("status")
586 +
    }
587 +
588 +
    /// The manifest file's checksum, which names its content.
589 +
    fn manifest_stamp(&self) -> Option<[u8; 32]> {
590 +
        use std::io::Read;
591 +
        let mut head = [0u8; MANIFEST_SUM.end];
592 +
        fs::File::open(self.manifest_path())
593 +
            .and_then(|mut f| f.read_exact(&mut head))
594 +
            .ok()?;
595 +
        head[MANIFEST_SUM].try_into().ok()
464 596
    }
465 597
598 +
    /// Record `statuses` as the status of working-copy change `wc` against
599 +
    /// the manifest as it is now; with no uncommitted work there is nothing
600 +
    /// to record, and the cache goes.
601 +
    pub(crate) fn write_status_cache(&self, wc: Option<&ChangeHash>, statuses: &[PathStatus]) {
602 +
        let path = self.status_cache_path();
603 +
        let (Some(wc), Some(stamp)) = (wc, self.manifest_stamp()) else {
604 +
            let _ = fs::remove_file(&path);
605 +
            return;
606 +
        };
607 +
        let _ = write_cache(&path, &encode_status_cache(wc, &stamp, statuses));
608 +
    }
609 +
466 610
    /// Whether the working copy differs from the last manifest. Without a
467 611
    /// manifest the answer is `None`.
468 612
    pub fn quick_dirty(&self) -> Result<Option<bool>> {
…
9 unchanged lines
478 622
        if uncommitted {
479 623
            return Ok(Some(true));
480 624
        }
625 +
        Ok(Some(self.disk_differs(&manifest, keep)?))
626 +
    }
627 +
628 +
    /// Whether the working copy differs from `manifest`: a file added,
629 +
    /// gone or changed. With `keep`, the listing made is left for the next
630 +
    /// walk of the working copy when it does.
631 +
    fn disk_differs(&self, manifest: &Manifest, keep: bool) -> Result<bool> {
481 632
        use rayon::prelude::*;
482 633
        let sparse = &self.config.sparse_paths;
483 634
        let in_scope = |p: &str| {
…
8 unchanged lines
492 643
        // base's, and a prompt's answer is the same either way.
493 644
        let known = |p: &str| manifest.entries.contains_key(p);
494 645
        let Ok(listing) = super::walk::list_knowing(&self.root, &in_scope, &known) else {
495 -
            return Ok(Some(true));
646 +
            return Ok(true);
496 647
        };
497 648
        let differs = || -> Result<bool> {
498 649
            let links_differ = listing.symlinks.iter().any(|(rel, target)| {
…
71 unchanged lines
570 721
        if dirty && keep {
571 722
            *self.listing_cache.borrow_mut() = Some(listing);
572 723
        }
573 -
        Ok(Some(dirty))
724 +
        Ok(dirty)
574 725
    }
575 726
576 727
    /// Prompt information without recording anything.
…
74 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +21 −0
…
479 unchanged lines
480 480
                        .blobs
481 481
                        .iter()
482 482
                        .any(|(p, b)| m.entries.get(p).and_then(|e| e.blob) != Some(*b))
483 +
                    // With uncommitted work, the status cache below holds
484 +
                    // only while the manifest describes the edited files as
485 +
                    // they are now.
486 +
                    || (uncommitted && statuses.iter().any(|st| {
487 +
                        let Some(e) = fs_entries.get(&st.path) else {
488 +
                            return m.entries.contains_key(&st.path);
489 +
                        };
490 +
                        let Some(me) = m.entries.get(&st.path) else {
491 +
                            return true;
492 +
                        };
493 +
                        let hash = e
494 +
                            .content_hash
495 +
                            .unwrap_or_else(|| *blake3::hash(&e.content).as_bytes());
496 +
                        let stat = e.stat.or_else(|| {
497 +
                            fs::symlink_metadata(self.root.join(&st.path))
498 +
                                .ok()
499 +
                                .map(|md| (md.len(), super::prompt::mtime_ns(&md)))
500 +
                        });
501 +
                        me.hash != hash || stat != Some((me.size, me.mtime_ns))
502 +
                    }))
483 503
            }
484 504
        };
485 505
        if worth {
486 506
            self.write_manifest_entries(conflicted, uncommitted, &fs_entries, &built.blobs)?;
487 507
        }
508 +
        self.write_status_cache((uncommitted && !conflicted).then_some(&new_hash), &statuses);
488 509
        let attributes = crate::attributes::Attributes::from_files(
489 510
            fs_entries
490 511
                .iter()
…
2105 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +54 −0
…
5900 unchanged lines
5901 5901
    let _ = first;
5902 5902
}
5903 5903
5904 +
/// oxygen#41: with uncommitted work, a status after a snapshot is answered
5905 +
/// from the status cache while every file is as the snapshot left it, and
5906 +
/// from a snapshot again as soon as one is not.
5907 +
#[test]
5908 +
fn status_with_uncommitted_work_comes_from_the_cache_until_a_file_changes() {
5909 +
    hermetic();
5910 +
    let dir = tempfile::tempdir().unwrap();
5911 +
    let root = dir.path();
5912 +
    let repo = Repo::init(root).unwrap();
5913 +
    write(root, "a.txt", b"a\n");
5914 +
    write(root, "b.txt", b"b\n");
5915 +
    repo.commit("base", &[]).unwrap();
5916 +
    assert_eq!(repo.status_by_manifest().unwrap(), Some(vec![]), "clean");
5917 +
5918 +
    write(root, "a.txt", b"a\nmore\n");
5919 +
    assert_eq!(
5920 +
        repo.status_by_manifest().unwrap(),
5921 +
        None,
5922 +
        "no snapshot of the edit yet"
5923 +
    );
5924 +
    let snap = repo.snapshot_status("oxy status").unwrap();
5925 +
    assert_eq!(snap.statuses.len(), 1);
5926 +
    let cached = repo
5927 +
        .status_by_manifest()
5928 +
        .unwrap()
5929 +
        .expect("answered from the cache");
5930 +
    assert_eq!(cached.len(), 1);
5931 +
    assert_eq!(
5932 +
        (cached[0].path.as_str(), cached[0].status),
5933 +
        ("a.txt", ChangeStatus::Modified)
5934 +
    );
5935 +
5936 +
    // Any further change sends status back to a snapshot.
5937 +
    write(root, "a.txt", b"a\nmore\nand more\n");
5938 +
    assert_eq!(repo.status_by_manifest().unwrap(), None, "edited again");
5939 +
    repo.snapshot_status("oxy status").unwrap();
5940 +
    assert!(repo.status_by_manifest().unwrap().is_some());
5941 +
    write(root, "new.txt", b"n\n");
5942 +
    assert_eq!(repo.status_by_manifest().unwrap(), None, "a file added");
5943 +
    fs::remove_file(root.join("new.txt")).unwrap();
5944 +
    assert!(
5945 +
        repo.status_by_manifest().unwrap().is_some(),
5946 +
        "back as it was"
5947 +
    );
5948 +
    fs::remove_file(root.join("b.txt")).unwrap();
5949 +
    assert_eq!(repo.status_by_manifest().unwrap(), None, "a file removed");
5950 +
    write(root, "b.txt", b"b\n");
5951 +
5952 +
    // A commit leaves nothing uncommitted: clean again, no cache needed.
5953 +
    repo.snapshot_status("oxy status").unwrap();
5954 +
    repo.commit("more", &[]).unwrap();
5955 +
    assert_eq!(repo.status_by_manifest().unwrap(), Some(vec![]));
5956 +
}
5957 +
5904 5958
/// oxygen#35: a history-only state keeps no inserted text. It reads a
5905 5959
/// stored insert's text blob only when it builds that file's graph, and
5906 5960
/// builds the same graph a full load does.
…
708 unchanged lines
modified docs/benchmarks.md +20 −0
…
749 unchanged lines
750 750
file added once and never edited straight from its text blob, keep sizes
751 751
in the render cache so a stat does not render, serve FUSE on several
752 752
threads, and read the state index lazily for a mount under 100 ms.
753 +
754 +
### Status with work in progress (2026-10-01, oxygen#41)
755 +
756 +
`MAINTAINERS` edited on the Linux tree on Earth (load average 2.2, one
757 +
Flow job running), oxy and Git alternating, three runs each:
758 +
759 +
| | before | after | Git |
760 +
|---|---:|---:|---:|
761 +
| status, one file edited (after a first status) | 0.42 to 0.46 s, 330 MB | **0.16 to 0.17 s**, 120 to 155 MB | 0.20 to 0.21 s |
762 +
| status, clean | 0.18 to 0.20 s | **0.15 to 0.16 s** | 0.20 to 0.24 s |
763 +
| diff, one file | 0.43 s | 0.40 to 0.41 s | 0.08 to 0.09 s |
764 +
765 +
Before, the manifest only proved a clean working copy. With uncommitted
766 +
work every status listed and snapshotted the tree again, loaded the state
767 +
index and rendered the base (listing 164 ms, index 60 ms, base 33 ms). Now
768 +
a snapshot leaves a status cache keyed by the working-copy change and the
769 +
manifest's checksum, and keeps the manifest current for the edited files.
770 +
A later status that finds every file as the manifest says answers from
771 +
the cache. Test builds check that answer against a snapshot. Diff still
772 +
renders the base; it is next.
modified src/cli/workspace.rs +30 −23
…
90 unchanged lines
91 91
    );
92 92
}
93 93
94 +
/// The working copy's changed paths, or that it is clean.
95 +
fn print_statuses(statuses: &[oxy_core::repo::PathStatus]) {
96 +
    if statuses.is_empty() {
97 +
        println!(
98 +
            "  {} {}",
99 +
            ui::ok(ui::glyph("✔", "*")),
100 +
            ui::dim("nothing to commit, working copy clean")
101 +
        );
102 +
        return;
103 +
    }
104 +
    println!();
105 +
    for s in statuses {
106 +
        println!(
107 +
            "  {} {}",
108 +
            ui::status_mark(status_tag(s.status)),
109 +
            ui::path(&s.path)
110 +
        );
111 +
    }
112 +
}
113 +
94 114
pub fn status(json: bool) -> Result<i32> {
95 115
    let repo = open()?;
96 -
    // Clean by the manifest alone: no history to load (oxygen#35).
97 -
    if !json && repo.clean_by_manifest()? {
116 +
    // Answered by the manifest alone, with no history to load (oxygen#35,
117 +
    // #41): a clean working copy, or one as the last snapshot left it.
118 +
    let known = if json {
119 +
        None
120 +
    } else {
121 +
        repo.status_by_manifest()?
122 +
    };
123 +
    if let Some(statuses) = known {
98 124
        let (_, wc) = repo.working_copy()?;
99 125
        let heads = repo.bookmark_heads()?;
100 126
        println!(
…
5 unchanged lines
106 132
            ui::sep(),
107 133
            ui::dim(plural(heads.len(), "head"))
108 134
        );
109 -
        println!(
110 -
            "  {} {}",
111 -
            ui::ok(ui::glyph("✔", "*")),
112 -
            ui::dim("nothing to commit, working copy clean")
113 -
        );
135 +
        print_statuses(&statuses);
114 136
        print_untracked_nested(&repo, &repo.untracked_nested());
115 137
        return Ok(0);
116 138
    }
…
22 unchanged lines
139 161
        ui::sep(),
140 162
        ui::dim(plural(heads.len(), "head"))
141 163
    );
142 -
    if snap.statuses.is_empty() {
143 -
        println!(
144 -
            "  {} {}",
145 -
            ui::ok(ui::glyph("✔", "*")),
146 -
            ui::dim("nothing to commit, working copy clean")
147 -
        );
148 -
    } else {
149 -
        println!();
150 -
        for s in &snap.statuses {
151 -
            println!(
152 -
                "  {} {}",
153 -
                ui::status_mark(status_tag(s.status)),
154 -
                ui::path(&s.path)
155 -
            );
156 -
        }
157 -
    }
164 +
    print_statuses(&snap.statuses);
158 165
    print_untracked_nested(&repo, &snap.untracked_nested);
159 166
    if !snap.base_snapshot.is_conflicted() {
160 167
        return Ok(0);
…
2199 unchanged lines