OXYGENHUB

Coverage / crates/oxy-core/src/repo/prompt.rs 94.5%

820 of 868 lines run, 155 of 220 functions · run #371 on main · run missed

1
//! Fast, side-effect-free status for shell prompts.
2
//!
3
//! Every snapshot and checkout writes a manifest of the working copy as it
4
//! was last seen: `size`, `mtime`, and BLAKE3 of each file. `quick_status`
5
//! walks the working copy and compares against that manifest, reading a
6
//! file's content only when its size or mtime changed, and records no
7
//! operation. Ahead/behind counts come from walking the store between the
8
//! local bookmark and its `origin` tracking state.
9
10
use super::*;
11
use std::time::UNIX_EPOCH;
12
13
#[derive(Debug, Clone, Default, PartialEq, Eq)]
14
pub struct PromptInfo {
15
    pub bookmark: String,
16
    /// Working-copy ChangeId prefix.
17
    pub change_id: String,
18
    pub heads: usize,
19
    pub dirty: bool,
20
    pub conflicted: bool,
21
    /// Changes local `main` has that `origin/main` lacks, when tracking exists.
22
    pub ahead: Option<usize>,
23
    pub behind: Option<usize>,
24
}
25
26
impl PromptInfo {
27
    /// The glyph for the working copy's state: `‼` conflicted, `✎` dirty,
28
    /// `✔` clean.
293×
    pub fn state_glyph(&self) -> &'static str {
303×
        if self.conflicted {
311×
            "‼"
322×
        } else if self.dirty {
331×
            "✎"
34
        } else {
351×
            "✔"
36
        }
373×
    }
38
39
    /// The ANSI colour (SGR parameter) for the state: red, yellow, or cyan.
403×
    pub fn state_color(&self) -> u8 {
413×
        if self.conflicted {
421×
            31
432×
        } else if self.dirty {
441×
            33
45
        } else {
461×
            36
47
        }
483×
    }
49
50
    /// `format` wrapped in the state colour. `guard` is `("\\[", "\\]")` for
51
    /// bash prompts, `("%{", "%}")` for zsh, or empty for raw output.
522×
    pub fn format_colored(&self, fmt: &str, guard: (&str, &str)) -> String {
532×
        format!(
54
            "{}\x1b[{}m{}{}{}\x1b[0m{}",
55
            guard.0,
562×
            self.state_color(),
57
            guard.1,
582×
            self.format(fmt),
59
            guard.0,
60
            guard.1
61
        )
622×
    }
63
64
    /// Expand `%b` bookmark, `%c` change id, `%h` heads, `%d` dirty marker
65
    /// (`*`), `%x` conflict marker (`!`), `%a`/`%B` ahead/behind counts
66
    /// (empty when unknown), `%A`/`%D` `+n`/`-n` when non-zero, `%s` the
67
    /// state glyph, `%U`/`%O` `↑n`/`↓n` when non-zero, `%i` the repository
68
    /// icon, `%%` percent.
6911×
    pub fn format(&self, fmt: &str) -> String {
7011×
        let mut out = String::new();
7111×
        let mut chars = fmt.chars();
7281×
        while let Some(c) = chars.next() {
7370×
            if c != '%' {
7424×
                out.push(c);
7524×
                continue;
7646×
            }
7746×
            match chars.next() {
787×
                Some('b') => out.push_str(&self.bookmark),
795×
                Some('c') => out.push_str(&self.change_id),
802×
                Some('h') => out.push_str(&self.heads.to_string()),
81
                Some('d') => {
824×
                    if self.dirty {
832×
                        out.push('*');
842×
                    }
85
                }
86
                Some('x') => {
874×
                    if self.conflicted {
881×
                        out.push('!');
893×
                    }
90
                }
912×
                Some('a') => out.push_str(&self.ahead.map(|n| n.to_string()).unwrap_or_default()),
922×
                Some('B') => out.push_str(&self.behind.map(|n| n.to_string()).unwrap_or_default()),
93
                Some('A') => {
944×
                    if let Some(n) = self.ahead.filter(|n| *n > 0) {
952×
                        out.push_str(&format!("+{n}"));
962×
                    }
97
                }
98
                Some('D') => {
994×
                    if let Some(n) = self.behind.filter(|n| *n > 0) {
1002×
                        out.push_str(&format!("-{n}"));
1012×
                    }
102
                }
1033×
                Some('s') => out.push_str(self.state_glyph()),
104
                Some('U') => {
1052×
                    if let Some(n) = self.ahead.filter(|n| *n > 0) {
1061×
                        out.push_str(&format!("↑{n}"));
1071×
                    }
108
                }
109
                Some('O') => {
1102×
                    if let Some(n) = self.behind.filter(|n| *n > 0) {
1111×
                        out.push_str(&format!("↓{n}"));
1121×
                    }
113
                }
1142×
                Some('i') => out.push_str(ICON),
1152×
                Some('%') => out.push('%'),
1161×
                Some(other) => {
1171×
                    out.push('%');
1181×
                    out.push(other);
1191×
                }
1200×
                None => out.push('%'),
121
            }
122
        }
12311×
        out
12411×
    }
