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 | + |