OXYGENHUB

Stat tracked files relative to their directories (refs #41) e96a430c78f3

The manifest check stats each tracked file by its full path, and on the
Linux tree most of a clean status was the kernel resolving every path
component from the root again. stat_tracked groups the files by
directory, opens each directory once and stats relative to it (fstatat
through libc, already a workspace dependency); walk::Stat (size, time,
executable bit) replaces fs::Metadata in listings so either way of
statting fills them. The check zips the manifest's entries rather than
looking each one up by path.

Stat check 84 ms to 73 ms; a clean status 0.10 to 0.12 s, level with Git
tuned for large repositories (0.10 to 0.11 s).
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-01 23:24 UTC
Signature
✔ signed by Andy McCall
Hash
e96a430c78f33620f5dbe2e33cba0780c818aa92d32d6c569361d3bb42838bf9
Depends on
135212de5370 1966f5db15ee 1d360c91a289 1ec3f008e96a 3604179679eb 36e51aa806c6 5ae7533f1888 75167672ebcd 76740263089c 8b276e0106d1 982864068a59 9ac1fbe211b4 cc7a00565161 e226a6cc2f71 e7ddd552b619 e993a086c13f ff0af1ee299d
Depended on by
80677b7a3513 5b4c51307b14 fddf64baa3d2
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
22 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified Cargo.lock +1 −0
…
1270 unchanged lines
1271 1271
 "globset",
1272 1272
 "hex",
1273 1273
 "ignore",
1274 +
 "libc",
1274 1275
 "ml-dsa",
1275 1276
 "proptest",
1276 1277
 "rand 0.8.8",
…
1284 unchanged lines
modified crates/oxy-core/Cargo.toml +3 −0
…
21 unchanged lines
22 22
globset = "0.4"
23 23
rayon = "1"
24 24
25 +
[target.'cfg(unix)'.dependencies]
26 +
libc = "0.2"
27 +
25 28
[dev-dependencies]
26 29
proptest = "1"
27 30
tempfile = "3"
modified crates/oxy-core/src/repo/mod.rs +10 −11
…
1643 unchanged lines
1644 1644
                    ),
1645 1645
                );
1646 1646
            } else if ft.is_file() {
1647 -
                let e = self.file_entry(rel, &path, &meta, cache)?;
1647 +
                let e = self.file_entry(rel, &path, &walk::Stat::of(&meta), cache)?;
1648 1648
                out.insert(rel.clone(), e);
1649 1649
            }
1650 1650
        }
…
5 unchanged lines
1656 1656
        &self,
1657 1657
        rel: &str,
1658 1658
        path: &Path,
1659 -
        meta: &fs::Metadata,
1659 +
        meta: &walk::Stat,
1660 1660
        cache: Option<&prompt::Manifest>,
