OXYGENHUB

The manifest keeps directory times: status lists the tree only when a directory changed (refs #41) 1ec3f008e96a

Manifest format 3 records every directory the walk listed, with its time
read before the directory was, and the nested repositories. While no
directory's time has moved and none is within the racy window, the check
stats the tracked files and does not list the tree; otherwise it lists as
before. On the Linux tree: one-file status 0.10 to 0.12 s (from 0.16 s;
Git 0.22 s), clean 0.09 to 0.12 s (Git 0.20 to 0.26 s), 80 MB; diff 0.25 s.

The racy-window guard moves into the shared check, so the prompt's
quick_dirty hashes files written within it too: a same-size rewrite on a
coarse clock read as clean there (Flow run #176 on plex).
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-01 19:58 UTC
Signature
✔ signed by Andy McCall
Hash
1ec3f008e96a7e781002706ba550165cd0b3da2f00bde9071cac11dc7534bf63
Depends on
135212de5370 1966f5db15ee 1d360c91a289 3604179679eb 36e51aa806c6 75167672ebcd 76740263089c 77445673174b 86e1824f2d0b 8fd78eb52ad2 982864068a59 98a1620a3840 b7f102fcbb17 c07406bcc237 cc7a00565161 e02bf0cd0533 f9466021286a
Depended on by
767ac6cd0545 80677b7a3513 94cdbc576ce1 236f35c0baba 0389c3e82f6c e96a430c78f3 e226a6cc2f71 10b4fd430eab
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
34 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified CHANGELOG.md +1 −1
…
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.
5 +
- **Status twice as fast as Git on the Linux tree (#41).** With uncommitted work, `oxy status` answers from a status cache while every file is as the last snapshot left it, and the manifest keeps every directory's time, so the tree is listed only when a directory's entries changed. One edited file is 0.10 to 0.12 s (from 0.44 s; Git 0.22 s) and a clean status 0.09 to 0.12 s (Git 0.20 to 0.26 s), in 80 MB. `oxy diff` renders the working copy from the cache too (0.25 s, from 0.41 s). A file written within the racy window is now hashed by the prompt's check too, as it was by status.
6 6
7 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.
8 8
…
53 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +13 −0
…
230 unchanged lines
231 231
    /// `--at-op`: read the repository as it was after this operation;
232 232
    /// recording an operation is refused.
233 233
    at_op: Option<ObjectRef>,
234 +
    /// The directories (with their times) and nested repositories of the
235 +
    /// last walk, for the manifest written from it.
236 +
    pub(crate) walked_dirs: std::cell::RefCell<Option<WalkedDirs>>,
234 237
}
235 238
239 +
/// A walk's directories with their times, and its nested repositories.
240 +
pub(crate) type WalkedDirs = (Vec<(String, u128)>, Vec<String>);
241 +
236 242
/// Marker file of a bare repository, inside `.oxy/`.
237 243
const BARE_MARKER: &str = "bare";
238 244
/// Marker of a hosted repository that has no id yet: the first push binds
…
59 unchanged lines
298 304
        let repo = Repo {
299 305
            last_change: Default::default(),
300 306
            at_op: None,
307 +
            walked_dirs: Default::default(),
301 308
            git_export_skipped: Default::default(),
302 309
            manifest_cache: Default::default(),
303 310
            listing_cache: Default::default(),
…
48 unchanged lines
352 359
        let repo = Repo {
353 360
            last_change: Default::default(),
354 361
            at_op: None,
362 +
            walked_dirs: Default::default(),
355 363
            git_export_skipped: Default::default(),
356 364
            manifest_cache: Default::default(),
357 365
            listing_cache: Default::default(),
…
70 unchanged lines
428 436
                let mut repo = Repo {
429 437
                    last_change: Default::default(),
430 438
                    at_op: None,
439 +
                    walked_dirs: Default::default(),
431 440
                    git_export_skipped: Default::default(),
432 441
                    manifest_cache: Default::default(),
433 442
                    listing_cache: Default::default(),
…
25 unchanged lines
459 468
                let mut repo = Repo {
460 469
                    last_change: Default::default(),
461 470
                    at_op: None,
471 +
                    walked_dirs: Default::default(),
462 472
                    git_export_skipped: Default::default(),
463 473
                    manifest_cache: Default::default(),
464 474
                    listing_cache: Default::default(),
…
1031 unchanged lines
1496 1506
            files,
1497 1507
            symlinks,
1498 1508
            nested,
1509 +
            dirs,
1499 1510
        } = match self.listing_cache.take() {
1500 1511
            Some(l) => l,
1501 1512
            // Ignore rules apply to untracked paths only, so the base's
…
17 unchanged lines
1519 1530
                .all(|r| on_disk.contains(r.as_str()));
1520 1531
            attrs_unchanged && none_removed
1521 1532
        });
1533 +
        *self.walked_dirs.borrow_mut() =
1534 +
            Some((dirs, nested.iter().map(|d| self.rel_path(d)).collect()));
1522 1535
        drop(walk_phase);
1523 1536
        let _read_phase = crate::timing::phase("walk:   of which stat and compare");
1524 1537
        // Reading files is most of a walk's cost; do it in parallel.
…
566 unchanged lines
modified crates/oxy-core/src/repo/prompt.rs +105 −8
…
145 unchanged lines
146 146
    /// When the manifest was written, for the racy-stat guard.
147 147
    pub written_ns: u128,
148 148
    pub entries: BTreeMap<String, ManifestEntry>,
149 +
    /// Every directory the walk listed, with its time then; empty when the
150 +
    /// manifest was not written from a walk (a checkout), and then the
151 +
    /// check lists the tree.
152 +
    pub dirs: Vec<(String, u128)>,
153 +
    /// Directories holding a repository of their own, as the walk found.
154 +
    pub nested: Vec<String>,
149 155
}
150 156
151 157
/// A parsed manifest and the identity (size, time, inode) of the file it
…
20 unchanged lines
172 178
/// recognised; BLAKE3 of everything else; then each entry in path order:
173 179
/// path, size, mtime in nanoseconds, BLAKE3 of the content, the stored
174 180
/// blob of an opaque file, the graph key it was found equal to, and
175 -
/// whether it is executable. A file that does not check, or in any other
181 +
/// whether it is executable. Format 3 puts before the entries every
182 +
/// directory the walk listed with its time, and the nested repositories. A file that does not check, or in any other
176 183
/// layout (an older manifest), reads as no manifest, and the next snapshot
177 184
/// writes it again: it is a cache, written without waiting for the disk.
178 -
const MANIFEST_MAGIC: &[u8; 8] = b"oxyman2\0";
185 +
const MANIFEST_MAGIC: &[u8; 8] = b"oxyman3\0";
179 186
/// Where the written time sits: after the magic and the two flags.
180 187
const MANIFEST_WRITTEN: std::ops::Range<usize> = 10..26;
181 188
/// Where the checksum sits, after the written time.
…
10 unchanged lines
192 199
    conflicted: bool,
193 200
    uncommitted: bool,
194 201
    written_ns: u128,
202 +
    dirs: &[(String, u128)],
203 +
    nested: &[String],
195 204
    entries: &mut dyn Iterator<Item = (&str, ManifestEntry)>,
196 205
) -> Vec<u8> {
197 206
    let mut w = crate::flat::Writer::default();
…
2 unchanged lines
200 209
    w.u8(uncommitted as u8);
201 210
    w.fixed(&written_ns.to_le_bytes());
202 211
    w.fixed(&[0; 32]);
212 +
    w.count(dirs.len());
213 +
    for (d, t) in dirs {
214 +
        w.bytes(d.as_bytes());
215 +
        w.fixed(&t.to_le_bytes());
216 +
    }
217 +
    w.count(nested.len());
218 +
    for n in nested {
219 +
        w.bytes(n.as_bytes());
220 +
    }
203 221
    for (path, e) in entries {
204 222
        w.bytes(path.as_bytes());
205 223
        w.u64(e.size);
…
24 unchanged lines
230 248
    let written_ns = u128::from_le_bytes(r.fixed().ok()?);
231 249
    if r.fixed::<32>().ok()? != manifest_sum(buf) {
232 250
        return None;
251 +
    }
252 +
    let mut dirs = Vec::new();
253 +
    for _ in 0..r.count().ok()? {
254 +
        let d = std::str::from_utf8(r.bytes().ok()?).ok()?.to_string();
255 +
        dirs.push((d, u128::from_le_bytes(r.fixed().ok()?)));
256 +
    }
257 +
    let mut nested = Vec::new();
258 +
    for _ in 0..r.count().ok()? {
259 +
        nested.push(std::str::from_utf8(r.bytes().ok()?).ok()?.to_string());
233 260
    }
234 261
    let mut entries = Vec::new();
235 262
    while !r.at_end() {
…
19 unchanged lines
255 282
        Manifest {
256 283
            written_ns,
257 284
            entries,
285 +
            dirs,
286 +
            nested,
258 287
        },
259 288
    ))
260 289
}
…
109 unchanged lines
370 399
            };
371 400
            (path.as_str(), entry)
372 401
        });
