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