1661 1661
    ) -> Result<FsEntry> {
1662 1662
        let mut e = read_file_entry(rel, path, meta, cache, None)?;
…
353 unchanged lines
2016 2016
}
2017 2017
2018 2018
#[cfg(unix)]
2019 -
fn is_executable(m: &fs::Metadata) -> bool {
2019 +
pub(crate) fn is_executable(m: &fs::Metadata) -> bool {
2020 2020
    use std::os::unix::fs::PermissionsExt;
2021 2021
    m.permissions().mode() & 0o111 != 0
2022 2022
}
2023 2023
#[cfg(not(unix))]
2024 -
fn is_executable(_m: &fs::Metadata) -> bool {
2024 +
pub(crate) fn is_executable(_m: &fs::Metadata) -> bool {
2025 2025
    false
2026 2026
}
2027 2027
…
20 unchanged lines
2048 2048
/// the racy window.
2049 2049
fn stat_cached<'m>(
2050 2050
    rel: &str,
2051 -
    meta: &fs::Metadata,
2051 +
    meta: &walk::Stat,
2052 2052
    cache: Option<&'m prompt::Manifest>,
2053 2053
) -> Option<&'m prompt::ManifestEntry> {
2054 2054
    let m = cache?;
2055 2055
    let e = m.entries.get(rel)?;
2056 -
    let mtime_ns = prompt::mtime_ns(meta);
2057 -
    let racy = mtime_ns + 2_000_000_000 >= m.written_ns;
2058 -
    (e.size == meta.len() && e.mtime_ns == mtime_ns && !racy).then_some(e)
2056 +
    let racy = meta.mtime_ns + 2_000_000_000 >= m.written_ns;
2057 +
    (e.size == meta.size && e.mtime_ns == meta.mtime_ns && !racy).then_some(e)
2059 2058
}
2060 2059
2061 2060
/// Read one working-copy file, or serve it from the stat cache (spec
…
7 unchanged lines
2069 2068
fn read_file_entry(
2070 2069
    rel: &str,
2071 2070
    path: &Path,
2072 -
    meta: &fs::Metadata,
2071 +
    meta: &walk::Stat,
2073 2072
    cache: Option<&prompt::Manifest>,
2074 2073
    key: Option<[u8; 32]>,
2075 2074
) -> Result<FsEntry> {
2076 -
    let exec = is_executable(meta);
2077 -
    let stat = Some((meta.len(), prompt::mtime_ns(meta)));
2075 +
    let exec = meta.exec;
2076 +
    let stat = Some((meta.size, meta.mtime_ns));
2078 2077
    let mut e = match stat_cached(rel, meta, cache) {
2079 2078
        Some(e) if e.blob.is_some() => FsEntry {
2080 2079
            blob: e.blob,
…
20 unchanged lines
modified crates/oxy-core/src/repo/prompt.rs +134 −64
…
287 unchanged lines
288 288
    ))
289 289
}
290 290
291 +
/// A tracked path as it is now: a file's stat, or a symlink's target.
292 +
enum Tracked {
293 +
    File(super::walk::Stat),
294 +
    Link(Vec<u8>),
295 +
}
296 +
297 +
/// The stat of every path in `rels` (relative to `root`; `None` for one
298 +
/// gone or neither a file nor a symlink), in the same order. Paths are
299 +
/// grouped by directory and each directory is opened once: a stat relative
300 +
/// to it resolves one name, where a stat by full path walks every
301 +
/// component from the root again (most of a clean status's time, in the
302 +
/// kernel's path lookup, on the Linux tree).
303 +
fn stat_tracked(root: &Path, rels: &[&String]) -> Vec<Option<Tracked>> {
304 +
    use rayon::prelude::*;
305 +
    let mut by_dir: HashMap<&str, Vec<(usize, &str)>> = HashMap::new();
306 +
    for (i, rel) in rels.iter().enumerate() {
307 +
        let (dir, name) = rel.rsplit_once('/').unwrap_or(("", rel.as_str()));
308 +
        by_dir.entry(dir).or_default().push((i, name));
309 +
    }
310 +
    let groups: Vec<(&str, Vec<(usize, &str)>)> = by_dir.into_iter().collect();
311 +
    let found: Vec<(usize, Option<Tracked>)> = groups
312 +
        .par_iter()
313 +
        .flat_map_iter(|(dir, names)| {
314 +
            let handle = fs::File::open(root.join(dir)).ok();
315 +
            names
316 +
                .iter()
317 +
                .map(|(i, name)| {
318 +
                    let rel = rels[*i];
319 +
                    let now = match &handle {
320 +
                        Some(d) => stat_in(d, name, || root.join(rel)),
321 +
                        None => None,
322 +
                    };
323 +
                    (*i, now)
324 +
                })
325 +
                .collect::<Vec<_>>()
326 +
        })
327 +
        .collect();
328 +
    let mut out: Vec<Option<Tracked>> = (0..rels.len()).map(|_| None).collect();
329 +
    for (i, now) in found {
330 +
        out[i] = now;
331 +
    }
332 +
    out
333 +
}
334 +
335 +
/// One entry of the open directory `dir`; `path` builds its full path,
336 +
/// needed only for a symlink's target.
337 +
#[cfg(unix)]
338 +
fn stat_in(dir: &fs::File, name: &str, path: impl Fn() -> PathBuf) -> Option<Tracked> {
339 +
    use std::os::fd::AsRawFd;
340 +
    let c = std::ffi::CString::new(name).ok()?;
341 +
    let mut st: libc::stat = unsafe { std::mem::zeroed() };
342 +
    // SAFETY: a valid directory descriptor, a NUL-terminated name, and a
343 +
    // stat buffer of the right type that outlives the call.
344 +
    let r = unsafe {
345 +
        libc::fstatat(
346 +
            dir.as_raw_fd(),
347 +
            c.as_ptr(),
348 +
            &mut st,
349 +
            libc::AT_SYMLINK_NOFOLLOW,
350 +
        )
351 +
    };
352 +
    if r != 0 {
353 +
        return None;
354 +
    }
355 +
    match st.st_mode & libc::S_IFMT {
356 +
        libc::S_IFREG => Some(Tracked::File(super::walk::Stat {
357 +
            size: st.st_size as u64,
358 +
            // As `mtime_ns` reads it: nanoseconds since the epoch, 0 before it.
359 +
            mtime_ns: if st.st_mtime < 0 {
360 +
                0
361 +
            } else {
362 +
                st.st_mtime as u128 * 1_000_000_000 + st.st_mtime_nsec as u128
363 +
            },
364 +
            exec: st.st_mode & 0o111 != 0,
365 +
        })),
366 +
        libc::S_IFLNK => {
367 +
            let t = fs::read_link(path()).ok()?;
368 +
            Some(Tracked::Link(t.to_string_lossy().as_bytes().to_vec()))
369 +
        }
370 +
        _ => None,
371 +
    }
372 +
}
373 +
374 +
#[cfg(not(unix))]
375 +
fn stat_in(_dir: &fs::File, _name: &str, path: impl Fn() -> PathBuf) -> Option<Tracked> {
376 +
    let p = path();
377 +
    let m = fs::symlink_metadata(&p).ok()?;
378 +
    if m.file_type().is_symlink() {
379 +
        let t = fs::read_link(&p).ok()?;
380 +
        Some(Tracked::Link(t.to_string_lossy().as_bytes().to_vec()))
381 +
    } else if m.is_file() {
382 +
        Some(Tracked::File(super::walk::Stat::of(&m)))
383 +
    } else {
384 +
        None
385 +
    }
386 +
}
387 +
291 388
/// The status cache: a magic number, the working-copy change, the
292 389
/// manifest checksum, then each status line (a status byte and a path).
293 390
const STATUS_MAGIC: &[u8; 8] = b"oxystat1";
…
423 unchanged lines
717 814
        });