373 -
        let new = encode_manifest(conflicted, uncommitted, written, &mut rows);
402 +
        // The directories of the walk these entries came from, if any.
403 +
        let (dirs, nested) = self.walked_dirs.take().unwrap_or_default();
404 +
        let new = encode_manifest(conflicted, uncommitted, written, &dirs, &nested, &mut rows);
374 405
        drop(rows);
375 406
        let p = self.manifest_path();
376 407
        // Leave an unchanged manifest alone when its stamp already trusts
…
40 unchanged lines
417 448
        });
418 449
        write_cache(
419 450
            &p,
420 -
            &encode_manifest(conflicted, uncommitted, m.written_ns, &mut rows),
451 +
            &encode_manifest(
452 +
                conflicted,
453 +
                uncommitted,
454 +
                m.written_ns,
455 +
                &m.dirs,
456 +
                &m.nested,
457 +
                &mut rows,
458 +
            ),
421 459
        )
422 460
    }
423 461
…
65 unchanged lines
489 527
        if conflicted || uncommitted || self.root.join(".oxymodules").exists() {
490 528
            return not("conflicted, uncommitted or subrepos");
491 529
        }
492 -
        if let Some(rel) = self.racy_change(&m) {
493 -
            return not(&format!("{rel} changed within the racy window"));
494 -
        }