125
}
126
127
/// Path → (size, mtime in nanoseconds, BLAKE3 hex) as last seen.
128
/// One manifest entry: the file's stat, its content hash, and the stored
129
/// blob for an opaque file.
130
#[derive(Clone, Debug)]
131
pub(crate) struct ManifestEntry {
132
    pub size: u64,
133
    pub mtime_ns: u128,
134
    pub hash: [u8; 32],
135
    pub blob: Option<ObjectRef>,
136
    /// The graph key (`State::graph_key`) of the base file this text file
137
    /// was found equal to, rendered, byte for byte.
138
    pub graph: Option<[u8; 32]>,
139
    /// Whether the file was executable: a mode change alters neither size
140
    /// nor time.
141
    pub exec: bool,
142
}
143
144
#[derive(Clone, Debug, Default)]
145
pub(crate) struct Manifest {
146
    /// When the manifest was written, for the racy-stat guard.
147
    pub written_ns: u128,
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>,
155
}
156
157
/// A parsed manifest and the identity (size, time, inode) of the file it
158
/// came from: reused while the file is the same one.
159
pub(crate) type CachedManifest = ((u64, u128, u64), (bool, bool, std::sync::Arc<Manifest>));
160
161
/// Now, in nanoseconds since the epoch.
1622541×
pub(crate) fn now_ns() -> u128 {
1632541×
    std::time::SystemTime::now()
1642541×
        .duration_since(UNIX_EPOCH)
1652541×
        .map(|d| d.as_nanos())
1662541×
        .unwrap_or(0)
1672541×
}
168
16930075×
pub(crate) fn mtime_ns(m: &fs::Metadata) -> u128 {
17030075×
    m.modified()
17130075×
        .ok()
17230075×
        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
17330075×
        .map(|d| d.as_nanos())
17430075×
        .unwrap_or(0)
17530075×
}
176
177
pub const DEFAULT_PROMPT_FORMAT: &str = "%b %c%d%x%A%D";
178
/// The repository icon for `%i` (an atom, for oxygen).
179
pub const ICON: &str = "⚛";
180
/// A format with glyphs: icon, bookmark, change id, state, ahead/behind.
181
pub const GLYPH_PROMPT_FORMAT: &str = "%i %b %c %s%U%O";
182
183
/// The manifest file (oxygen#41): a flat binary layout read in one pass.
184
/// A magic number; the conflicted and uncommitted flags; when it was
185
/// written, at a fixed offset so an otherwise equal manifest can be
186
/// recognised; BLAKE3 of everything else; then each entry in path order:
187
/// path, size, mtime in nanoseconds, BLAKE3 of the content, the stored
188
/// blob of an opaque file, the graph key it was found equal to, and
189
/// whether it is executable. Format 3 puts before the entries every
190
/// directory the walk listed with its time, and the nested repositories. A file that does not check, or in any other
191
/// layout (an older manifest), reads as no manifest, and the next snapshot
192
/// writes it again: it is a cache, written without waiting for the disk.
193
const MANIFEST_MAGIC: &[u8; 8] = b"oxyman3\0";
194
/// Where the written time sits: after the magic and the two flags.
195
const MANIFEST_WRITTEN: std::ops::Range<usize> = 10..26;
196
/// Where the checksum sits, after the written time.
197
const MANIFEST_SUM: std::ops::Range<usize> = 26..58;
198
1994530×
fn manifest_sum(buf: &[u8]) -> [u8; 32] {
2004530×
    let mut h = blake3::Hasher::new();
2014530×
    h.update(&buf[..MANIFEST_SUM.start]);
2024530×
    h.update_rayon(&buf[MANIFEST_SUM.end..]);
2034530×
    *h.finalize().as_bytes()
2044530×
}
205
2062381×
fn encode_manifest(
2072381×
    conflicted: bool,
2082381×
    uncommitted: bool,
2092381×
    written_ns: u128,
2102381×
    dirs: &[(String, u128)],
2112381×
    nested: &[String],
2122381×
    entries: &mut dyn Iterator<Item = (&str, ManifestEntry)>,
2132381×
) -> Vec<u8> {
2142381×
    let mut w = crate::flat::Writer::default();
2152381×
    w.fixed(MANIFEST_MAGIC);
2162381×
    w.u8(conflicted as u8);
2172381×
    w.u8(uncommitted as u8);
2182381×
    w.fixed(&written_ns.to_le_bytes());
2192381×
    w.fixed(&[0; 32]);
2202381×
    w.count(dirs.len());
2213345×
    for (d, t) in dirs {
2223345×
        w.bytes(d.as_bytes());
2233345×
        w.fixed(&t.to_le_bytes());
2243345×
    }
2252381×
    w.count(nested.len());
2262381×
    for n in nested {
22751×
        w.bytes(n.as_bytes());
22851×
    }
22931026×
    for (path, e) in entries {
23031026×
        w.bytes(path.as_bytes());
23131026×
        w.u64(e.size);
23231026×
        w.fixed(&e.mtime_ns.to_le_bytes());
23331026×
        w.fixed(&e.hash);
23431026×
        w.opt_object(&e.blob);
23531026×
        match e.graph {
2361434×
            None => w.u8(0),
23729592×
            Some(k) => {
23829592×
                w.u8(1);
23929592×
                w.fixed(&k);
24029592×
            }
241
        }
24231026×
        w.u8(e.exec as u8);
243
    }
2442381×
    let sum = manifest_sum(&w.0);
2452381×
    w.0[MANIFEST_SUM].copy_from_slice(&sum);
2462381×
    w.0
2472381×
}
248
2492149×
fn decode_manifest(buf: &[u8]) -> Option<(bool, bool, Manifest)> {
2502149×
    let mut r = crate::flat::Reader::new(buf);
2512149×
    if &r.fixed::<8>().ok()? != MANIFEST_MAGIC {
2520×
        return None;
2532149×
    }
2542149×
    let conflicted = r.u8().ok()? == 1;
2552149×
    let uncommitted = r.u8().ok()? == 1;
2562149×
    let written_ns = u128::from_le_bytes(r.fixed().ok()?);
2572149×
    if r.fixed::<32>().ok()? != manifest_sum(buf) {
2583×
        return None;
2592146×
    }
2602146×
    let mut dirs = Vec::new();
2612146×
    for _ in 0..r.count().ok()? {
2623058×
        let d = std::str::from_utf8(r.bytes().ok()?).ok()?.to_string();
2633058×
        dirs.push((d, u128::from_le_bytes(r.fixed().ok()?)));
264
    }
2652146×
    let mut nested = Vec::new();
2662146×
    for _ in 0..r.count().ok()? {
26743×
        nested.push(std::str::from_utf8(r.bytes().ok()?).ok()?.to_string());
268
    }
2692146×
    let mut entries = Vec::new();
27028078×
    while !r.at_end() {
27125932×
        let path = std::str::from_utf8(r.bytes().ok()?).ok()?.to_string();
27225932×
        let e = ManifestEntry {
27325932×
            size: r.u64().ok()?,
27425932×
            mtime_ns: u128::from_le_bytes(r.fixed().ok()?),
27525932×
            hash: r.fixed().ok()?,
27625932×
            blob: r.opt_object().ok()?,
27725932×
            graph: match r.u8().ok()? {
2781452×
                0 => None,
27924480×
                _ => Some(r.fixed().ok()?),
280
            },
28125932×
            exec: r.u8().ok()? == 1,
282
        };
28325932×
        entries.push((path, e));
284
    }
285
    // Written in path order, so this builds the map without searching.
2862146×
    let entries = entries.into_iter().collect();
2872146×
    Some((
2882146×
        conflicted,
2892146×
        uncommitted,
2902146×
        Manifest {
2912146×
            written_ns,
2922146×
            entries,
2932146×
            dirs,
2942146×
            nested,
2952146×
        },
2962146×
    ))
2972149×
}
298
299
/// A tracked path as it is now: a file's stat, or a symlink's target.
300
enum Tracked {
301
    File(super::walk::Stat),
302
    Link(Vec<u8>),
303
}
304
305
/// The stat of every path in `rels` (relative to `root`; `None` for one
306
/// gone or neither a file nor a symlink), in the same order. Paths are
307
/// grouped by directory and each directory is opened once: a stat relative
308
/// to it resolves one name, where a stat by full path walks every
309
/// component from the root again (most of a clean status's time, in the
310
/// kernel's path lookup, on the Linux tree).
3117×
fn stat_tracked(root: &Path, rels: &[&String]) -> Vec<Option<Tracked>> {
312
    use rayon::prelude::*;
3137×
    let mut by_dir: HashMap<&str, Vec<(usize, &str)>> = HashMap::new();
3144210×
    for (i, rel) in rels.iter().enumerate() {
3154210×
        let (dir, name) = rel.rsplit_once('/').unwrap_or(("", rel.as_str()));
3164210×
        by_dir.entry(dir).or_default().push((i, name));
3174210×
    }
3187×
    let groups: Vec<(&str, Vec<(usize, &str)>)> = by_dir.into_iter().collect();
3197×
    let found: Vec<(usize, Option<Tracked>)> = groups
3207×
        .par_iter()
32111×
        .flat_map_iter(|(dir, names)| {
32211×
            let handle = fs::File::open(root.join(dir)).ok();
32311×
            names
32411×
                .iter()
3254210×
                .map(|(i, name)| {
3264210×
                    let rel = rels[*i];
3274210×
                    let now = match &handle {
3284210×
                        Some(d) => stat_in(d, name, || root.join(rel)),
3290×
                        None => None,
330
                    };
3314210×
                    (*i, now)
3324210×
                })
33311×
                .collect::<Vec<_>>()
33411×
        })
3357×
        .collect();
3367×
    let mut out: Vec<Option<Tracked>> = (0..rels.len()).map(|_| None).collect();
3374210×
    for (i, now) in found {
3384210×
        out[i] = now;
3394210×
    }
3407×
    out
3417×
}
342
343
/// One entry of the open directory `dir`; `path` builds its full path,
344
/// needed only for a symlink's target.
345
#[cfg(unix)]
3464210×
fn stat_in(dir: &fs::File, name: &str, path: impl Fn() -> PathBuf) -> Option<Tracked> {
347
    use std::os::fd::AsRawFd;
3484210×
    let c = std::ffi::CString::new(name).ok()?;
3494210×
    let mut st: libc::stat = unsafe { std::mem::zeroed() };
350
    // SAFETY: a valid directory descriptor, a NUL-terminated name, and a
351
    // stat buffer of the right type that outlives the call.
3524210×
    let r = unsafe {
3534210×
        libc::fstatat(
3544210×
            dir.as_raw_fd(),
3554210×
            c.as_ptr(),
3564210×
            &mut st,
357
            libc::AT_SYMLINK_NOFOLLOW,
358
        )
359
    };
3604210×
    if r != 0 {
3610×
        return None;
3624210×
    }
3634210×
    match st.st_mode & libc::S_IFMT {
364
        libc::S_IFREG => Some(Tracked::File(super::walk::Stat {
3654210×
            size: st.st_size as u64,
366
            // As `mtime_ns` reads it: nanoseconds since the epoch, 0 before it.
3674210×
            mtime_ns: if st.st_mtime < 0 {
3680×
                0
369
            } else {
3704210×
                st.st_mtime as u128 * 1_000_000_000 + st.st_mtime_nsec as u128
371
            },
3724210×
            exec: st.st_mode & 0o111 != 0,
373
        })),
374
        libc::S_IFLNK => {
3750×
            let t = fs::read_link(path()).ok()?;
3760×
            Some(Tracked::Link(t.to_string_lossy().as_bytes().to_vec()))
377
        }
3780×
        _ => None,
379
    }
3804210×
}
381
382
#[cfg(not(unix))]
383
fn stat_in(_dir: &fs::File, _name: &str, path: impl Fn() -> PathBuf) -> Option<Tracked> {
384
    let p = path();
385
    let m = fs::symlink_metadata(&p).ok()?;
386
    if m.file_type().is_symlink() {
387
        let t = fs::read_link(&p).ok()?;
388
        Some(Tracked::Link(t.to_string_lossy().as_bytes().to_vec()))
389
    } else if m.is_file() {
390
        Some(Tracked::File(super::walk::Stat::of(&m)))
391
    } else {
392
        None
393
    }
394
}
395
396
/// The status cache: a magic number, the working-copy change, the
397
/// manifest checksum, then each status line (a status byte and a path).
398
const STATUS_MAGIC: &[u8; 8] = b"oxystat1";
399
400844×
fn status_byte(s: ChangeStatus) -> u8 {
401844×
    match s {
402314×
        ChangeStatus::Added => 0,
403413×
        ChangeStatus::Modified => 1,
40493×
        ChangeStatus::Removed => 2,
40517×
        ChangeStatus::Renamed => 3,
4067×
        ChangeStatus::ModeChanged => 4,
4070×
        ChangeStatus::ConflictEdited => 5,
4080×
        ChangeStatus::Resolved => 6,
409
    }
410844×
}
411
412382×
fn encode_status_cache(wc: &ChangeHash, stamp: &[u8; 32], statuses: &[PathStatus]) -> Vec<u8> {
413382×
    let mut out = Vec::with_capacity(80 + statuses.len() * 40);
414382×
    out.extend_from_slice(STATUS_MAGIC);
415382×
    out.extend_from_slice(&wc.0);
416382×
    out.extend_from_slice(stamp);
417382×
    out.extend_from_slice(&(statuses.len() as u32).to_le_bytes());
418640×
    for s in statuses {
419640×
        out.push(status_byte(s.status));
420640×
        out.extend_from_slice(&(s.path.len() as u32).to_le_bytes());
421640×
        out.extend_from_slice(s.path.as_bytes());
422640×
    }
423382×
    out
424382×
}
425
426237×
fn decode_status_cache(b: &[u8]) -> Option<(ChangeHash, [u8; 32], Vec<PathStatus>)> {
427237×
    let rest = b.strip_prefix(STATUS_MAGIC)?;
428237×
    let wc = ObjectRef(rest.get(..32)?.try_into().ok()?);
429237×
    let stamp: [u8; 32] = rest.get(32..64)?.try_into().ok()?;
430237×
    let n = u32::from_le_bytes(rest.get(64..68)?.try_into().ok()?) as usize;
431237×
    let mut at = 68;
432237×
    let mut out = Vec::with_capacity(n.min(1 << 16));
433237×
    for _ in 0..n {
434412×
        let status = match *rest.get(at)? {
435117×
            0 => ChangeStatus::Added,
436251×
            1 => ChangeStatus::Modified,
43736×
            2 => ChangeStatus::Removed,
4385×
            3 => ChangeStatus::Renamed,
4393×
            4 => ChangeStatus::ModeChanged,
4400×
            5 => ChangeStatus::ConflictEdited,
4410×
            6 => ChangeStatus::Resolved,
4420×
            _ => return None,
443
        };
444412×
        let len = u32::from_le_bytes(rest.get(at + 1..at + 5)?.try_into().ok()?) as usize;
445412×
        let path = std::str::from_utf8(rest.get(at + 5..at + 5 + len)?)
446412×
            .ok()?
447412×
            .to_string();
448412×
        out.push(PathStatus { path, status });
449412×
        at += 5 + len;
450
    }
451237×
    (at == rest.len()).then_some((wc, stamp, out))
452237×
}
453
454
impl Repo {
45510144×
    pub(crate) fn manifest_path(&self) -> PathBuf {
45610144×
        self.oxy
45710144×
            .join("workspaces")
45810144×
            .join(self.workspace.to_hex())
45910144×
            .join("manifest")
46010144×
    }
461
462
    /// Record the working copy as it is now (after a snapshot, commit, or
463
    /// checkout), with header lines saying whether the base state is
464
    /// conflicted and whether the working-copy change holds uncommitted work.
4651×
    pub(crate) fn write_manifest(&self, conflicted: bool, uncommitted: bool) -> Result<()> {
4661×
        let entries = self.walk_working_copy()?;
4671×
        self.write_manifest_entries(conflicted, uncommitted, &entries, &HashMap::new())
4681×
    }
469
470
    /// Write the manifest from entries already walked; `blobs` names the
471
    /// stored blob of each opaque path so the next walk can skip reading it.
4721419×
    pub(crate) fn write_manifest_entries(
4731419×
        &self,
4741419×
        conflicted: bool,
4751419×
        uncommitted: bool,
4761419×
        entries: &BTreeMap<String, FsEntry>,
4771419×
        blobs: &HashMap<String, ObjectRef>,
4781419×
    ) -> Result<()> {
4791419×
        let dirs = self.walked_dirs.take().unwrap_or_default();
4801419×
        self.write_manifest_entries_as_of(conflicted, uncommitted, entries, blobs, now_ns(), dirs)
4811419×
    }
482
483
    /// `write_manifest_entries` stamped `written` (when the entries were
484
    /// read), with the directories of the walk they came from.
4851916×
    pub(crate) fn write_manifest_entries_as_of(
4861916×
        &self,
4871916×
        conflicted: bool,
4881916×
        uncommitted: bool,
4891916×
        entries: &BTreeMap<String, FsEntry>,
4901916×
        blobs: &HashMap<String, ObjectRef>,
4911916×
        written: u128,
4921916×
        (dirs, nested): WalkedDirs,
4931916×
    ) -> Result<()> {
4941916×
        self.manifest_cache.take();
4951916×
        self.listing_cache.take();
4961916×
        let mut newest_ns: u128 = 0;
49722609×
        let mut rows = entries.iter().map(|(path, e)| {
49822609×
            let (size, mtime_ns) = match e.stat {
49916641×
                Some(known) => known,
5005968×
                None => match fs::symlink_metadata(self.root.join(path)) {
5015966×
                    Ok(m) => (m.len(), mtime_ns(&m)),
5022×
                    Err(_) => (e.content.len() as u64, 0),
503
                },
504
            };
50522609×
            newest_ns = newest_ns.max(mtime_ns);
50622609×
            let entry = ManifestEntry {
50722609×
                size,
50822609×
                mtime_ns,
50922609×
                hash: e
51022609×
                    .content_hash
51122609×
                    .unwrap_or_else(|| *blake3::hash(&e.content).as_bytes()),
51222609×
                blob: blobs.get(path).copied().or(e.blob),
51322609×
                graph: e.graph_key,
51422609×
                exec: e.exec,
515
            };
51622609×
            (path.as_str(), entry)
51722609×
        });
5181916×
        let encoding = crate::timing::phase("manifest: rows and encoding");
5191916×
        let new = encode_manifest(conflicted, uncommitted, written, &dirs, &nested, &mut rows);
5201916×
        drop(rows);
5211916×
        drop(encoding);
5221916×
        if crate::timing::enabled() {
5235×
            let unstated = entries.values().filter(|e| e.stat.is_none()).count();
5242×
            let unhashed = entries
5252×
                .values()
5265×
                .filter(|e| e.content_hash.is_none())
5272×
                .count();
5282×
            eprintln!(
529
                "timing manifest: {} rows, {unstated} without a stat, {unhashed} without a hash",
5302×
                entries.len()
531
            );
5321914×
        }
5331916×
        let _t = crate::timing::phase("manifest: compare and write");
5341916×
        let p = self.manifest_path();
535
        // Leave an unchanged manifest alone when its stamp already trusts
536
        // every entry (none is within the racy window): rewriting it would
537
        // change nothing but the file, which editors watching the tree
538
        // (and subrepos inside it) take as a change.
5391916×
        if let Ok(old) = fs::read(&p) {
5401426×
            let (w, sum) = (MANIFEST_WRITTEN, MANIFEST_SUM);
5411426×
            if old.len() == new.len()
542451×
                && old[..w.start] == new[..w.start]
543398×
                && old[sum.end..] == new[sum.end..]
544
            {
545282×
                let old_written = u128::from_le_bytes(old[w.clone()].try_into().unwrap());
546282×
                if newest_ns + 2_000_000_000 < old_written {
54793×
                    return Ok(());
548189×
                }
5491144×
            }
550490×
        }
5511823×
        fs::create_dir_all(p.parent().unwrap())?;
5521823×
        write_cache(&p, &new)
5531916×
    }
554
555
    /// Change the header flags, and set the graph key of each path in
556
    /// `keys` (after a commit, the new base's keys of the files it holds as
557
    /// they are on disk).
558215×
    pub(crate) fn rewrite_manifest(
559215×
        &self,
560215×
        conflicted: bool,
561215×
        uncommitted: bool,
562215×
        keys: &HashMap<String, [u8; 32]>,
563215×
    ) -> Result<()> {
564215×
        self.manifest_cache.take();
565215×
        self.listing_cache.take();
566215×
        let p = self.manifest_path();
567215×
        let Some((_, _, m)) = fs::read(&p).ok().and_then(|b| decode_manifest(&b)) else {
5680×
            return self.write_manifest(conflicted, uncommitted);
569
        };
570646×
        let mut rows = m.entries.iter().map(|(path, e)| {
571646×
            let mut e = e.clone();
572646×
            if let Some(k) = keys.get(path) {
573105×
                e.graph = Some(*k);
574541×
            }
575646×
            (path.as_str(), e)
576646×
        });
577215×
        write_cache(
578215×
            &p,
579215×
            &encode_manifest(
580215×
                conflicted,
581215×
                uncommitted,
582215×
                m.written_ns,
583215×
                &m.dirs,
584215×
                &m.nested,
585215×
                &mut rows,
586215×
            ),
587
        )
588215×
    }
589
590
    /// Forget the manifest after an operation that changed the working-copy
591
    /// change without a snapshot (undo, rewrites); the prompt shows blank
592
    /// markers until the next snapshot.
59357×
    pub(crate) fn drop_manifest(&self) {
59457×
        self.manifest_cache.take();
59557×
        self.listing_cache.take();
59657×
        let _ = fs::remove_file(self.manifest_path());
59757×
    }
598
599
    /// The manifest, with whether it records a conflict and uncommitted
600
    /// work; parsed once while the file stays the same one.
601
    /// Whether the working copy was conflicted when last recorded (the
602
    /// manifest's flag), without looking at the files.
6038×
    pub fn recorded_conflicted(&self) -> Result<bool> {
6048×
        Ok(self.read_manifest()?.is_some_and(|(c, _, _)| c))
6058×
    }
606
6075384×
    pub(crate) fn read_manifest(&self) -> Result<Option<(bool, bool, std::sync::Arc<Manifest>)>> {
6085384×
        let path = self.manifest_path();
6095384×
        let id = match fs::metadata(&path) {
6103960×
            Ok(m) => {
611
                #[cfg(unix)]
6123960×
                let ino = std::os::unix::fs::MetadataExt::ino(&m);
613
                #[cfg(not(unix))]
614
                let ino = 0;
6153960×
                (m.len(), mtime_ns(&m), ino)
616
            }
6171424×
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6180×
            Err(e) => return Err(e.into()),
619
        };
6203960×
        if let Some((cached_id, m)) = &*self.manifest_cache.borrow() {
6212026×
            if *cached_id == id {
6222026×
                return Ok(Some(m.clone()));
6230×
            }
6241934×
        }
6251934×
        let Some((c, u, m)) = self.parse_manifest(&path)? else {
6263×
            return Ok(None);
627
        };
6281931×
        let m = (c, u, std::sync::Arc::new(m));
6291931×
        *self.manifest_cache.borrow_mut() = Some((id, m.clone()));
6301931×
        Ok(Some(m))
6315384×
    }
632
6331934×
    fn parse_manifest(&self, path: &std::path::Path) -> Result<Option<(bool, bool, Manifest)>> {
6341934×
        let buf = match fs::read(path) {
6351934×
            Ok(b) => b,
6360×
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6370×
            Err(e) => return Err(e.into()),
638
        };
6391934×
        Ok(decode_manifest(&buf))
6401934×
    }
641
642
    /// Whether the working copy is certainly clean, known from the manifest
643
    /// alone, without loading any history (spec §12.4): the last snapshot
644
    /// or commit found no uncommitted work and no conflict, there are no
645
    /// subrepos (whose state the manifest does not hold), and no file was
646
    /// added, removed or changed since. An entry inside the racy window,
647
    /// where an equal size and time do not prove equal content, is checked
648
    /// by hashing the file. `false` means only that a snapshot must decide.
649950×
    pub fn clean_by_manifest(&self) -> Result<bool> {
650950×
        let _t = crate::timing::phase("status: check the manifest");
651
        // Why a snapshot must decide, with timings on.
652950×
        let not = |why: &str| {
653699×
            if crate::timing::enabled() {
6541×
                eprintln!("timing status: not clean by the manifest: {why}");
655698×
            }
656699×
            Ok(false)
657699×
        };
658950×
        let Some((conflicted, uncommitted, m)) = self.read_manifest()? else {
659308×
            return not("no manifest");
660
        };
661642×
        if conflicted || uncommitted || self.root.join(".oxymodules").exists() {
66228×
            return not("conflicted, uncommitted or subrepos");
663614×
        }
664614×
        let started = now_ns();
665614×
        if self.disk_differs(&m, true)? {
666363×
            return not("a file differs");
667251×
        }
668
        // Verified clean. Entries or directories within the racy window of
669
        // the manifest's time were hashed or listed just now; restamped with
670
        // the time this check began, the next check can trust them, as
671
        // Git restamps its index (anything changed within two seconds of
672
        // that time is still racy by the same rule).
673
        // A check that had to list the tree leaves the directories it
674
        // listed: kept, the next check need not list.
675251×
        let window = 2_000_000_000;
676251×
        let listed = self.listed_dirs.take();
677254×
        let racy = m.dirs.iter().any(|(_, t)| t + window >= m.written_ns)
6782×
            || m.entries
6792×
                .values()
68010×
                .any(|e| e.mtime_ns + window >= m.written_ns);
681251×
        if (racy || (m.dirs.is_empty() && listed.is_some())) && started > m.written_ns {
682250×
            let (dirs, nested) = listed.unwrap_or_else(|| (m.dirs.clone(), m.nested.clone()));
683250×
            self.restamp_manifest(&m, conflicted, uncommitted, started, &dirs, &nested);
6841×
        }
685251×
        Ok(true)
686950×
    }
687
688
    /// Rewrite the manifest with a new time and directory list, its entries
689
    /// unchanged.
690250×
    fn restamp_manifest(
691250×
        &self,
692250×
        m: &Manifest,
693250×
        conflicted: bool,
694250×
        uncommitted: bool,
695250×
        written_ns: u128,
696250×
        dirs: &[(String, u128)],
697250×
        nested: &[String],
698250×
    ) {
699250×
        let _t = crate::timing::phase("status: restamp the manifest");
7007771×
        let mut rows = m.entries.iter().map(|(p, e)| (p.as_str(), e.clone()));
701250×
        let bytes = encode_manifest(conflicted, uncommitted, written_ns, dirs, nested, &mut rows);
702250×
        self.manifest_cache.take();
703250×
        let _ = write_cache(&self.manifest_path(), &bytes);
704250×
    }
705
706
    /// A manifest entry written within the racy window whose file no
707
    /// longer matches it. Such entries are hashed, in parallel (a checkout
708
    /// just wrote them all).
709866×
    fn racy_change(&self, m: &Manifest) -> Option<String> {
710
        use rayon::prelude::*;
711866×
        let racy: Vec<(&String, &ManifestEntry)> = m
712866×
            .entries
713866×
            .iter()
71411328×
            .filter(|(_, e)| e.mtime_ns + 2_000_000_000 >= m.written_ns)
715866×
            .collect();
716866×
        let root = &self.root;
717866×
        racy.par_iter()
7184873×
            .find_any(|(rel, e)| {
7194873×
                let path = root.join(rel.as_str());
7204873×
                let same = match fs::symlink_metadata(&path) {
7214867×
                    Ok(md) if md.file_type().is_symlink() => fs::read_link(&path).is_ok_and(|t| {
7220×
                        *blake3::hash(t.to_string_lossy().as_bytes()).as_bytes() == e.hash
7230×
                    }),
7244867×
                    Ok(md) if md.is_file() => {
7254865×
                        md.len() == e.size
7264739×
                            && fs::read(&path).is_ok_and(|b| *blake3::hash(&b).as_bytes() == e.hash)
727
                    }
728
                    // A subrepo: its pin is compared on its own (`subrepo_moved`),
729
                    // its directory's time says nothing (oxygen#75).
7302×
                    Ok(md) if md.is_dir() => {
7312×
                        path.join(OXY_DIR).exists() || path.join(".git").exists()
732
                    }
7336×
                    _ => false,
734
                };
7354873×
                !same
7364873×
            })
737866×
            .map(|(rel, _)| rel.to_string())
738866×
    }
739
740
    /// The status lines of the last snapshot, when the working copy is as
741
    /// that snapshot left it: the cache it wrote names the working-copy
742
    /// change and the manifest it recorded, and every file still matches
743
    /// that manifest. `None` when a snapshot must decide (no cache, a file
744
    /// changed, conflicts or subrepos). An empty list is a clean working
745
    /// copy.
746757×
    pub fn status_by_manifest(&self) -> Result<Option<Vec<PathStatus>>> {
747
        // A listing left by an earlier check describes the files as they
748
        // were then; this check makes its own.
749757×
        self.listing_cache.take();
750757×
        let Some((conflicted, uncommitted, m)) = self.read_manifest()? else {
751134×
            return Ok(None);
752
        };
753623×
        if !uncommitted {
754378×
            return Ok(self.clean_by_manifest()?.then(Vec::new));
755245×
        }
756245×
        let _t = crate::timing::phase("status: by the status cache");
757245×
        if conflicted || self.root.join(".oxymodules").exists() {
7588×
            return Ok(None);
759237×
        }
760237×
        let Some((wc, stamp, statuses)) = fs::read(self.status_cache_path())
761237×
            .ok()
762237×
            .and_then(|b| decode_status_cache(&b))
763
        else {
7640×
            return Ok(None);
765
        };
766237×
        let now = self.view()?.workspaces.get(&self.workspace).copied();
767237×
        if now != Some(wc) || self.manifest_stamp() != Some(stamp) {
7680×
            return Ok(None);
769237×
        }
770237×
        if self.disk_differs(&m, true)? {
7718×
            return Ok(None);
772229×
        }
773
        // Test builds check the cache against a snapshot of the files.
774
        #[cfg(test)]
775
        {
77657×
            let snap = self.snapshot_status("status cache check")?;
777114×
            let key = |v: &[PathStatus]| {
778114×
                let mut k: Vec<(String, u8)> = v
779114×
                    .iter()
780204×
                    .map(|p| (p.path.clone(), status_byte(p.status)))
781114×
                    .collect();
782114×
                k.sort();
783114×
                k
784114×
            };
78557×
            assert_eq!(
78657×
                key(&statuses),
78757×
                key(&snap.statuses),
788
                "status cache disagrees with a snapshot"
789
            );
790
        }
791229×
        Ok(Some(statuses))
792757×
    }
793
7941160×
    fn status_cache_path(&self) -> PathBuf {
7951160×
        self.manifest_path().with_file_name("status")
7961160×
    }
797
798
    /// The manifest file's checksum, which names its content.
7991160×
    fn manifest_stamp(&self) -> Option<[u8; 32]> {
800
        use std::io::Read;
8011160×
        let mut head = [0u8; MANIFEST_SUM.end];
8021160×
        fs::File::open(self.manifest_path())
8031160×
            .and_then(|mut f| f.read_exact(&mut head))
8041160×
            .ok()?;
8051160×
        head[MANIFEST_SUM].try_into().ok()
8061160×
    }
807
808
    /// Record `statuses` as the status of working-copy change `wc` against
809
    /// the manifest as it is now; with no uncommitted work there is nothing
810
    /// to record, and the cache goes.
811923×
    pub(crate) fn write_status_cache(&self, wc: Option<&ChangeHash>, statuses: &[PathStatus]) {
812923×
        let path = self.status_cache_path();
813923×
        let (Some(wc), Some(stamp)) = (wc, self.manifest_stamp()) else {
814541×
            let _ = fs::remove_file(&path);
815541×
            return;
816
        };
817382×
        let _ = write_cache(&path, &encode_status_cache(wc, &stamp, statuses));
818923×
    }
819
820
    /// Whether the working copy differs from the last manifest. Without a
821
    /// manifest the answer is `None`.
82216×
    pub fn quick_dirty(&self) -> Result<Option<bool>> {
82316×
        self.quick_dirty_keeping(false)
82416×
    }
825
826
    /// `quick_dirty`; with `keep`, the listing it made is left for the
827
    /// next walk of the working copy.
82816×
    fn quick_dirty_keeping(&self, keep: bool) -> Result<Option<bool>> {
82916×
        let Some((_, uncommitted, manifest)) = self.read_manifest()? else {
8300×
            return Ok(None);
831
        };
83216×
        if uncommitted {
8331×
            return Ok(Some(true));
83415×
        }
83515×
        Ok(Some(self.disk_differs(&manifest, keep)?))
83616×
    }
837
838
    /// Whether the working copy differs from `manifest`: a file added,
839
    /// gone or changed. With `keep`, the listing made is left for the next
840
    /// walk of the working copy when it does.
841866×
    fn disk_differs(&self, manifest: &Manifest, keep: bool) -> Result<bool> {
842
        // A file written within the racy window can change without its size
843
        // or time moving (a coarse clock): those entries are hashed. One
844
        // that changed still lets the directory check below hand its stats
845
        // to the snapshot that follows.
846866×
        let racy = self.racy_change(manifest).is_some();
847866×
        if let Some(differs) = self.disk_differs_by_dirs_keeping(manifest, keep, racy) {
8484×
            return Ok(differs || racy);
849862×
        }
850862×
        if racy {
851170×
            return Ok(true);
852692×
        }
853692×
        self.disk_differs_listing(manifest, keep)
854866×
    }
855
856
    /// The manifest's entries that are subrepos (nested repositories it
857
    /// tracks): a directory, compared by its pin, not its stat.
858
    /// Known from the walk's nested repositories, and from `.oxymodules`:
859
    /// a commit's manifest does not list the nested repositories.
8609×
    fn subrepo_entries<'m>(&self, manifest: &'m Manifest) -> Vec<(&'m String, &'m ManifestEntry)> {
8619×
        let modules = self.modules_on_disk();
8629×
        let mut out: Vec<(&'m String, &'m ManifestEntry)> = manifest
8639×
            .nested
8649×
            .iter()
8659×
            .map(String::as_str)
8669×
            .chain(modules.entries.keys().map(String::as_str))
8679×
            .filter_map(|n| manifest.entries.get_key_value(n))
8689×
            .collect();
8699×
        out.sort_by(|a, b| a.0.cmp(b.0));
8709×
        out.dedup_by(|a, b| a.0 == b.0);
8719×
        out
8729×
    }