718 815
        if !dirs_same {
719 816
            return None;
720 -
        }
721 -
        // Each tracked path as it is now: a file's stat, a symlink's target.
722 -
        enum Now {
723 -
            File(PathBuf, fs::Metadata),
724 -
            Link(Vec<u8>),
725 817
        }
726 -
        let differs_from = |me: &ManifestEntry, path: &Path, now: &Now| match now {
727 -
            Now::Link(t) => *blake3::hash(t).as_bytes() != me.hash,
728 -
            Now::File(_, m) => {
729 -
                m.len() != me.size
730 -
                    || super::is_executable(m) != me.exec
731 -
                    || (mtime_ns(m) != me.mtime_ns
732 -
                        && !fs::read(path).is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash))
818 +
        let rels: Vec<&String> = manifest.entries.keys().collect();
819 +
        let now = stat_tracked(root, &rels);
820 +
        let differs_from = |me: &ManifestEntry, rel: &str, now: &Tracked| match now {
821 +
            Tracked::Link(t) => *blake3::hash(t).as_bytes() != me.hash,
822 +
            Tracked::File(m) => {
823 +
                m.size != me.size
824 +
                    || m.exec != me.exec
825 +
                    || (m.mtime_ns != me.mtime_ns
826 +
                        && !fs::read(root.join(rel))
827 +
                            .is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash))
733 828
            }
734 829
        };
735 -
        let stat = |rel: &str| -> Option<Now> {
736 -
            let path = root.join(rel);
737 -
            let m = fs::symlink_metadata(&path).ok()?;
738 -
            if m.file_type().is_symlink() {
739 -
                let t = fs::read_link(&path).ok()?;
740 -
                Some(Now::Link(t.to_string_lossy().as_bytes().to_vec()))
741 -
            } else if m.is_file() {
742 -
                Some(Now::File(path, m))
743 -
            } else {
744 -
                None
745 -
            }
746 -
        };
