Status: a lean parallel lister of the working copy, reused by the snapshot after a failed clean check (refs #41) 76740263089c
The ignore crate's walker is replaced by walk::list: each directory is read once, stats come from the directory entries, ignore files are compiled only where they exist, and results are gathered per directory. On the Linux tree the listing drops from about 0.5 s to 0.18 s. A clean check that finds the working copy changed leaves its listing for the snapshot that follows.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-30 21:07 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
76740263089c13ac125888c02bef400ac817c5f7e6290ef67ccc525ba0fa7964- Depends on
- 135212de5370 4dee277c327a 75167672ebcd 8b276e0106d1 982864068a59 9ac1fbe211b4 b7f102fcbb17 cc7a00565161 d0990c2bff74 da868d5d2646 e7ddd552b619 eaf3e9068180
- Depended on by
- c397a8c36a9f 64dc5197f9df 4b9d06778581 b689779e9e74 170bb436e1d5 e96a430c78f3 1ec3f008e96a 3604179679eb d4dbab97e948 f9466021286a 36e51aa806c6 1966f5db15ee
- 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.2.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
- 26 atoms, 1 tree operation, 3 files
Browse the tree at this change
added crates/oxy-core/src/repo/walk.rs +254 −0
| 1 | + | //! Listing the working copy (spec §12.3): every file and symlink under the |
|
| 2 | + | //! root, found in parallel. `.oxy` is skipped, and so is a nested |
|
| 3 | + | //! repository (a directory holding `.oxy` or `.git`), which is reported |
|
| 4 | + | //! instead. `.oxyignore` files (and `.ignore` files, as before) exclude |
|
| 5 | + | //! paths with gitignore syntax, the nearest file deciding. |
|
| 6 | + | //! |
|
| 7 | + | //! Each directory is read once; a file's stat comes from its directory |
|
| 8 | + | //! entry, and results are gathered a directory at a time, so a tree of |
|
| 9 | + | //! 100 000 files costs about what its system calls cost. |
|
| 10 | + | ||
| 11 | + | use super::*; |
|
| 12 | + | use ignore::gitignore::{Gitignore, GitignoreBuilder}; |
|
| 13 | + | use ignore::Match; |
|
| 14 | + | use std::sync::{Arc, Mutex}; |
|
| 15 | + | ||
| 16 | + | /// What a listing found. |
|
| 17 | + | #[derive(Default)] |
|
| 18 | + | pub(crate) struct Listing { |
|
| 19 | + | /// Regular files: path relative to the root (with `/`), full path, stat. |
|
| 20 | + | pub files: Vec<(String, PathBuf, fs::Metadata)>, |
|
| 21 | + | /// Symbolic links and their targets. |
|
| 22 | + | pub symlinks: Vec<(String, Vec<u8>)>, |
|
| 23 | + | /// Directories holding a repository of their own. |
|
| 24 | + | pub nested: Vec<PathBuf>, |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | /// The ignore files in force for a directory, innermost first. |
|
| 28 | + | #[derive(Clone, Default)] |
|
| 29 | + | struct Rules(Option<Arc<(Gitignore, Rules)>>); |
|
| 30 | + | ||
| 31 | + | impl Rules { |
|
| 32 | + | /// The rules for `dir`, adding its own ignore files if it has any. |
|
| 33 | + | fn enter(&self, dir: &Path, names: &[String]) -> Rules { |
|
| 34 | + | let files: Vec<&str> = [".ignore", ".oxyignore"] |
|
| 35 | + | .into_iter() |
|
| 36 | + | .filter(|f| names.iter().any(|n| n == f)) |
|
| 37 | + | .collect(); |
|
| 38 | + | if files.is_empty() { |
|
| 39 | + | return self.clone(); |
|
| 40 | + | } |
|
| 41 | + | let mut b = GitignoreBuilder::new(dir); |
|
| 42 | + | for f in files { |
|
| 43 | + | // An unreadable or malformed ignore file excludes nothing. |
|
| 44 | + | let _ = b.add(dir.join(f)); |
|
| 45 | + | } |
|
| 46 | + | match b.build() { |
|
| 47 | + | Ok(g) if !g.is_empty() => Rules(Some(Arc::new((g, self.clone())))), |
|
| 48 | + | _ => self.clone(), |
|
| 49 | + | } |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | fn ignored(&self, path: &Path, is_dir: bool) -> bool { |
|
| 53 | + | let mut at = &self.0; |
|
| 54 | + | while let Some(node) = at { |
|
| 55 | + | match node.0.matched(path, is_dir) { |
|
| 56 | + | Match::Ignore(_) => return true, |
|
| 57 | + | Match::Whitelist(_) => return false, |
|
| 58 | + | Match::None => {} |
|
| 59 | + | } |
|
| 60 | + | at = &(node.1).0; |
|
| 61 | + | } |
|
| 62 | + | false |
|
| 63 | + | } |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | struct Ctx<'a> { |
|
| 67 | + | in_scope: &'a (dyn Fn(&str) -> bool + Sync), |
|
| 68 | + | out: Mutex<Listing>, |
|
| 69 | + | failed: Mutex<Option<String>>, |
|
| 70 | + | } |
|
| 71 | + | ||
| 72 | + | /// List the working copy under `root`; files outside `in_scope` (a sparse |
|
| 73 | + | /// checkout's paths) are left out, though every directory is visited. |
|
| 74 | + | pub(crate) fn list(root: &Path, in_scope: &(dyn Fn(&str) -> bool + Sync)) -> Result<Listing> { |
|
| 75 | + | let ctx = Ctx { |
|
| 76 | + | in_scope, |
|
| 77 | + | out: Mutex::new(Listing::default()), |
|
| 78 | + | failed: Mutex::new(None), |
|
| 79 | + | }; |
|
| 80 | + | rayon::scope(|s| { |
|
| 81 | + | visit( |
|
| 82 | + | s, |
|
| 83 | + | root.to_path_buf(), |
|
| 84 | + | String::new(), |
|
| 85 | + | true, |
|
| 86 | + | Rules::default(), |
|
| 87 | + | &ctx, |
|
| 88 | + | ) |
|
| 89 | + | }); |
|
| 90 | + | if let Some(e) = ctx.failed.into_inner().unwrap_or_else(|e| e.into_inner()) { |
|
| 91 | + | return fail(e); |
|
| 92 | + | } |
|
| 93 | + | Ok(ctx.out.into_inner().unwrap_or_else(|e| e.into_inner())) |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | fn visit<'s>( |
|
| 97 | + | s: &rayon::Scope<'s>, |
|
| 98 | + | dir: PathBuf, |
|
| 99 | + | rel: String, |
|
| 100 | + | top: bool, |
|
| 101 | + | rules: Rules, |
|
| 102 | + | ctx: &'s Ctx<'s>, |
|
| 103 | + | ) { |
|
| 104 | + | let read = || -> std::io::Result<Vec<fs::DirEntry>> { fs::read_dir(&dir)?.collect() }; |
|
| 105 | + | let entries = match read() { |
|
| 106 | + | Ok(e) => e, |
|
| 107 | + | Err(e) => { |
|
| 108 | + | ctx.failed |
|
| 109 | + | .lock() |
|
| 110 | + | .unwrap_or_else(|e| e.into_inner()) |
|
| 111 | + | .get_or_insert_with(|| format!("{}: {e}", dir.display())); |
|
| 112 | + | return; |
|
| 113 | + | } |
|
| 114 | + | }; |
|
| 115 | + | let names: Vec<String> = entries |
|
| 116 | + | .iter() |
|
| 117 | + | .map(|e| e.file_name().to_string_lossy().into_owned()) |
|
| 118 | + | .collect(); |
|
| 119 | + | if !top && names.iter().any(|n| n == OXY_DIR || n == ".git") { |
|
| 120 | + | ctx.out |
|
| 121 | + | .lock() |
|
| 122 | + | .unwrap_or_else(|e| e.into_inner()) |
|
| 123 | + | .nested |
|
| 124 | + | .push(dir); |
|
| 125 | + | return; |
|
| 126 | + | } |
|
| 127 | + | let rules = rules.enter(&dir, &names); |
|
| 128 | + | let mut mine = Listing::default(); |
|
| 129 | + | for (e, name) in entries.into_iter().zip(names) { |
|
| 130 | + | if name == OXY_DIR { |
|
| 131 | + | continue; |
|
| 132 | + | } |
|
| 133 | + | let Ok(ft) = e.file_type() else { continue }; |
|
| 134 | + | let path = e.path(); |
|
| 135 | + | if rules.ignored(&path, ft.is_dir()) { |
|
| 136 | + | continue; |
|
| 137 | + | } |
|
| 138 | + | let child = if rel.is_empty() { |
|
| 139 | + | name |
|
| 140 | + | } else { |
|
| 141 | + | format!("{rel}/{name}") |
|
| 142 | + | }; |
|
| 143 | + | if ft.is_dir() { |
|
| 144 | + | let rules = rules.clone(); |
|
| 145 | + | s.spawn(move |s| visit(s, path, child, false, rules, ctx)); |
|
| 146 | + | } else if !(ctx.in_scope)(&child) { |
|
| 147 | + | } else if ft.is_symlink() { |
|
| 148 | + | match fs::read_link(&path) { |
|
| 149 | + | Ok(t) => mine |
|
| 150 | + | .symlinks |
|
| 151 | + | .push((child, t.to_string_lossy().as_bytes().to_vec())), |
|
| 152 | + | Err(e) => { |
|
| 153 | + | ctx.failed |
|
| 154 | + | .lock() |
|
| 155 | + | .unwrap_or_else(|e| e.into_inner()) |
|
| 156 | + | .get_or_insert_with(|| format!("{}: {e}", path.display())); |
|
| 157 | + | } |
|
| 158 | + | } |
|
| 159 | + | } else if ft.is_file() { |
|
| 160 | + | match e.metadata() { |
|
| 161 | + | Ok(m) => mine.files.push((child, path, m)), |
|
| 162 | + | Err(e) => { |
|
| 163 | + | ctx.failed |
|
| 164 | + | .lock() |
|
| 165 | + | .unwrap_or_else(|e| e.into_inner()) |
|
| 166 | + | .get_or_insert_with(|| format!("{}: {e}", path.display())); |
|
| 167 | + | } |
|
| 168 | + | } |
|
| 169 | + | } |
|
| 170 | + | } |
|
| 171 | + | if !mine.files.is_empty() || !mine.symlinks.is_empty() { |
|
| 172 | + | let mut out = ctx.out.lock().unwrap_or_else(|e| e.into_inner()); |
|
| 173 | + | out.files.append(&mut mine.files); |
|
| 174 | + | out.symlinks.append(&mut mine.symlinks); |
|
| 175 | + | } |
|
| 176 | + | } |
|
| 177 | + | ||
| 178 | + | #[cfg(test)] |
|
| 179 | + | mod tests { |
|
| 180 | + | use super::*; |
|
| 181 | + | ||
| 182 | + | fn write(root: &Path, rel: &str, text: &str) { |
|
| 183 | + | let p = root.join(rel); |
|
| 184 | + | fs::create_dir_all(p.parent().unwrap()).unwrap(); |
|
| 185 | + | fs::write(p, text).unwrap(); |
|
| 186 | + | } |
|
| 187 | + | ||
| 188 | + | fn listed(root: &Path, in_scope: &(dyn Fn(&str) -> bool + Sync)) -> (Vec<String>, Vec<String>) { |
|
| 189 | + | let l = list(root, in_scope).unwrap(); |
|
| 190 | + | let mut files: Vec<String> = l.files.into_iter().map(|(r, _, _)| r).collect(); |
|
| 191 | + | files.extend(l.symlinks.into_iter().map(|(r, _)| r)); |
|
| 192 | + | files.sort(); |
|
| 193 | + | let mut nested: Vec<String> = l |
|
| 194 | + | .nested |
|
| 195 | + | .iter() |
|
| 196 | + | .map(|d| { |
|
| 197 | + | d.strip_prefix(root) |
|
| 198 | + | .unwrap() |
|
| 199 | + | .to_string_lossy() |
|
| 200 | + | .replace('\\', "/") |
|
| 201 | + | }) |
|
| 202 | + | .collect(); |
|
| 203 | + | nested.sort(); |
|
| 204 | + | (files, nested) |
|
| 205 | + | } |
|
| 206 | + | ||
| 207 | + | #[test] |
|
| 208 | + | fn ignore_files_nested_repositories_and_scope() { |
|
| 209 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 210 | + | let root = dir.path(); |
|
| 211 | + | write(root, ".oxy/config", ""); |
|
| 212 | + | write(root, ".oxyignore", "*.log\nbuild/\n"); |
|
| 213 | + | write(root, ".hidden", ""); |
|
| 214 | + | write(root, "a.txt", ""); |
|
| 215 | + | write(root, "a.log", ""); |
|
| 216 | + | write(root, "build/out.o", ""); |
|
| 217 | + | write(root, "src/main.c", ""); |
|
| 218 | + | write(root, "src/deep/x.log", ""); |
|
| 219 | + | // A nearer file overrides an outer rule, and `.ignore` counts too. |
|
| 220 | + | write(root, "keep/.oxyignore", "!*.log\n"); |
|
| 221 | + | write(root, "keep/k.log", ""); |
|
| 222 | + | write(root, "other/.ignore", "secret\n"); |
|
| 223 | + | write(root, "other/secret", ""); |
|
| 224 | + | write(root, "other/public", ""); |
|
| 225 | + | // Nested repositories are reported, not entered. |
|
| 226 | + | write(root, "sub/.oxy/config", ""); |
|
| 227 | + | write(root, "sub/inner.txt", ""); |
|
| 228 | + | write(root, "vendor/g/.git/HEAD", ""); |
|
| 229 | + | #[cfg(unix)] |
|
| 230 | + | std::os::unix::fs::symlink("a.txt", root.join("link")).unwrap(); |
|
| 231 | + | ||
| 232 | + | let (files, nested) = listed(root, &|_| true); |
|
| 233 | + | let mut want = vec![ |
|
| 234 | + | ".hidden", |
|
| 235 | + | ".oxyignore", |
|
| 236 | + | "a.txt", |
|
| 237 | + | "keep/.oxyignore", |
|
| 238 | + | "keep/k.log", |
|
| 239 | + | "other/.ignore", |
|
| 240 | + | "other/public", |
|
| 241 | + | "src/main.c", |
|
| 242 | + | ]; |
|
| 243 | + | if cfg!(unix) { |
|
| 244 | + | want.push("link"); |
|
| 245 | + | } |
|
| 246 | + | want.sort(); |
|
| 247 | + | assert_eq!(files, want); |
|
| 248 | + | assert_eq!(nested, vec!["sub", "vendor/g"]); |
|
| 249 | + | ||
| 250 | + | // A sparse scope leaves files out but still visits directories. |
|
| 251 | + | let (files, _) = listed(root, &|p: &str| p.starts_with("src/")); |
|
| 252 | + | assert_eq!(files, vec!["src/main.c"]); |
|
| 253 | + | } |
|
| 254 | + | } |
modified crates/oxy-core/src/repo/mod.rs +26 −106
| … | 79 unchanged lines |
||
| 80 | 80 | mod tags; |
|
| 81 | 81 | #[cfg(test)] |
|
| 82 | 82 | mod tests; |
|
| 83 | + | mod walk; |
|
| 83 | 84 | mod workspace; |
|
| 84 | 85 | ||
| 85 | 86 | pub use bookmarks::{BookmarkInfo, MergeReport, StateGap}; |
|
| … | 85 unchanged lines |
||
| 171 | 172 | /// The manifest as last read, with the file identity it was read from, |
|
| 172 | 173 | /// so one command parses it once (see `read_manifest`). |
|
| 173 | 174 | pub(crate) manifest_cache: std::cell::RefCell<Option<prompt::CachedManifest>>, |
|
| 175 | + | /// A listing of the working copy left by a failed clean check, for the |
|
| 176 | + | /// snapshot that follows it to reuse; taken by the next walk and |
|
| 177 | + | /// dropped whenever the manifest is written. |
|
| 178 | + | pub(crate) listing_cache: std::cell::RefCell<Option<walk::Listing>>, |
|
| 174 | 179 | root: PathBuf, |
|
| 175 | 180 | oxy: PathBuf, |
|
| 176 | 181 | store: Store, |
|
| … | 72 unchanged lines |
||
| 249 | 254 | let repo = Repo { |
|
| 250 | 255 | git_export_skipped: Default::default(), |
|
| 251 | 256 | manifest_cache: Default::default(), |
|
| 257 | + | listing_cache: Default::default(), |
|
| 252 | 258 | root, |
|
| 253 | 259 | oxy, |
|
| 254 | 260 | store, |
|
| … | 44 unchanged lines |
||
| 299 | 305 | let repo = Repo { |
|
| 300 | 306 | git_export_skipped: Default::default(), |
|
| 301 | 307 | manifest_cache: Default::default(), |
|
| 308 | + | listing_cache: Default::default(), |
|
| 302 | 309 | root, |
|
| 303 | 310 | oxy, |
|
| 304 | 311 | store, |
|
| … | 66 unchanged lines |
||
| 371 | 378 | let mut repo = Repo { |
|
| 372 | 379 | git_export_skipped: Default::default(), |
|
| 373 | 380 | manifest_cache: Default::default(), |
|
| 381 | + | listing_cache: Default::default(), |
|
| 374 | 382 | root: d.to_path_buf(), |
|
| 375 | 383 | oxy: shared, |
|
| 376 | 384 | store, |
|
| … | 21 unchanged lines |
||
| 398 | 406 | let mut repo = Repo { |
|
| 399 | 407 | git_export_skipped: Default::default(), |
|
| 400 | 408 | manifest_cache: Default::default(), |
|
| 409 | + | listing_cache: Default::default(), |
|
| 401 | 410 | root: d.to_path_buf(), |
|
| 402 | 411 | oxy, |
|
| 403 | 412 | store, |
|
| … | 692 unchanged lines |
||
| 1096 | 1105 | self.walk_working_copy_with(None, &BTreeMap::new(), None) |
|
| 1097 | 1106 | } |
|
| 1098 | 1107 | ||
| 1099 | - | /// A walker over the working copy that never enters a nested repository |
|
| 1100 | - | /// (a directory below the root holding `.oxy` or `.git`); the directories |
|
| 1101 | - | /// it skipped are collected into `nested`. |
|
| 1102 | - | pub(crate) fn walker( |
|
| 1103 | - | &self, |
|
| 1104 | - | nested: &std::sync::Arc<std::sync::Mutex<Vec<PathBuf>>>, |
|
| 1105 | - | ) -> ignore::WalkBuilder { |
|
| 1106 | - | let mut wb = ignore::WalkBuilder::new(&self.root); |
|
| 1107 | - | wb.hidden(false) |
|
| 1108 | - | .git_ignore(false) |
|
| 1109 | - | .git_global(false) |
|
| 1110 | - | .git_exclude(false) |
|
| 1111 | - | .require_git(false) |
|
| 1112 | - | .follow_links(false) |
|
| 1113 | - | .add_custom_ignore_filename(".oxyignore"); |
|
| 1114 | - | let nested = nested.clone(); |
|
| 1115 | - | wb.filter_entry(move |e| { |
|
| 1116 | - | if e.file_name() == OXY_DIR { |
|
| 1117 | - | return false; |
|
| 1118 | - | } |
|
| 1119 | - | if e.depth() > 0 |
|
| 1120 | - | && e.file_type().is_some_and(|t| t.is_dir()) |
|
| 1121 | - | && (e.path().join(OXY_DIR).exists() || e.path().join(".git").exists()) |
|
| 1122 | - | { |
|
| 1123 | - | nested.lock().unwrap().push(e.path().to_path_buf()); |
|
| 1124 | - | return false; |
|
| 1125 | - | } |
|
| 1126 | - | true |
|
| 1127 | - | }); |
|
| 1128 | - | wb |
|
| 1129 | - | } |
|
| 1130 | - | ||
| 1131 | 1108 | /// Walk the working copy; with a manifest, a file whose size and mtime |
|
| 1132 | 1109 | /// match it is not read: an opaque file yields its known blob, and a |
|
| 1133 | 1110 | /// text file whose recorded graph key equals its base graph's key in |
|
| … | 15 unchanged lines |
||
| 1149 | 1126 | tracked: &BTreeMap<String, (NodeKind, Option<ObjectRef>)>, |
|
| 1150 | 1127 | keys: Option<&HashMap<String, String>>, |
|
| 1151 | 1128 | ) -> Result<BTreeMap<String, FsEntry>> { |
|
| 1152 | - | let nested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); |
|
| 1153 | - | let wb = self.walker(&nested); |
|
| 1154 | 1129 | let mut out = BTreeMap::new(); |
|
| 1155 | - | // The walk itself runs on every core: each entry is found and |
|
| 1156 | - | // statted on its own. |
|
| 1157 | - | enum Found { |
|
| 1158 | - | File(String, PathBuf, fs::Metadata), |
|
| 1159 | - | Symlink(String, Vec<u8>), |
|
| 1160 | - | } |
|
| 1161 | - | let found: std::sync::Mutex<Vec<Found>> = std::sync::Mutex::new(Vec::new()); |
|
| 1162 | - | let failed: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None); |
|
| 1163 | - | let (root, sparse) = (&self.root, &self.config.sparse_paths); |
|
| 1130 | + | let sparse = &self.config.sparse_paths; |
|
| 1164 | 1131 | let in_scope = |p: &str| { |
|
| 1165 | 1132 | sparse.is_empty() |
|
| 1166 | 1133 | || sparse |
|
| 1167 | 1134 | .iter() |
|
| 1168 | 1135 | .any(|s| p == s || p.starts_with(&format!("{s}/"))) |
|
| 1169 | 1136 | }; |
|
| 1170 | - | wb.build_parallel().run(|| { |
|
| 1171 | - | let (found, failed) = (&found, &failed); |
|
| 1172 | - | Box::new(move |entry| { |
|
| 1173 | - | use ignore::WalkState; |
|
| 1174 | - | let step = || -> std::result::Result<Option<Found>, String> { |
|
| 1175 | - | let entry = entry.map_err(|e| e.to_string())?; |
|
| 1176 | - | let path = entry.path(); |
|
| 1177 | - | if path == root { |
|
| 1178 | - | return Ok(None); |
|
| 1179 | - | } |
|
| 1180 | - | let under = path.strip_prefix(root).unwrap(); |
|
| 1181 | - | // Already `/`-separated where that is the separator. |
|
| 1182 | - | let rel: String = match under.to_str() { |
|
| 1183 | - | Some(s) if std::path::MAIN_SEPARATOR == '/' => s.to_string(), |
|
| 1184 | - | _ => under |
|
| 1185 | - | .components() |
|
| 1186 | - | .map(|c| c.as_os_str().to_string_lossy().to_string()) |
|
| 1187 | - | .collect::<Vec<_>>() |
|
| 1188 | - | .join("/"), |
|
| 1189 | - | }; |
|
| 1190 | - | let Some(ft) = entry.file_type() else { |
|
| 1191 | - | return Ok(None); |
|
| 1192 | - | }; |
|
| 1193 | - | if ft.is_dir() || !in_scope(&rel) { |
|
| 1194 | - | return Ok(None); |
|
| 1195 | - | } |
|
| 1196 | - | if ft.is_symlink() { |
|
| 1197 | - | let target = fs::read_link(path).map_err(|e| e.to_string())?; |
|
| 1198 | - | return Ok(Some(Found::Symlink( |
|
| 1199 | - | rel, |
|
| 1200 | - | target.to_string_lossy().as_bytes().to_vec(), |
|
| 1201 | - | ))); |
|
| 1202 | - | } |
|
| 1203 | - | if ft.is_file() { |
|
| 1204 | - | let meta = entry.metadata().map_err(|e| e.to_string())?; |
|
| 1205 | - | return Ok(Some(Found::File(rel, path.to_path_buf(), meta))); |
|
| 1206 | - | } |
|
| 1207 | - | Ok(None) |
|
| 1208 | - | }; |
|
| 1209 | - | match step() { |
|
| 1210 | - | Ok(Some(f)) => found.lock().unwrap().push(f), |
|
| 1211 | - | Ok(None) => {} |
|
| 1212 | - | Err(e) => { |
|
| 1213 | - | failed.lock().unwrap().get_or_insert(e); |
|
| 1214 | - | return WalkState::Quit; |
|
| 1215 | - | } |
|
| 1216 | - | } |
|
| 1217 | - | WalkState::Continue |
|
| 1218 | - | }) |
|
| 1219 | - | }); |
|
| 1220 | - | if let Some(e) = failed.into_inner().unwrap() { |
|
| 1221 | - | return fail(e); |
|
| 1222 | - | } |
|
| 1223 | - | let mut files: Vec<(String, PathBuf, fs::Metadata)> = Vec::new(); |
|
| 1224 | - | for f in found.into_inner().unwrap() { |
|
| 1225 | - | match f { |
|
| 1226 | - | Found::File(rel, path, meta) => files.push((rel, path, meta)), |
|
| 1227 | - | Found::Symlink(rel, target) => { |
|
| 1228 | - | out.insert(rel, FsEntry::new(NodeKind::Symlink, false, target)); |
|
| 1229 | - | } |
|
| 1230 | - | } |
|
| 1137 | + | let walk_phase = crate::timing::phase("walk: of which listing"); |
|
| 1138 | + | let walk::Listing { |
|
| 1139 | + | files, |
|
| 1140 | + | symlinks, |
|
| 1141 | + | nested, |
|
| 1142 | + | } = match self.listing_cache.take() { |
|
| 1143 | + | Some(l) => l, |
|
| 1144 | + | None => walk::list(&self.root, &in_scope)?, |
|
| 1145 | + | }; |
|
| 1146 | + | for (rel, target) in symlinks { |
|
| 1147 | + | out.insert(rel, FsEntry::new(NodeKind::Symlink, false, target)); |
|
| 1231 | 1148 | } |
|
| 1232 | 1149 | let keys = keys.filter(|k| { |
|
| 1233 | 1150 | let on_disk: HashSet<&str> = files.iter().map(|(r, _, _)| r.as_str()).collect(); |
|
| … | 11 unchanged lines |
||
| 1245 | 1162 | .all(|r| on_disk.contains(r.as_str())); |
|
| 1246 | 1163 | attrs_unchanged && none_removed |
|
| 1247 | 1164 | }); |
|
| 1165 | + | drop(walk_phase); |
|
| 1166 | + | let _read_phase = crate::timing::phase("walk: of which stat and compare"); |
|
| 1248 | 1167 | // Reading files is most of a walk's cost; do it in parallel. |
|
| 1249 | 1168 | { |
|
| 1250 | 1169 | use rayon::prelude::*; |
|
| … | 16 unchanged lines |
||
| 1267 | 1186 | out.insert(rel, e); |
|
| 1268 | 1187 | } |
|
| 1269 | 1188 | } |
|
| 1189 | + | drop(_read_phase); |
|
| 1190 | + | let _rest = crate::timing::phase("walk: of which subrepos and hidden"); |
|
| 1270 | 1191 | // Nested repositories (spec §7.8): registered in `.oxymodules` or |
|
| 1271 | 1192 | // already tracked as subrepo nodes, they contribute their pin; |
|
| 1272 | 1193 | // unregistered ones are left alone. |
|
| … | 4 unchanged lines |
||
| 1277 | 1198 | self.clear_removed_subrepo(rel); |
|
| 1278 | 1199 | } |
|
| 1279 | 1200 | } |
|
| 1280 | - | let nested_dirs = std::mem::take(&mut *nested.lock().unwrap()); |
|
| 1281 | - | for dir in nested_dirs { |
|
| 1201 | + | for dir in nested { |
|
| 1282 | 1202 | let rel = self.rel_path(&dir); |
|
| 1283 | 1203 | if !self.in_scope(&rel) || removed.contains(&rel) { |
|
| 1284 | 1204 | continue; |
|
| … | 423 unchanged lines |
modified crates/oxy-core/src/repo/prompt.rs +64 −91
| … | 210 unchanged lines |
||
| 211 | 211 | blobs: &HashMap<String, ObjectRef>, |
|
| 212 | 212 | ) -> Result<()> { |
|
| 213 | 213 | self.manifest_cache.take(); |
|
| 214 | + | self.listing_cache.take(); |
|
| 214 | 215 | let written = std::time::SystemTime::now() |
|
| 215 | 216 | .duration_since(UNIX_EPOCH) |
|
| 216 | 217 | .map(|d| d.as_nanos()) |
|
| … | 62 unchanged lines |
||
| 279 | 280 | keys: &HashMap<String, String>, |
|
| 280 | 281 | ) -> Result<()> { |
|
| 281 | 282 | self.manifest_cache.take(); |
|
| 283 | + | self.listing_cache.take(); |
|
| 282 | 284 | let p = self.manifest_path(); |
|
| 283 | 285 | let text = match fs::read_to_string(&p) { |
|
| 284 | 286 | Ok(t) => t, |
|
| … | 27 unchanged lines |
||
| 312 | 314 | /// markers until the next snapshot. |
|
| 313 | 315 | pub(crate) fn drop_manifest(&self) { |
|
| 314 | 316 | self.manifest_cache.take(); |
|
| 317 | + | self.listing_cache.take(); |
|
| 315 | 318 | let _ = fs::remove_file(self.manifest_path()); |
|
| 316 | 319 | } |
|
| 317 | 320 | ||
| … | 89 unchanged lines |
||
| 407 | 410 | if racy { |
|
| 408 | 411 | return Ok(false); |
|
| 409 | 412 | } |
|
| 410 | - | Ok(self.quick_dirty()? == Some(false)) |
|
| 413 | + | Ok(self.quick_dirty_keeping(true)? == Some(false)) |
|
| 411 | 414 | } |
|
| 412 | 415 | ||
| 413 | 416 | /// Whether the working copy differs from the last manifest. Without a |
|
| 414 | 417 | /// manifest the answer is `None`. |
|
| 415 | 418 | pub fn quick_dirty(&self) -> Result<Option<bool>> { |
|
| 419 | + | self.quick_dirty_keeping(false) |
|
| 420 | + | } |
|
| 421 | + | ||
| 422 | + | /// `quick_dirty`; with `keep`, the listing it made is left for the |
|
| 423 | + | /// next walk of the working copy. |
|
| 424 | + | fn quick_dirty_keeping(&self, keep: bool) -> Result<Option<bool>> { |
|
| 416 | 425 | let Some((_, uncommitted, manifest)) = self.read_manifest()? else { |
|
| 417 | 426 | return Ok(None); |
|
| 418 | 427 | }; |
|
| 419 | 428 | if uncommitted { |
|
| 420 | 429 | return Ok(Some(true)); |
|
| 421 | 430 | } |
|
| 422 | - | use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed}; |
|
| 423 | - | let nested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); |
|
| 424 | - | let wb = self.walker(&nested); |
|
| 425 | - | // Every file is checked on its own, so the walk runs in parallel and |
|
| 426 | - | // stops at the first difference. |
|
| 427 | - | let dirty = AtomicBool::new(false); |
|
| 428 | - | let seen = AtomicUsize::new(0); |
|
| 429 | - | let (root, sparse) = (&self.root, &self.config.sparse_paths); |
|
| 431 | + | use rayon::prelude::*; |
|
| 432 | + | let sparse = &self.config.sparse_paths; |
|
| 430 | 433 | let in_scope = |p: &str| { |
|
| 431 | 434 | sparse.is_empty() |
|
| 432 | 435 | || sparse |
|
| 433 | 436 | .iter() |
|
| 434 | 437 | .any(|s| p == s || p.starts_with(&format!("{s}/"))) |
|
| 438 | + | }; |
|
| 439 | + | // A working copy that cannot be listed counts as changed. |
|
| 440 | + | let Ok(listing) = super::walk::list(&self.root, &in_scope) else { |
|
| 441 | + | return Ok(Some(true)); |
|
| 435 | 442 | }; |
|
| 436 | - | wb.build_parallel().run(|| { |
|
| 437 | - | Box::new(|entry| { |
|
| 438 | - | use ignore::WalkState; |
|
| 439 | - | if dirty.load(Relaxed) { |
|
| 440 | - | return WalkState::Quit; |
|
| 441 | - | } |
|
| 442 | - | let Ok(entry) = entry else { |
|
| 443 | - | return WalkState::Continue; |
|
| 444 | - | }; |
|
| 445 | - | let Some(ft) = entry.file_type() else { |
|
| 446 | - | return WalkState::Continue; |
|
| 443 | + | let differs = || -> Result<bool> { |
|
| 444 | + | let links_differ = listing.symlinks.iter().any(|(rel, target)| { |
|
| 445 | + | manifest |
|
| 446 | + | .entries |
|
| 447 | + | .get(rel) |
|
| 448 | + | .is_none_or(|me| blake3::hash(target).to_hex().as_str() != me.hash) |
|
| 449 | + | }); |
|
| 450 | + | if links_differ { |
|
| 451 | + | return Ok(true); |
|
| 452 | + | } |
|
| 453 | + | // Each file is checked on its own, in parallel, stopping at the |
|
| 454 | + | // first difference; an unreadable one counts as different. |
|
| 455 | + | let files_differ = listing.files.par_iter().any(|(rel, path, m)| { |
|
| 456 | + | manifest.entries.get(rel).is_none_or(|me| { |
|
| 457 | + | m.len() != me.size |
|
| 458 | + | || super::is_executable(m) != me.exec |
|
| 459 | + | || (mtime_ns(m) != me.mtime_ns |
|
| 460 | + | && !fs::read(path) |
|
| 461 | + | .is_ok_and(|b| blake3::hash(&b).to_hex().as_str() == me.hash)) |
|
| 462 | + | }) |
|
| 463 | + | }); |
|
| 464 | + | if files_differ { |
|
| 465 | + | return Ok(true); |
|
| 466 | + | } |
|
| 467 | + | let seen = listing.files.len() + listing.symlinks.len(); |
|
| 468 | + | if seen == manifest.entries.len() { |
|
| 469 | + | return Ok(false); |
|
| 470 | + | } |
|
| 471 | + | // Manifest entries the walk skipped: tracked files that ignore rules |
|
| 472 | + | // hide. They count as present when still on disk and unchanged. |
|
| 473 | + | for (rel, me) in &manifest.entries { |
|
| 474 | + | let path = self.root.join(rel); |
|
| 475 | + | let Ok(m) = fs::symlink_metadata(&path) else { |
|
| 476 | + | return Ok(true); |
|
| 447 | 477 | }; |
|
| 448 | - | if ft.is_dir() { |
|
| 449 | - | return WalkState::Continue; |
|
| 478 | + | if m.file_type().is_symlink() { |
|
| 479 | + | continue; |
|
| 450 | 480 | } |
|
| 451 | - | let under = entry.path().strip_prefix(root).unwrap(); |
|
| 452 | - | let rel: String = match under.to_str() { |
|
| 453 | - | Some(s) if std::path::MAIN_SEPARATOR == '/' => s.to_string(), |
|
| 454 | - | _ => under |
|
| 455 | - | .components() |
|
| 456 | - | .map(|c| c.as_os_str().to_string_lossy().to_string()) |
|
| 457 | - | .collect::<Vec<_>>() |
|
| 458 | - | .join("/"), |
|
| 459 | - | }; |
|
| 460 | - | if !in_scope(&rel) { |
|
| 461 | - | return WalkState::Continue; |
|
| 462 | - | } |
|
| 463 | - | // Whether this file differs from its entry; an unreadable |
|
| 464 | - | // one counts as different. |
|
| 465 | - | let differs = match manifest.entries.get(&rel) { |
|
| 466 | - | None => true, |
|
| 467 | - | Some(me) => { |
|
| 468 | - | seen.fetch_add(1, Relaxed); |
|
| 469 | - | match fs::symlink_metadata(entry.path()) { |
|
| 470 | - | Err(_) => true, |
|
| 471 | - | Ok(_) if ft.is_symlink() => { |
|
| 472 | - | !fs::read_link(entry.path()).is_ok_and(|t| { |
|
| 473 | - | blake3::hash(t.to_string_lossy().as_bytes()) |
|
| 474 | - | .to_hex() |
|
| 475 | - | .as_str() |
|
| 476 | - | == me.hash |
|
| 477 | - | }) |
|
| 478 | - | } |
|
| 479 | - | Ok(m) => { |
|
| 480 | - | m.len() != me.size |
|
| 481 | - | || super::is_executable(&m) != me.exec |
|
| 482 | - | || (mtime_ns(&m) != me.mtime_ns |
|
| 483 | - | && !fs::read(entry.path()).is_ok_and(|b| { |
|
| 484 | - | blake3::hash(&b).to_hex().as_str() == me.hash |
|
| 485 | - | })) |
|
| 486 | - | } |
|
| 487 | - | } |
|
| 488 | - | } |
|
| 489 | - | }; |
|
| 490 | - | if differs { |
|
| 491 | - | dirty.store(true, Relaxed); |
|
| 492 | - | return WalkState::Quit; |
|
| 481 | + | if m.len() != me.size |
|
| 482 | + | || super::is_executable(&m) != me.exec |
|
| 483 | + | || (mtime_ns(&m) != me.mtime_ns |
|
| 484 | + | && blake3::hash(&fs::read(&path)?).to_hex().as_str() != me.hash) |
|
| 485 | + | { |
|
| 486 | + | return Ok(true); |
|
| 493 | 487 | } |
|
| 494 | - | WalkState::Continue |
|
| 495 | - | }) |
|
| 496 | - | }); |
|
| 497 | - | if dirty.load(Relaxed) { |
|
| 498 | - | return Ok(Some(true)); |
|
| 499 | - | } |
|
| 500 | - | let seen = seen.load(Relaxed); |
|
| 501 | - | if seen == manifest.entries.len() { |
|
| 502 | - | return Ok(Some(false)); |
|
| 503 | - | } |
|
| 504 | - | // Manifest entries the walk skipped: tracked files that ignore rules |
|
| 505 | - | // hide. They count as present when still on disk and unchanged. |
|
| 506 | - | for (rel, me) in &manifest.entries { |
|
| 507 | - | let path = self.root.join(rel); |
|
| 508 | - | let Ok(m) = fs::symlink_metadata(&path) else { |
|
| 509 | - | return Ok(Some(true)); |
|
| 510 | - | }; |
|
| 511 | - | if m.file_type().is_symlink() { |
|
| 512 | - | continue; |
|
| 513 | - | } |
|
| 514 | - | if m.len() != me.size |
|
| 515 | - | || super::is_executable(&m) != me.exec |
|
| 516 | - | || (mtime_ns(&m) != me.mtime_ns |
|
| 517 | - | && blake3::hash(&fs::read(&path)?).to_hex().as_str() != me.hash) |
|
| 518 | - | { |
|
| 519 | - | return Ok(Some(true)); |
|
| 520 | 488 | } |
|
| 489 | + | Ok(false) |
|
| 490 | + | }; |
|
| 491 | + | let dirty = differs()?; |
|
| 492 | + | if dirty && keep { |
|
| 493 | + | *self.listing_cache.borrow_mut() = Some(listing); |
|
| 521 | 494 | } |
|
| 522 | - | Ok(Some(false)) |
|
| 495 | + | Ok(Some(dirty)) |
|
| 523 | 496 | } |
|
| 524 | 497 | ||
| 525 | 498 | /// Prompt information without recording anything. |
|
| … | 74 unchanged lines |