873
874
    /// Whether a subrepo is at another pin than the manifest recorded: its
875
    /// pin now (the nested repository's base, or Git's `HEAD`), encoded as
876
    /// a snapshot records it, against the recorded hash. Absent, it keeps
877
    /// its recorded pin, as a snapshot does; unreadable, it counts as moved.
8783×
    fn subrepo_moved(&self, rel: &str, me: &ManifestEntry) -> bool {
8793×
        let dir = self.root.join(rel);
8803×
        if !(dir.join(OXY_DIR).exists() || dir.join(".git").exists()) {
8810×
            return false;
8823×
        }
8833×
        let modules = self.modules_on_disk();
8843×
        let hint = modules.entries.get(rel).and_then(|m| m.bookmark.clone());
8853×
        match super::subrepos::read_pin(&dir, hint.as_deref()) {
886
            // A commit's manifest names the pin by its blob (its hash is of
887
            // no bytes); a snapshot's by the bytes' hash.
8883×
            Ok(pin) => match me.blob {
8893×
                Some(b) => Repo::blob_ref(&pin.encode()) != b,
8900×
                None => *blake3::hash(&pin.encode()).as_bytes() != me.hash,
891
            },
8920×
            Err(_) => true,
893
        }
8943×
    }
895
896
    /// [`Repo::disk_differs`] without listing the tree, when the manifest