495 530
        if self.disk_differs(&m, true)? {
496 531
            return not("a file differs");
497 532
        }
…
59 unchanged lines
557 592
        if now != Some(wc) || self.manifest_stamp() != Some(stamp) {
558 593
            return Ok(None);
559 594
        }
560 -
        if self.racy_change(&m).is_some() || self.disk_differs(&m, true)? {
595 +
        if self.disk_differs(&m, true)? {
561 596
            return Ok(None);
562 597
        }
563 598
        // Test builds check the cache against a snapshot of the files.
…
65 unchanged lines
629 664
    /// gone or changed. With `keep`, the listing made is left for the next
630 665
    /// walk of the working copy when it does.
631 666
    fn disk_differs(&self, manifest: &Manifest, keep: bool) -> Result<bool> {
667 +
        // A file written within the racy window can change without its size
668 +
        // or time moving (a coarse clock): those entries are hashed.
669 +
        if self.racy_change(manifest).is_some() {
670 +
            return Ok(true);
671 +
        }
672 +
        if let Some(differs) = self.disk_differs_by_dirs(manifest) {
673 +
            return Ok(differs);
674 +
        }
675 +
        self.disk_differs_listing(manifest, keep)
676 +
    }
677 +
678 +
    /// [`Repo::disk_differs`] without listing the tree, when the manifest
679 +
    /// knows its directories: a directory's time moves when an entry is
680 +
    /// added, removed or renamed in it, so with every directory's time as
681 +
    /// recorded (and none within the racy window) the files are the
682 +
    /// manifest's, and each is checked by its stat. `None` when a
683 +
    /// directory changed or the manifest has none: the tree must be listed.
684 +
    pub(crate) fn disk_differs_by_dirs(&self, manifest: &Manifest) -> Option<bool> {
685 +
        use rayon::prelude::*;
686 +
        if manifest.dirs.is_empty() || !self.config.sparse_paths.is_empty() {
687 +
            return None;
688 +
        }
689 +
        let _t = crate::timing::phase("status: directories and files by stat");
690 +
        let root = &self.root;
691 +
        let dirs_same = manifest.dirs.par_iter().all(|(rel, t)| {
692 +
            *t + 2_000_000_000 < manifest.written_ns
693 +
                && fs::metadata(root.join(rel)).is_ok_and(|m| m.is_dir() && mtime_ns(&m) == *t)
694 +
        });
695 +
        if !dirs_same {
696 +
            return None;
697 +
        }
698 +
        let differs = manifest.entries.par_iter().any(|(rel, me)| {
699 +
            let path = root.join(rel.as_str());
700 +
            let Ok(m) = fs::symlink_metadata(&path) else {
701 +
                return true;
702 +
            };
703 +
            if m.file_type().is_symlink() {
704 +
                return !fs::read_link(&path).is_ok_and(|t| {
705 +
                    *blake3::hash(t.to_string_lossy().as_bytes()).as_bytes() == me.hash
706 +
                });
707 +
            }
708 +
            !m.is_file()
709 +
                || m.len() != me.size
710 +
                || super::is_executable(&m) != me.exec
711 +
                || (mtime_ns(&m) != me.mtime_ns
712 +
                    && !fs::read(&path).is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash))
713 +
        });
714 +
        if !differs {
715 +
            // Nested repositories the manifest has no entry for are not
716 +
            // subrepos: nothing in them is tracked (oxygen#43).
717 +
            *self.untracked_nested.borrow_mut() = manifest
718 +
                .nested
719 +
                .iter()
720 +
                .filter(|rel| !manifest.entries.contains_key(*rel))
721 +
                .cloned()
722 +
                .collect();
723 +
        }
724 +
        Some(differs)
725 +
    }
