OXYGENHUB

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