897
    /// knows its directories: a directory's time moves when an entry is
898
    /// added, removed or renamed in it, so with every directory's time as
899
    /// recorded (and none within the racy window) the files are the
900
    /// manifest's, and each is checked by its stat. `None` when a
901
    /// directory changed or the manifest has none: the tree must be listed.
902
    #[cfg(test)]
9035×
    pub(crate) fn disk_differs_by_dirs(&self, manifest: &Manifest) -> Option<bool> {
9045×
        self.disk_differs_by_dirs_keeping(manifest, false, false)
9055×
    }
906
907
    /// [`Repo::disk_differs_by_dirs`]; with `keep`, when a file differs
908
    /// (or `racy` says one does), the stats taken become the listing of the
909
    /// snapshot that follows: no directory's entries changed, so the
910
    /// manifest's files are the tree's.
911871×
    fn disk_differs_by_dirs_keeping(
912871×
        &self,
913871×
        manifest: &Manifest,
914871×
        keep: bool,
915871×
        racy: bool,
916871×
    ) -> Option<bool> {
917
        use rayon::prelude::*;
918871×
        if manifest.dirs.is_empty() || !self.config.sparse_paths.is_empty() {
9193×
            return None;
920868×
        }
921868×
        let _t = crate::timing::phase("status: directories and files by stat");
922868×
        let root = &self.root;
923947×
        let dirs_same = manifest.dirs.par_iter().all(|(rel, t)| {
924947×
            *t + 2_000_000_000 < manifest.written_ns
92525×
                && fs::metadata(root.join(rel)).is_ok_and(|m| m.is_dir() && mtime_ns(&m) == *t)
926947×
        });
927868×
        if !dirs_same {
928861×
            return None;
9297×
        }
930
        // Subrepos are directories, compared by their pins below.
9317×
        let subrepos = self.subrepo_entries(manifest);
9324211×
        let is_subrepo = |rel: &str| subrepos.iter().any(|(r, _)| r.as_str() == rel);
9337×
        let tracked: Vec<(&String, &ManifestEntry)> = manifest
9347×
            .entries
9357×
            .iter()
9364211×
            .filter(|(rel, _)| !is_subrepo(rel))
9377×
            .collect();
9387×
        let rels: Vec<&String> = tracked.iter().map(|(r, _)| *r).collect();
9397×
        let now = stat_tracked(root, &rels);
9402118×
        let differs_from = |me: &ManifestEntry, rel: &str, now: &Tracked| match now {
9410×
            Tracked::Link(t) => *blake3::hash(t).as_bytes() != me.hash,
9422118×
            Tracked::File(m) => {
9432118×
                m.size != me.size
9442117×
                    || m.exec != me.exec
9452117×
                    || (m.mtime_ns != me.mtime_ns
9461×
                        && !fs::read(root.join(rel))
9471×
                            .is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash))
948
            }
9492118×
        };