747 -
        let differs = if !keep {
748 -
            manifest
749 -
                .entries
750 -
                .par_iter()
751 -
                .any(|(rel, me)| match stat(rel) {
752 -
                    Some(now) => differs_from(me, &root.join(rel.as_str()), &now),
753 -
                    None => true,
754 -
                })
755 -
        } else {
756 -
            let now: Vec<(&String, Option<Now>)> = manifest
757 -
                .entries
758 -
                .par_iter()
759 -
                .map(|(rel, _)| (rel, stat(rel)))
760 -
                .collect();
761 -
            // A path gone or no longer a file: its directory moved, unless
762 -
            // within a clock tick; list the tree to be sure.
763 -
            if now.iter().any(|(_, n)| n.is_none()) {
764 -
                return None;
765 -
            }
766 -
            let differs = now.par_iter().any(|(rel, n)| {
767 -
                let me = &manifest.entries[*rel];
768 -
                differs_from(me, &root.join(rel.as_str()), n.as_ref().unwrap())
769 -
            });
770 -
            if differs || racy {
771 -
                let mut listing = super::walk::Listing {
772 -
                    dirs: manifest.dirs.clone(),
773 -
                    nested: manifest.nested.iter().map(|n| root.join(n)).collect(),
774 -
                    ..Default::default()
775 -
                };
776 -
                for (rel, n) in now {
777 -
                    match n.unwrap() {
778 -
                        Now::File(path, m) => listing.files.push((rel.clone(), path, m)),
779 -
                        Now::Link(t) => listing.symlinks.push((rel.clone(), t)),
780 -
                    }
830 +
        // A path gone or no longer a file: its directory moved, unless
831 +
        // within a clock tick; list the tree to be sure.
832 +
        if now.iter().any(Option::is_none) {
833 +
            return None;
834 +
        }
835 +
        // The entries in the order `rels` was taken in: no lookup by path.
836 +
        let entries: Vec<&ManifestEntry> = manifest.entries.values().collect();
837 +
        let differs = rels
838 +
            .par_iter()
839 +
            .zip(entries.par_iter())
840 +
            .zip(now.par_iter())
841 +
            .any(|((rel, me), n)| differs_from(me, rel, n.as_ref().unwrap()));
842 +
        if keep && (differs || racy) {
843 +
            let mut listing = super::walk::Listing {
844 +
                dirs: manifest.dirs.clone(),
845 +
                nested: manifest.nested.iter().map(|n| root.join(n)).collect(),
846 +
                ..Default::default()
847 +
            };
848 +
            for (rel, n) in rels.into_iter().zip(now) {
849 +
                match n.unwrap() {
850 +
                    Tracked::File(m) => listing.files.push((rel.clone(), root.join(rel), m)),
851 +
                    Tracked::Link(t) => listing.symlinks.push((rel.clone(), t)),
781 852
                }
782 -
                *self.listing_cache.borrow_mut() = Some(listing);
783 853
            }
784 -
            differs
785 -
        };
854 +
            *self.listing_cache.borrow_mut() = Some(listing);
855 +
        }
786 856
        if !differs {
787 857
            // Nested repositories the manifest has no entry for are not
788 858
            // subrepos: nothing in them is tracked (oxygen#43).
…
39 unchanged lines
828 898
            // first difference; an unreadable one counts as different.
829 899
            let files_differ = listing.files.par_iter().any(|(rel, path, m)| {
830 900
                manifest.entries.get(rel).is_none_or(|me| {
831 -
                    m.len() != me.size
832 -
                        || super::is_executable(m) != me.exec
833 -
                        || (mtime_ns(m) != me.mtime_ns
901 +
                    m.size != me.size
902 +
                        || m.exec != me.exec
903 +
                        || (m.mtime_ns != me.mtime_ns
834 904
                            && !fs::read(path)
835 905
                                .is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash))
836 906
                })
…
134 unchanged lines
modified crates/oxy-core/src/repo/walk.rs +21 −2
…
15 unchanged lines
16 16
use ignore::Match;
17 17
use std::sync::{Arc, Mutex};
18 18
19 +
/// What a stat says about a file that the working copy needs: its size,
20 +
/// modification time and executable bit.
21 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22 +
pub(crate) struct Stat {
23 +
    pub size: u64,
24 +
    pub mtime_ns: u128,
25 +
    pub exec: bool,
26 +
}
27 +
28 +
impl Stat {
29 +
    pub(crate) fn of(m: &fs::Metadata) -> Stat {
30 +
        Stat {
31 +
            size: m.len(),
32 +
            mtime_ns: super::prompt::mtime_ns(m),
33 +
            exec: super::is_executable(m),
34 +
        }
35 +
    }
36 +
}
37 +
19 38
/// What a listing found, each list in path order.
20 39
#[derive(Default)]
21 40
pub(crate) struct Listing {
22 41
    /// Regular files: path relative to the root (with `/`), full path, stat.
23 -
    pub files: Vec<(String, PathBuf, fs::Metadata)>,
42 +
    pub files: Vec<(String, PathBuf, Stat)>,
24 43
    /// Symbolic links and their targets.
25 44
    pub symlinks: Vec<(String, Vec<u8>)>,
26 45
    /// Directories holding a repository of their own.
…
175 unchanged lines
202 221
            }
203 222
        } else if ft.is_file() {
204 223
            match e.metadata() {
205 -
                Ok(m) => mine.files.push((child, path, m)),
224 +
                Ok(m) => mine.files.push((child, path, Stat::of(&m))),
206 225
                Err(e) => {
207 226
                    ctx.failed
208 227
                        .lock()
…
100 unchanged lines
modified docs/benchmarks.md +9 −0
…
869 unchanged lines
870 870
oxy's. oxy beats Git as it comes on status only. Claims of speed against
871 871
Git should say which Git.
872 872
873 +
Then the stat check by directory (same night): tracked files are grouped
874 +
by directory, each directory opened once, and each file statted relative
875 +
to it (`fstatat`), so a stat resolves one name rather than every component
876 +
from the root (the kernel's path lookup was most of a clean status). The
877 +
stat check went from 84 ms to 73 ms; a clean status is 0.10 to 0.12 s,
878 +
level with tuned Git (0.10 to 0.11 s). What is left is the 96 000 stat
879 +
calls themselves, the same work Git does; doing less needs a file watcher
880 +
(Git's fsmonitor), which oxy does not have.
881 +