726 +
727 +
    /// [`Repo::disk_differs`] by listing the tree.
728 +
    fn disk_differs_listing(&self, manifest: &Manifest, keep: bool) -> Result<bool> {
632 729
        use rayon::prelude::*;
633 730
        let sparse = &self.config.sparse_paths;
634 731
        let in_scope = |p: &str| {
…
167 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +52 −0
…
5980 unchanged lines
5981 5981
    assert_eq!(repo.diff_files(None).unwrap().len(), 3);
5982 5982
}
5983 5983
5984 +
/// oxygen#41: a manifest written from a walk keeps every directory's time;
5985 +
/// while none has moved, the check stats the tracked files without listing
5986 +
/// the tree, and a file added or removed anywhere moves its directory's
5987 +
/// time, so the tree is listed again.
5988 +
#[test]
5989 +
fn the_manifest_check_skips_listing_while_no_directory_changed() {
5990 +
    hermetic();
5991 +
    let dir = tempfile::tempdir().unwrap();
5992 +
    let root = dir.path();
5993 +
    let repo = Repo::init(root).unwrap();
5994 +
    write(root, "a.txt", b"a\n");
5995 +
    write(root, "sub/deep/b.txt", b"b\n");
5996 +
    repo.commit("base", &[]).unwrap();
5997 +
    // Out of the racy window: every file and directory a minute old.
5998 +
    let old = std::time::SystemTime::now() - std::time::Duration::from_secs(60);
5999 +
    for p in ["a.txt", "sub/deep/b.txt", "sub/deep", "sub", ""] {
6000 +
        fs::File::open(root.join(p))
6001 +
            .unwrap()
6002 +
            .set_modified(old)
6003 +
            .unwrap();
6004 +
    }
6005 +
    repo.snapshot_status("oxy status").unwrap();
6006 +
    repo.write_manifest(false, false).unwrap();
6007 +
    let m = repo.read_manifest().unwrap().unwrap().2;
6008 +
    assert!(m.dirs.iter().any(|(d, _)| d == "sub/deep"), "{:?}", m.dirs);
6009 +
    assert_eq!(
6010 +
        repo.disk_differs_by_dirs(&m),
6011 +
        Some(false),
6012 +
        "no listing needed"
6013 +
    );
6014 +
    assert_eq!(repo.status_by_manifest().unwrap(), Some(vec![]));
6015 +
6016 +
    // A same-size edit with a new time: found by its stat.
6017 +
    write(root, "sub/deep/b.txt", b"c\n");
6018 +
    assert_eq!(repo.disk_differs_by_dirs(&m), Some(true));
6019 +
    write(root, "sub/deep/b.txt", b"b\n");
6020 +
    fs::File::open(root.join("sub/deep/b.txt"))
6021 +
        .unwrap()
6022 +
        .set_modified(old)
6023 +
        .unwrap();
6024 +
    assert_eq!(repo.disk_differs_by_dirs(&m), Some(false));
6025 +
6026 +
    // A new file moves its directory's time: the tree must be listed.
6027 +
    write(root, "sub/deep/new.txt", b"n\n");
6028 +
    assert_eq!(repo.disk_differs_by_dirs(&m), None);
6029 +
    assert_eq!(repo.status_by_manifest().unwrap(), None);
6030 +
    fs::remove_file(root.join("sub/deep/new.txt")).unwrap();
6031 +
    // A removed one too.
6032 +
    fs::remove_file(root.join("a.txt")).unwrap();
6033 +
    assert_eq!(repo.disk_differs_by_dirs(&m), None);
6034 +
}
6035 +
5984 6036
/// oxygen#35: a history-only state keeps no inserted text. It reads a
5985 6037
/// stored insert's text blob only when it builds that file's graph, and
5986 6038
/// builds the same graph a full load does.
…
708 unchanged lines
modified crates/oxy-core/src/repo/walk.rs +24 −6
…
24 unchanged lines
25 25
    pub symlinks: Vec<(String, Vec<u8>)>,
26 26
    /// Directories holding a repository of their own.
27 27
    pub nested: Vec<PathBuf>,
28 +
    /// Every directory listed (the root as ""), with its modification time
29 +
    /// read before its entries were: a later change to its entries moves
30 +
    /// the time, so a manifest that keeps these can skip reading a
31 +
    /// directory whose time has not moved.
32 +
    pub dirs: Vec<(String, u128)>,
28 33
}
29 34
30 35
/// The ignore files in force for a directory, innermost first.
…
64 unchanged lines
95 100
        out: Mutex::new(Listing::default()),