950
        // A path gone or no longer a file: its directory moved, unless
951
        // within a clock tick; list the tree to be sure.
9527×
        if now.iter().any(Option::is_none) {
9530×
            return None;
9547×
        }
955
        // The entries in the order `rels` was taken in: no lookup by path.
9567×
        let entries: Vec<&ManifestEntry> = tracked.iter().map(|(_, me)| *me).collect();
9577×
        let differs = rels
9587×
            .par_iter()
9597×
            .zip(entries.par_iter())
9607×
            .zip(now.par_iter())
9612118×
            .any(|((rel, me), n)| differs_from(me, rel, n.as_ref().unwrap()))
9625×
            || subrepos.iter().any(|(rel, me)| self.subrepo_moved(rel, me));
9637×
        if keep && (differs || racy) {
9641×
            let mut listing = super::walk::Listing {
9651×
                dirs: manifest.dirs.clone(),
9661×
                nested: manifest.nested.iter().map(|n| root.join(n)).collect(),
9671×
                ..Default::default()
968
            };
9692100×
            for (rel, n) in rels.into_iter().zip(now) {
9702100×
                match n.unwrap() {
9712100×
                    Tracked::File(m) => listing.files.push((rel.clone(), root.join(rel), m)),
9720×
                    Tracked::Link(t) => listing.symlinks.push((rel.clone(), t)),
973
                }
974
            }
9751×
            *self.listing_cache.borrow_mut() = Some(listing);
9766×
        }
9777×
        if !differs {
978
            // Nested repositories the manifest has no entry for are not
979
            // subrepos: nothing in them is tracked (oxygen#43).
9805×
            *self.untracked_nested.borrow_mut() = manifest
9815×
                .nested
9825×
                .iter()
9835×
                .filter(|rel| !manifest.entries.contains_key(*rel))
9845×
                .cloned()
9855×
                .collect();
9862×
        }
9877×
        Some(differs)
988871×
    }
989
990
    /// [`Repo::disk_differs`] by listing the tree.
991692×
    fn disk_differs_listing(&self, manifest: &Manifest, keep: bool) -> Result<bool> {
992
        use rayon::prelude::*;
993692×
        let sparse = &self.config.sparse_paths;
9947317×
        let in_scope = |p: &str| {
9957317×
            sparse.is_empty()
9965×
                || sparse
9975×
                    .iter()
9986×
                    .any(|s| p == s || p.starts_with(&format!("{s}/")))
9997317×
        };
1000
        // A working copy that cannot be listed counts as changed.
1001
        // Known files are not matched against the ignore rules: with no
1002
        // uncommitted work (the clean check), the manifest's files are the
1003
        // base's, and a prompt's answer is the same either way.
10047329×
        let known = |p: &str| manifest.entries.contains_key(p);
1005692×
        let Ok(listing) = super::walk::list_knowing(&self.root, &in_scope, &known) else {
10060×
            return Ok(true);
1007
        };
1008692×
        let differs = || -> Result<bool> {
1009692×
            let links_differ = listing.symlinks.iter().any(|(rel, target)| {
10101×
                manifest
10111×
                    .entries
10121×
                    .get(rel)
10131×
                    .is_none_or(|me| *blake3::hash(target).as_bytes() != me.hash)
10141×
            });
1015692×
            if links_differ {
10161×
                return Ok(true);
1017691×
            }
1018
            // Each file is checked on its own, in parallel, stopping at the
1019
            // first difference; an unreadable one counts as different.
10206841×
            let files_differ = listing.files.par_iter().any(|(rel, path, m)| {
10216841×
                manifest.entries.get(rel).is_none_or(|me| {
10226642×
                    m.size != me.size
10236641×
                        || m.exec != me.exec
10246638×
                        || (m.mtime_ns != me.mtime_ns
10253×
                            && !fs::read(path)
10263×
                                .is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash))
10276642×
                })
10286841×
            });
1029691×
            if files_differ {
1030202×
                return Ok(true);
1031489×
            }
1032489×
            let seen = listing.files.len() + listing.symlinks.len();
1033489×
            if seen == manifest.entries.len() {
1034487×
                return Ok(false);
10352×
            }
1036
            // Manifest entries the walk skipped: tracked files that ignore
1037
            // rules hide (Linux tracks some its own .gitignore names). Found
1038
            // by merging the two sorted lists; they count as present when
1039
            // still on disk and unchanged, checked on every core.
10402×
            let mut listed: Vec<&str> = listing
10412×
                .files
10422×
                .iter()
10434×
                .map(|(r, _, _)| r.as_str())
10442×
                .chain(listing.symlinks.iter().map(|(r, _)| r.as_str()))
10452×
                .collect();
10462×
            listed.sort_unstable();
10472×
            let mut at = listed.iter().peekable();
10482×
            let skipped: Vec<(&String, &ManifestEntry)> = manifest
10492×
                .entries
10502×
                .iter()
10516×
                .filter(|(rel, _)| {
10528×
                    while at.next_if(|l| **l < rel.as_str()).is_some() {}
10536×
                    at.peek().is_none_or(|l| **l != rel.as_str())
10546×
                })
10552×
                .collect();
10562×
            let root = &self.root;
1057
            // Subrepos are not listed as files: compared by their pins.
10582×
            let subrepos = self.subrepo_entries(manifest);
10592×
            if subrepos.iter().any(|(rel, me)| self.subrepo_moved(rel, me)) {
10601×
                return Ok(true);
10611×
            }
10621×
            let skipped: Vec<_> = skipped
10631×
                .into_iter()
10641×
                .filter(|(rel, _)| !subrepos.iter().any(|(r, _)| r == rel))
10651×
                .collect();
10661×
            Ok(skipped.par_iter().any(|(rel, me)| {
10670×
                let path = root.join(rel.as_str());
10680×
                let Ok(m) = fs::symlink_metadata(&path) else {
10690×
                    return true;
1070
                };
10710×
                !m.file_type().is_symlink()
10720×
                    && (m.len() != me.size
10730×
                        || super::is_executable(&m) != me.exec
10740×
                        || (mtime_ns(&m) != me.mtime_ns
10750×
                            && !fs::read(&path)
10760×
                                .is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash)))
10770×
            }))