96 101
        failed: Mutex::new(None),
97 102
    };
103 +
    let root_time = fs::metadata(root)
104 +
        .map(|m| super::prompt::mtime_ns(&m))
105 +
        .unwrap_or(0);
98 106
    rayon::scope(|s| {
99 107
        visit(
100 108
            s,
101 109
            root.to_path_buf(),
102 110
            String::new(),
111 +
            root_time,
103 112
            true,
104 113
            Rules::default(),
105 114
            &ctx,
…
8 unchanged lines
114 123
        use rayon::prelude::*;
115 124
        out.files.par_sort_unstable_by(|a, b| a.0.cmp(&b.0));
116 125
        out.symlinks.sort_unstable_by(|a, b| a.0.cmp(&b.0));
126 +
        out.dirs.sort_unstable_by(|a, b| a.0.cmp(&b.0));
127 +
        out.nested.sort_unstable();
117 128
    }
118 129
    Ok(out)
119 130
}
120 131
132 +
#[allow(clippy::too_many_arguments)]
121 133
fn visit<'s>(
122 134
    s: &rayon::Scope<'s>,
123 135
    dir: PathBuf,
124 136
    rel: String,
137 +
    mtime: u128,
125 138
    top: bool,
126 139
    rules: Rules,
127 140
    ctx: &'s Ctx<'s>,
…
23 unchanged lines
151 164
    }
152 165
    let rules = rules.enter(&dir, &names);
153 166
    let mut mine = Listing::default();
167 +
    mine.dirs.push((rel.clone(), mtime));
154 168
    for (e, name) in entries.into_iter().zip(names) {
155 169
        if name == OXY_DIR {
156 170
            continue;
…
10 unchanged lines
167 181
        }
168 182
        if ft.is_dir() {
169 183
            let rules = rules.clone();
170 -
            s.spawn(move |s| visit(s, path, child, false, rules, ctx));
184 +
            // The directory's time now, before it is read.
185 +
            let time = e
186 +
                .metadata()
187 +
                .map(|m| super::prompt::mtime_ns(&m))
188 +
                .unwrap_or(0);
189 +
            s.spawn(move |s| visit(s, path, child, time, false, rules, ctx));
171 190
        } else if !(ctx.in_scope)(&child) {
172 191
        } else if ft.is_symlink() {
173 192
            match fs::read_link(&path) {
…
19 unchanged lines
193 212
            }
194 213
        }
195 214
    }
196 -
    if !mine.files.is_empty() || !mine.symlinks.is_empty() {
197 -
        let mut out = ctx.out.lock().unwrap_or_else(|e| e.into_inner());
198 -
        out.files.append(&mut mine.files);
199 -
        out.symlinks.append(&mut mine.symlinks);
200 -
    }
215 +
    let mut out = ctx.out.lock().unwrap_or_else(|e| e.into_inner());
216 +
    out.files.append(&mut mine.files);
217 +
    out.symlinks.append(&mut mine.symlinks);
218 +
    out.dirs.append(&mut mine.dirs);
201 219
}
202 220
203 221
#[cfg(test)]
…
87 unchanged lines
modified docs/benchmarks.md +12 −0
…
778 unchanged lines
779 779
slower than a snapshot (0.9 s) and was not kept. What remains is the
780 780
status check's listing (about 130 ms), the index (60 ms) and rendering.
781 781
782 +
Then directory times (same evening, load average 3.1): the manifest
783 +
(format 3) keeps every directory the walk listed with its time, read
784 +
before the directory was. While no directory's time has moved, the check
785 +
stats the tracked files and does not list the tree; a directory whose
786 +
entries changed sends it back to a listing.
787 +
788 +
| | before | after | Git |
789 +
|---|---:|---:|---:|
790 +
| status, one file edited | 0.16 to 0.20 s, 120 to 155 MB | **0.10 to 0.12 s, 80 MB** | 0.22 s |
791 +
| status, clean | 0.15 to 0.21 s | **0.09 to 0.12 s, 80 MB** | 0.20 to 0.26 s |
792 +
| diff, one file | 0.34 s | 0.24 to 0.25 s | 0.09 to 0.13 s |
793 +