1078692×
        };
1079692×
        let dirty = differs()?;
1080692×
        if !dirty {
1081
            // Nested repositories the manifest has no entry for are not
1082
            // subrepos: nothing in them is tracked (oxygen#43).
1083488×
            let mut nested: Vec<String> = listing
1084488×
                .nested
1085488×
                .iter()
1086488×
                .map(|d| self.rel_path(d))
1087488×
                .filter(|rel| !manifest.entries.contains_key(rel))
1088488×
                .collect();
1089488×
            nested.sort();
1090488×
            *self.untracked_nested.borrow_mut() = nested;
1091
            // A complete list of the directories, each timed before it was
1092
            // read, for a restamp to keep (see `clean_by_manifest`).
1093488×
            *self.listed_dirs.borrow_mut() = Some((
1094488×
                listing.dirs.clone(),
1095488×
                listing.nested.iter().map(|d| self.rel_path(d)).collect(),
1096
            ));
1097204×
        }
1098692×
        if dirty && keep {
1099202×
            *self.listing_cache.borrow_mut() = Some(listing);
1100490×
        }
1101692×
        Ok(dirty)
1102692×
    }
1103
1104
    /// Prompt information without recording anything.
110511×
    pub fn quick_status(&self) -> Result<PromptInfo> {
110611×
        let view = self.view()?;
110711×
        let bookmark = self.config.bookmark.clone();
110811×
        let heads = self.heads_of(&view, &bookmark)?;
110911×
        let wc_hash = view.workspaces.get(&self.workspace).copied();
111011×
        let change_id = match wc_hash {
111111×
            Some(h) => self.store.get_change_meta(&h)?.change_id.short(8),
11120×
            None => String::new(),
1113
        };
1114
        // Conflictedness was recorded by the last snapshot; without a
1115
        // manifest (never snapshotted) both flags are unknown and shown clear.
111611×
        let manifest = self.read_manifest()?;
111711×
        let conflicted = manifest.as_ref().map(|(c, _, _)| *c).unwrap_or(false);
111811×
        let dirty = self.quick_dirty()?.unwrap_or(false);
111911×
        let (ahead, behind) = match view
112011×
            .remotes
112111×
            .get(&self.default_remote())
112211×
            .and_then(|b| b.get(&bookmark))
1123
        {
112410×
            Some(st) => {
112510×
                let remote_heads = self.store.get_state(st)?.heads;
112610×
                let ahead = self.negotiate(&remote_heads, &heads).map(|v| v.len()).ok();
112710×
                let behind = self.negotiate(&heads, &remote_heads).map(|v| v.len()).ok();
112810×
                (ahead, behind)
1129
            }
11301×
            None => (None, None),
1131
        };
113211×
        Ok(PromptInfo {
113311×
            bookmark,
113411×
            change_id,
113511×
            heads: heads.len(),
113611×
            dirty,
113711×
            conflicted,
113811×
            ahead,
113911×
            behind,
114011×
        })
114111×
    }
1142
}
1143
1144
#[cfg(test)]
1145
mod tests {
1146
    use super::*;
1147
1148
    #[test]
11491×
    fn placeholders_and_glyphs() {
11501×
        let clean = PromptInfo {
11511×
            bookmark: "main".into(),
11521×
            change_id: "658990aa".into(),
11531×
            heads: 1,
11541×
            dirty: false,
11551×
            conflicted: false,
11561×
            ahead: Some(0),
11571×
            behind: None,
11581×
        };
11591×
        assert_eq!(clean.format(DEFAULT_PROMPT_FORMAT), "main 658990aa");
11601×
        assert_eq!(clean.format(GLYPH_PROMPT_FORMAT), "⚛ main 658990aa ✔");
11611×
        let busy = PromptInfo {
11621×
            dirty: true,
11631×
            ahead: Some(2),
11641×
            behind: Some(3),
11651×
            ..clean.clone()
11661×
        };
11671×
        assert_eq!(busy.format(DEFAULT_PROMPT_FORMAT), "main 658990aa*+2-3");
11681×
        assert_eq!(busy.format(GLYPH_PROMPT_FORMAT), "⚛ main 658990aa ✎↑2↓3");
11691×
        let bad = PromptInfo {
11701×
            conflicted: true,
11711×
            ..busy.clone()
11721×
        };
11731×
        assert_eq!(bad.format("%s%x%d %h %a/%B %%"), "‼!* 1 2/3 %");
11741×
        assert_eq!(bad.state_color(), 31);
11751×
        assert_eq!(
11761×
            busy.format_colored("%b", ("\\[", "\\]")),
1177
            "\\[\x1b[33m\\]main\\[\x1b[0m\\]"
1178
        );
11791×
        assert_eq!(clean.format_colored("%b", ("", "")), "\x1b[36mmain\x1b[0m");
11801×
        assert_eq!(clean.format("%q"), "%q");
11811×
    }
1182
}