OXYGENHUB
# 94cdbc576ce1 oxygen / crates / oxy-core / src / repo / prompt.rs
34.9 KiB · Rust · last changed Timing phases for writing the manifest (refs #41) 1 month ago source blame history raw
This file is in conflict in this state; the markers below are the conflict. All conflicts
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.
29
    pub fn state_glyph(&self) -> &'static str {
30
        if self.conflicted {
31
            "‼"
32
        } else if self.dirty {
33
            "✎"
34
        } else {
35
            "✔"
36
        }
37
    }
38
39
    /// The ANSI colour (SGR parameter) for the state: red, yellow, or cyan.
40
    pub fn state_color(&self) -> u8 {
41
        if self.conflicted {
42
            31
43
        } else if self.dirty {
44
            33
45
        } else {
46
            36
47
        }
48
    }
49
50
    /// `format` wrapped in the state colour. `guard` is `("\\[", "\\]")` for
51
    /// bash prompts, `("%{", "%}")` for zsh, or empty for raw output.
52
    pub fn format_colored(&self, fmt: &str, guard: (&str, &str)) -> String {
53
        format!(
54
            "{}\x1b[{}m{}{}{}\x1b[0m{}",
55
            guard.0,
56
            self.state_color(),
57
            guard.1,
58
            self.format(fmt),
59
            guard.0,
60
            guard.1
61
        )
62
    }
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.
69
    pub fn format(&self, fmt: &str) -> String {
70
        let mut out = String::new();
71
        let mut chars = fmt.chars();
72
        while let Some(c) = chars.next() {
73
            if c != '%' {
74
                out.push(c);
75
                continue;
76
            }
77
            match chars.next() {
78
                Some('b') => out.push_str(&self.bookmark),
79
                Some('c') => out.push_str(&self.change_id),
80
                Some('h') => out.push_str(&self.heads.to_string()),
81
                Some('d') => {
82
                    if self.dirty {
83
                        out.push('*');
84
                    }
85
                }
86
                Some('x') => {
87
                    if self.conflicted {
88
                        out.push('!');
89
                    }
90
                }
91
                Some('a') => out.push_str(&self.ahead.map(|n| n.to_string()).unwrap_or_default()),
92
                Some('B') => out.push_str(&self.behind.map(|n| n.to_string()).unwrap_or_default()),
93
                Some('A') => {
94
                    if let Some(n) = self.ahead.filter(|n| *n > 0) {
95
                        out.push_str(&format!("+{n}"));
96
                    }
97
                }
98
                Some('D') => {
99
                    if let Some(n) = self.behind.filter(|n| *n > 0) {
100
                        out.push_str(&format!("-{n}"));
101
                    }
102
                }
103
                Some('s') => out.push_str(self.state_glyph()),
104
                Some('U') => {
105
                    if let Some(n) = self.ahead.filter(|n| *n > 0) {
106
                        out.push_str(&format!("↑{n}"));
107
                    }
108
                }
109
                Some('O') => {
110
                    if let Some(n) = self.behind.filter(|n| *n > 0) {
111
                        out.push_str(&format!("↓{n}"));
112
                    }
113
                }
114
                Some('i') => out.push_str(ICON),
115
                Some('%') => out.push('%'),
116
                Some(other) => {
117
                    out.push('%');
118
                    out.push(other);
119
                }
120
                None => out.push('%'),
121
            }
122
        }
123
        out
124
    }
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
pub(crate) fn mtime_ns(m: &fs::Metadata) -> u128 {
162
    m.modified()
163
        .ok()
164
        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
165
        .map(|d| d.as_nanos())
166
        .unwrap_or(0)
167
}
168
169
pub const DEFAULT_PROMPT_FORMAT: &str = "%b %c%d%x%A%D";
170
/// The repository icon for `%i` (an atom, for oxygen).
171
pub const ICON: &str = "⚛";
172
/// A format with glyphs: icon, bookmark, change id, state, ahead/behind.
173
pub const GLYPH_PROMPT_FORMAT: &str = "%i %b %c %s%U%O";
174
175
/// The manifest file (oxygen#41): a flat binary layout read in one pass.
176
/// A magic number; the conflicted and uncommitted flags; when it was
177
/// written, at a fixed offset so an otherwise equal manifest can be
178
/// recognised; BLAKE3 of everything else; then each entry in path order:
179
/// path, size, mtime in nanoseconds, BLAKE3 of the content, the stored
180
/// blob of an opaque file, the graph key it was found equal to, and
181
/// whether it is executable. Format 3 puts before the entries every
182
/// directory the walk listed with its time, and the nested repositories. A file that does not check, or in any other
183
/// layout (an older manifest), reads as no manifest, and the next snapshot
184
/// writes it again: it is a cache, written without waiting for the disk.
185
const MANIFEST_MAGIC: &[u8; 8] = b"oxyman3\0";
186
/// Where the written time sits: after the magic and the two flags.
187
const MANIFEST_WRITTEN: std::ops::Range<usize> = 10..26;
188
/// Where the checksum sits, after the written time.
189
const MANIFEST_SUM: std::ops::Range<usize> = 26..58;
190
191
fn manifest_sum(buf: &[u8]) -> [u8; 32] {
192
    let mut h = blake3::Hasher::new();
193
    h.update(&buf[..MANIFEST_SUM.start]);
194
    h.update_rayon(&buf[MANIFEST_SUM.end..]);
195
    *h.finalize().as_bytes()
196
}
197
198
fn encode_manifest(
199
    conflicted: bool,
200
    uncommitted: bool,
201
    written_ns: u128,
202
    dirs: &[(String, u128)],
203
    nested: &[String],
204
    entries: &mut dyn Iterator<Item = (&str, ManifestEntry)>,
205
) -> Vec<u8> {
206
    let mut w = crate::flat::Writer::default();
207
    w.fixed(MANIFEST_MAGIC);
208
    w.u8(conflicted as u8);
209
    w.u8(uncommitted as u8);
210
    w.fixed(&written_ns.to_le_bytes());
211
    w.fixed(&[0; 32]);
212
    w.count(dirs.len());
213
    for (d, t) in dirs {
214
        w.bytes(d.as_bytes());
215
        w.fixed(&t.to_le_bytes());
216
    }
217
    w.count(nested.len());
218
    for n in nested {
219
        w.bytes(n.as_bytes());
220
    }
221
    for (path, e) in entries {
222
        w.bytes(path.as_bytes());
223
        w.u64(e.size);
224
        w.fixed(&e.mtime_ns.to_le_bytes());
225
        w.fixed(&e.hash);
226
        w.opt_object(&e.blob);
227
        match e.graph {
228
            None => w.u8(0),
229
            Some(k) => {
230
                w.u8(1);
231
                w.fixed(&k);
232
            }
233
        }
234
        w.u8(e.exec as u8);
235
    }
236
    let sum = manifest_sum(&w.0);
237
    w.0[MANIFEST_SUM].copy_from_slice(&sum);
238
    w.0
239
}
240
241
fn decode_manifest(buf: &[u8]) -> Option<(bool, bool, Manifest)> {
242
    let mut r = crate::flat::Reader::new(buf);
243
    if &r.fixed::<8>().ok()? != MANIFEST_MAGIC {
244
        return None;
245
    }
246
    let conflicted = r.u8().ok()? == 1;
247
    let uncommitted = r.u8().ok()? == 1;
248
    let written_ns = u128::from_le_bytes(r.fixed().ok()?);
249
    if r.fixed::<32>().ok()? != manifest_sum(buf) {
250
        return None;
251
    }
252
    let mut dirs = Vec::new();
253
    for _ in 0..r.count().ok()? {
254
        let d = std::str::from_utf8(r.bytes().ok()?).ok()?.to_string();
255
        dirs.push((d, u128::from_le_bytes(r.fixed().ok()?)));
256
    }
257
    let mut nested = Vec::new();
258
    for _ in 0..r.count().ok()? {
259
        nested.push(std::str::from_utf8(r.bytes().ok()?).ok()?.to_string());
260
    }
261
    let mut entries = Vec::new();
262
    while !r.at_end() {
263
        let path = std::str::from_utf8(r.bytes().ok()?).ok()?.to_string();
264
        let e = ManifestEntry {
265
            size: r.u64().ok()?,
266
            mtime_ns: u128::from_le_bytes(r.fixed().ok()?),
267
            hash: r.fixed().ok()?,
268
            blob: r.opt_object().ok()?,
269
            graph: match r.u8().ok()? {
270
                0 => None,
271
                _ => Some(r.fixed().ok()?),
272
            },
273
            exec: r.u8().ok()? == 1,
274
        };
275
        entries.push((path, e));
276
    }
277
    // Written in path order, so this builds the map without searching.
278
    let entries = entries.into_iter().collect();
279
    Some((
280
        conflicted,
281
        uncommitted,
282
        Manifest {
283
            written_ns,
284
            entries,
285
            dirs,
286
            nested,
287
        },
288
    ))
289
}
290
291
/// The status cache: a magic number, the working-copy change, the
292
/// manifest checksum, then each status line (a status byte and a path).
293
const STATUS_MAGIC: &[u8; 8] = b"oxystat1";
294
295
fn status_byte(s: ChangeStatus) -> u8 {
296
    match s {
297
        ChangeStatus::Added => 0,
298
        ChangeStatus::Modified => 1,
299
        ChangeStatus::Removed => 2,
300
        ChangeStatus::Renamed => 3,
301
        ChangeStatus::ModeChanged => 4,
302
        ChangeStatus::ConflictEdited => 5,
303
        ChangeStatus::Resolved => 6,
304
    }
305
}
306
307
fn encode_status_cache(wc: &ChangeHash, stamp: &[u8; 32], statuses: &[PathStatus]) -> Vec<u8> {
308
    let mut out = Vec::with_capacity(80 + statuses.len() * 40);
309
    out.extend_from_slice(STATUS_MAGIC);
310
    out.extend_from_slice(&wc.0);
311
    out.extend_from_slice(stamp);
312
    out.extend_from_slice(&(statuses.len() as u32).to_le_bytes());
313
    for s in statuses {
314
        out.push(status_byte(s.status));
315
        out.extend_from_slice(&(s.path.len() as u32).to_le_bytes());
316
        out.extend_from_slice(s.path.as_bytes());
317
    }
318
    out
319
}
320
321
fn decode_status_cache(b: &[u8]) -> Option<(ChangeHash, [u8; 32], Vec<PathStatus>)> {
322
    let rest = b.strip_prefix(STATUS_MAGIC)?;
323
    let wc = ObjectRef(rest.get(..32)?.try_into().ok()?);
324
    let stamp: [u8; 32] = rest.get(32..64)?.try_into().ok()?;
325
    let n = u32::from_le_bytes(rest.get(64..68)?.try_into().ok()?) as usize;
326
    let mut at = 68;
327
    let mut out = Vec::with_capacity(n.min(1 << 16));
328
    for _ in 0..n {
329
        let status = match *rest.get(at)? {
330
            0 => ChangeStatus::Added,
331
            1 => ChangeStatus::Modified,
332
            2 => ChangeStatus::Removed,
333
            3 => ChangeStatus::Renamed,
334
            4 => ChangeStatus::ModeChanged,
335
            5 => ChangeStatus::ConflictEdited,
336
            6 => ChangeStatus::Resolved,
337
            _ => return None,
338
        };
339
        let len = u32::from_le_bytes(rest.get(at + 1..at + 5)?.try_into().ok()?) as usize;
340
        let path = std::str::from_utf8(rest.get(at + 5..at + 5 + len)?)
341
            .ok()?
342
            .to_string();
343
        out.push(PathStatus { path, status });
344
        at += 5 + len;
345
    }
346
    (at == rest.len()).then_some((wc, stamp, out))
347
}
348
349
impl Repo {
350
    pub(crate) fn manifest_path(&self) -> PathBuf {
351
        self.oxy
352
            .join("workspaces")
353
            .join(self.workspace.to_hex())
354
            .join("manifest")
355
    }
356
357
    /// Record the working copy as it is now (after a snapshot, commit, or
358
    /// checkout), with header lines saying whether the base state is
359
    /// conflicted and whether the working-copy change holds uncommitted work.
360
    pub(crate) fn write_manifest(&self, conflicted: bool, uncommitted: bool) -> Result<()> {
361
        let entries = self.walk_working_copy()?;
362
        self.write_manifest_entries(conflicted, uncommitted, &entries, &HashMap::new())
363
    }
364
365
    /// Write the manifest from entries already walked; `blobs` names the
366
    /// stored blob of each opaque path so the next walk can skip reading it.
367
    pub(crate) fn write_manifest_entries(
368
        &self,
369
        conflicted: bool,
370
        uncommitted: bool,
371
        entries: &BTreeMap<String, FsEntry>,
372
        blobs: &HashMap<String, ObjectRef>,
373
    ) -> Result<()> {
374
        self.manifest_cache.take();
375
        self.listing_cache.take();
376
        let written = std::time::SystemTime::now()
377
            .duration_since(UNIX_EPOCH)
378
            .map(|d| d.as_nanos())
379
            .unwrap_or(0);
380
        let mut newest_ns: u128 = 0;
381
        let mut rows = entries.iter().map(|(path, e)| {
382
            let (size, mtime_ns) = match e.stat {
383
                Some(known) => known,
384
                None => match fs::symlink_metadata(self.root.join(path)) {
385
                    Ok(m) => (m.len(), mtime_ns(&m)),
386
                    Err(_) => (e.content.len() as u64, 0),
387
                },
388
            };
389
            newest_ns = newest_ns.max(mtime_ns);
390
            let entry = ManifestEntry {
391
                size,
392
                mtime_ns,
393
                hash: e
394
                    .content_hash
395
                    .unwrap_or_else(|| *blake3::hash(&e.content).as_bytes()),
396
                blob: blobs.get(path).copied().or(e.blob),
397
                graph: e.graph_key,
398
                exec: e.exec,
399
            };
400
            (path.as_str(), entry)
401
        });
402
<<<<<<< oxy conflict 1 of 1, side 1 of 2: change 484e854e "The manifest keeps directory times: status lists the tree only when a directory changed (refs #41)"
403
        // The directories of the walk these entries came from, if any.
404
        let (dirs, nested) = self.walked_dirs.take().unwrap_or_default();
405
======= oxy conflict 1 of 1, side 2 of 2: change c04ec0a5 "Timing phases for writing the manifest (refs #41)"
406
        let encoding = crate::timing::phase("manifest: rows and encoding");
407
>>>>>>> oxy conflict 1 of 1 end
408
        let new = encode_manifest(conflicted, uncommitted, written, &dirs, &nested, &mut rows);
409
        drop(rows);
410
        drop(encoding);
411
        if crate::timing::enabled() {
412
            let unstated = entries.values().filter(|e| e.stat.is_none()).count();
413
            let unhashed = entries
414
                .values()
415
                .filter(|e| e.content_hash.is_none())
416
                .count();
417
            eprintln!(
418
                "timing manifest: {} rows, {unstated} without a stat, {unhashed} without a hash",
419
                entries.len()
420
            );
421
        }
422
        let _t = crate::timing::phase("manifest: compare and write");
423
        let p = self.manifest_path();
424
        // Leave an unchanged manifest alone when its stamp already trusts
425
        // every entry (none is within the racy window): rewriting it would
426
        // change nothing but the file, which editors watching the tree
427
        // (and subrepos inside it) take as a change.
428
        if let Ok(old) = fs::read(&p) {
429
            let (w, sum) = (MANIFEST_WRITTEN, MANIFEST_SUM);
430
            if old.len() == new.len()
431
                && old[..w.start] == new[..w.start]
432
                && old[sum.end..] == new[sum.end..]
433
            {
434
                let old_written = u128::from_le_bytes(old[w.clone()].try_into().unwrap());
435
                if newest_ns + 2_000_000_000 < old_written {
436
                    return Ok(());
437
                }
438
            }
439
        }
440
        fs::create_dir_all(p.parent().unwrap())?;
441
        write_cache(&p, &new)
442
    }
443
444
    /// Change the header flags, and set the graph key of each path in
445
    /// `keys` (after a commit, the new base's keys of the files it holds as
446
    /// they are on disk).
447
    pub(crate) fn rewrite_manifest(
448
        &self,
449
        conflicted: bool,
450
        uncommitted: bool,
451
        keys: &HashMap<String, [u8; 32]>,
452
    ) -> Result<()> {
453
        self.manifest_cache.take();
454
        self.listing_cache.take();
455
        let p = self.manifest_path();
456
        let Some((_, _, m)) = fs::read(&p).ok().and_then(|b| decode_manifest(&b)) else {
457
            return self.write_manifest(conflicted, uncommitted);
458
        };
459
        let mut rows = m.entries.iter().map(|(path, e)| {
460
            let mut e = e.clone();
461
            if let Some(k) = keys.get(path) {
462
                e.graph = Some(*k);
463
            }
464
            (path.as_str(), e)
465
        });
466
        write_cache(
467
            &p,
468
            &encode_manifest(
469
                conflicted,
470
                uncommitted,
471
                m.written_ns,
472
                &m.dirs,
473
                &m.nested,
474
                &mut rows,
475
            ),
476
        )
477
    }
478
479
    /// Forget the manifest after an operation that changed the working-copy
480
    /// change without a snapshot (undo, rewrites); the prompt shows blank
481
    /// markers until the next snapshot.
482
    pub(crate) fn drop_manifest(&self) {
483
        self.manifest_cache.take();
484
        self.listing_cache.take();
485
        let _ = fs::remove_file(self.manifest_path());
486
    }
487
488
    /// The manifest, with whether it records a conflict and uncommitted
489
    /// work; parsed once while the file stays the same one.
490
    pub(crate) fn read_manifest(&self) -> Result<Option<(bool, bool, std::sync::Arc<Manifest>)>> {
491
        let path = self.manifest_path();
492
        let id = match fs::metadata(&path) {
493
            Ok(m) => {
494
                #[cfg(unix)]
495
                let ino = std::os::unix::fs::MetadataExt::ino(&m);
496
                #[cfg(not(unix))]
497
                let ino = 0;
498
                (m.len(), mtime_ns(&m), ino)
499
            }
500
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
501
            Err(e) => return Err(e.into()),
502
        };
503
        if let Some((cached_id, m)) = &*self.manifest_cache.borrow() {
504
            if *cached_id == id {
505
                return Ok(Some(m.clone()));
506
            }
507
        }
508
        let Some((c, u, m)) = self.parse_manifest(&path)? else {
509
            return Ok(None);
510
        };
511
        let m = (c, u, std::sync::Arc::new(m));
512
        *self.manifest_cache.borrow_mut() = Some((id, m.clone()));
513
        Ok(Some(m))
514
    }
515
516
    fn parse_manifest(&self, path: &std::path::Path) -> Result<Option<(bool, bool, Manifest)>> {
517
        let buf = match fs::read(path) {
518
            Ok(b) => b,
519
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
520
            Err(e) => return Err(e.into()),
521
        };
522
        Ok(decode_manifest(&buf))
523
    }
524
525
    /// Whether the working copy is certainly clean, known from the manifest
526
    /// alone, without loading any history (spec §12.4): the last snapshot
527
    /// or commit found no uncommitted work and no conflict, there are no
528
    /// subrepos (whose state the manifest does not hold), and no file was
529
    /// added, removed or changed since. An entry inside the racy window,
530
    /// where an equal size and time do not prove equal content, is checked
531
    /// by hashing the file. `false` means only that a snapshot must decide.
532
    pub fn clean_by_manifest(&self) -> Result<bool> {
533
        let _t = crate::timing::phase("status: check the manifest");
534
        // Why a snapshot must decide, with timings on.
535
        let not = |why: &str| {
536
            if crate::timing::enabled() {
537
                eprintln!("timing status: not clean by the manifest: {why}");
538
            }
539
            Ok(false)
540
        };
541
        let Some((conflicted, uncommitted, m)) = self.read_manifest()? else {
542
            return not("no manifest");
543
        };
544
        if conflicted || uncommitted || self.root.join(".oxymodules").exists() {
545
            return not("conflicted, uncommitted or subrepos");
546
        }
547
        if self.disk_differs(&m, true)? {
548
            return not("a file differs");
549
        }
550
        Ok(true)
551
    }
552
553
    /// A manifest entry written within the racy window whose file no
554
    /// longer matches it. Such entries are hashed, in parallel (a checkout
555
    /// just wrote them all).
556
    fn racy_change(&self, m: &Manifest) -> Option<String> {
557
        use rayon::prelude::*;
558
        let racy: Vec<(&String, &ManifestEntry)> = m
559
            .entries
560
            .iter()
561
            .filter(|(_, e)| e.mtime_ns + 2_000_000_000 >= m.written_ns)
562
            .collect();
563
        let root = &self.root;
564
        racy.par_iter()
565
            .find_any(|(rel, e)| {
566
                let path = root.join(rel.as_str());
567
                let same = match fs::symlink_metadata(&path) {
568
                    Ok(md) if md.file_type().is_symlink() => fs::read_link(&path).is_ok_and(|t| {
569
                        *blake3::hash(t.to_string_lossy().as_bytes()).as_bytes() == e.hash
570
                    }),
571
                    Ok(md) if md.is_file() => {
572
                        md.len() == e.size
573
                            && fs::read(&path).is_ok_and(|b| *blake3::hash(&b).as_bytes() == e.hash)
574
                    }
575
                    _ => false,
576
                };
577
                !same
578
            })
579
            .map(|(rel, _)| rel.to_string())
580
    }
581
582
    /// The status lines of the last snapshot, when the working copy is as
583
    /// that snapshot left it: the cache it wrote names the working-copy
584
    /// change and the manifest it recorded, and every file still matches
585
    /// that manifest. `None` when a snapshot must decide (no cache, a file
586
    /// changed, conflicts or subrepos). An empty list is a clean working
587
    /// copy.
588
    pub fn status_by_manifest(&self) -> Result<Option<Vec<PathStatus>>> {
589
        // A listing left by an earlier check describes the files as they
590
        // were then; this check makes its own.
591
        self.listing_cache.take();
592
        let Some((conflicted, uncommitted, m)) = self.read_manifest()? else {
593
            return Ok(None);
594
        };
595
        if !uncommitted {
596
            return Ok(self.clean_by_manifest()?.then(Vec::new));
597
        }
598
        let _t = crate::timing::phase("status: by the status cache");
599
        if conflicted || self.root.join(".oxymodules").exists() {
600
            return Ok(None);
601
        }
602
        let Some((wc, stamp, statuses)) = fs::read(self.status_cache_path())
603
            .ok()
604
            .and_then(|b| decode_status_cache(&b))
605
        else {
606
            return Ok(None);
607
        };
608
        let now = self.view()?.workspaces.get(&self.workspace).copied();
609
        if now != Some(wc) || self.manifest_stamp() != Some(stamp) {
610
            return Ok(None);
611
        }
612
        if self.disk_differs(&m, true)? {
613
            return Ok(None);
614
        }
615
        // Test builds check the cache against a snapshot of the files.
616
        #[cfg(test)]
617
        {
618
            let snap = self.snapshot_status("status cache check")?;
619
            let key = |v: &[PathStatus]| {
620
                let mut k: Vec<(String, u8)> = v
621
                    .iter()
622
                    .map(|p| (p.path.clone(), status_byte(p.status)))
623
                    .collect();
624
                k.sort();
625
                k
626
            };
627
            assert_eq!(
628
                key(&statuses),
629
                key(&snap.statuses),
630
                "status cache disagrees with a snapshot"
631
            );
632
        }
633
        Ok(Some(statuses))
634
    }
635
636
    fn status_cache_path(&self) -> PathBuf {
637
        self.manifest_path().with_file_name("status")
638
    }
639
640
    /// The manifest file's checksum, which names its content.
641
    fn manifest_stamp(&self) -> Option<[u8; 32]> {
642
        use std::io::Read;
643
        let mut head = [0u8; MANIFEST_SUM.end];
644
        fs::File::open(self.manifest_path())
645
            .and_then(|mut f| f.read_exact(&mut head))
646
            .ok()?;
647
        head[MANIFEST_SUM].try_into().ok()
648
    }
649
650
    /// Record `statuses` as the status of working-copy change `wc` against
651
    /// the manifest as it is now; with no uncommitted work there is nothing
652
    /// to record, and the cache goes.
653
    pub(crate) fn write_status_cache(&self, wc: Option<&ChangeHash>, statuses: &[PathStatus]) {
654
        let path = self.status_cache_path();
655
        let (Some(wc), Some(stamp)) = (wc, self.manifest_stamp()) else {
656
            let _ = fs::remove_file(&path);
657
            return;
658
        };
659
        let _ = write_cache(&path, &encode_status_cache(wc, &stamp, statuses));
660
    }
661
662
    /// Whether the working copy differs from the last manifest. Without a
663
    /// manifest the answer is `None`.
664
    pub fn quick_dirty(&self) -> Result<Option<bool>> {
665
        self.quick_dirty_keeping(false)
666
    }
667
668
    /// `quick_dirty`; with `keep`, the listing it made is left for the
669
    /// next walk of the working copy.
670
    fn quick_dirty_keeping(&self, keep: bool) -> Result<Option<bool>> {
671
        let Some((_, uncommitted, manifest)) = self.read_manifest()? else {
672
            return Ok(None);
673
        };
674
        if uncommitted {
675
            return Ok(Some(true));
676
        }
677
        Ok(Some(self.disk_differs(&manifest, keep)?))
678
    }
679
680
    /// Whether the working copy differs from `manifest`: a file added,
681
    /// gone or changed. With `keep`, the listing made is left for the next
682
    /// walk of the working copy when it does.
683
    fn disk_differs(&self, manifest: &Manifest, keep: bool) -> Result<bool> {
684
        // A file written within the racy window can change without its size
685
        // or time moving (a coarse clock): those entries are hashed.
686
        if self.racy_change(manifest).is_some() {
687
            return Ok(true);
688
        }
689
        if let Some(differs) = self.disk_differs_by_dirs(manifest) {
690
            return Ok(differs);
691
        }
692
        self.disk_differs_listing(manifest, keep)
693
    }
694
695
    /// [`Repo::disk_differs`] without listing the tree, when the manifest
696
    /// knows its directories: a directory's time moves when an entry is
697
    /// added, removed or renamed in it, so with every directory's time as
698
    /// recorded (and none within the racy window) the files are the
699
    /// manifest's, and each is checked by its stat. `None` when a
700
    /// directory changed or the manifest has none: the tree must be listed.
701
    pub(crate) fn disk_differs_by_dirs(&self, manifest: &Manifest) -> Option<bool> {
702
        use rayon::prelude::*;
703
        if manifest.dirs.is_empty() || !self.config.sparse_paths.is_empty() {
704
            return None;
705
        }
706
        let _t = crate::timing::phase("status: directories and files by stat");
707
        let root = &self.root;
708
        let dirs_same = manifest.dirs.par_iter().all(|(rel, t)| {
709
            *t + 2_000_000_000 < manifest.written_ns
710
                && fs::metadata(root.join(rel)).is_ok_and(|m| m.is_dir() && mtime_ns(&m) == *t)
711
        });
712
        if !dirs_same {
713
            return None;
714
        }
715
        let differs = manifest.entries.par_iter().any(|(rel, me)| {
716
            let path = root.join(rel.as_str());
717
            let Ok(m) = fs::symlink_metadata(&path) else {
718
                return true;
719
            };
720
            if m.file_type().is_symlink() {
721
                return !fs::read_link(&path).is_ok_and(|t| {
722
                    *blake3::hash(t.to_string_lossy().as_bytes()).as_bytes() == me.hash
723
                });
724
            }
725
            !m.is_file()
726
                || m.len() != me.size
727
                || super::is_executable(&m) != me.exec
728
                || (mtime_ns(&m) != me.mtime_ns
729
                    && !fs::read(&path).is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash))
730
        });
731
        if !differs {
732
            // Nested repositories the manifest has no entry for are not
733
            // subrepos: nothing in them is tracked (oxygen#43).
734
            *self.untracked_nested.borrow_mut() = manifest
735
                .nested
736
                .iter()
737
                .filter(|rel| !manifest.entries.contains_key(*rel))
738
                .cloned()
739
                .collect();
740
        }
741
        Some(differs)
742
    }
743
744
    /// [`Repo::disk_differs`] by listing the tree.
745
    fn disk_differs_listing(&self, manifest: &Manifest, keep: bool) -> Result<bool> {
746
        use rayon::prelude::*;
747
        let sparse = &self.config.sparse_paths;
748
        let in_scope = |p: &str| {
749
            sparse.is_empty()
750
                || sparse
751
                    .iter()
752
                    .any(|s| p == s || p.starts_with(&format!("{s}/")))
753
        };
754
        // A working copy that cannot be listed counts as changed.
755
        // Known files are not matched against the ignore rules: with no
756
        // uncommitted work (the clean check), the manifest's files are the
757
        // base's, and a prompt's answer is the same either way.
758
        let known = |p: &str| manifest.entries.contains_key(p);
759
        let Ok(listing) = super::walk::list_knowing(&self.root, &in_scope, &known) else {
760
            return Ok(true);
761
        };
762
        let differs = || -> Result<bool> {
763
            let links_differ = listing.symlinks.iter().any(|(rel, target)| {
764
                manifest
765
                    .entries
766
                    .get(rel)
767
                    .is_none_or(|me| *blake3::hash(target).as_bytes() != me.hash)
768
            });
769
            if links_differ {
770
                return Ok(true);
771
            }
772
            // Each file is checked on its own, in parallel, stopping at the
773
            // first difference; an unreadable one counts as different.
774
            let files_differ = listing.files.par_iter().any(|(rel, path, m)| {
775
                manifest.entries.get(rel).is_none_or(|me| {
776
                    m.len() != me.size
777
                        || super::is_executable(m) != me.exec
778
                        || (mtime_ns(m) != me.mtime_ns
779
                            && !fs::read(path)
780
                                .is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash))
781
                })
782
            });
783
            if files_differ {
784
                return Ok(true);
785
            }
786
            let seen = listing.files.len() + listing.symlinks.len();
787
            if seen == manifest.entries.len() {
788
                return Ok(false);
789
            }
790
            // Manifest entries the walk skipped: tracked files that ignore
791
            // rules hide (Linux tracks some its own .gitignore names). Found
792
            // by merging the two sorted lists; they count as present when
793
            // still on disk and unchanged, checked on every core.
794
            let mut listed: Vec<&str> = listing
795
                .files
796
                .iter()
797
                .map(|(r, _, _)| r.as_str())
798
                .chain(listing.symlinks.iter().map(|(r, _)| r.as_str()))
799
                .collect();
800
            listed.sort_unstable();
801
            let mut at = listed.iter().peekable();
802
            let skipped: Vec<(&String, &ManifestEntry)> = manifest
803
                .entries
804
                .iter()
805
                .filter(|(rel, _)| {
806
                    while at.next_if(|l| **l < rel.as_str()).is_some() {}
807
                    at.peek().is_none_or(|l| **l != rel.as_str())
808
                })
809
                .collect();
810
            let root = &self.root;
811
            Ok(skipped.par_iter().any(|(rel, me)| {
812
                let path = root.join(rel.as_str());
813
                let Ok(m) = fs::symlink_metadata(&path) else {
814
                    return true;
815
                };
816
                !m.file_type().is_symlink()
817
                    && (m.len() != me.size
818
                        || super::is_executable(&m) != me.exec
819
                        || (mtime_ns(&m) != me.mtime_ns
820
                            && !fs::read(&path)
821
                                .is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash)))
822
            }))
823
        };
824
        let dirty = differs()?;
825
        if !dirty {
826
            // Nested repositories the manifest has no entry for are not
827
            // subrepos: nothing in them is tracked (oxygen#43).
828
            *self.untracked_nested.borrow_mut() = listing
829
                .nested
830
                .iter()
831
                .map(|d| self.rel_path(d))
832
                .filter(|rel| !manifest.entries.contains_key(rel))
833
                .collect();
834
        }
835
        if dirty && keep {
836
            *self.listing_cache.borrow_mut() = Some(listing);
837
        }
838
        Ok(dirty)
839
    }
840
841
    /// Prompt information without recording anything.
842
    pub fn quick_status(&self) -> Result<PromptInfo> {
843
        let view = self.view()?;
844
        let bookmark = self.config.bookmark.clone();
845
        let heads = self.heads_of(&view, &bookmark)?;
846
        let wc_hash = view.workspaces.get(&self.workspace).copied();
847
        let change_id = match wc_hash {
848
            Some(h) => self.store.get_change_meta(&h)?.change_id.short(8),
849
            None => String::new(),
850
        };
851
        // Conflictedness was recorded by the last snapshot; without a
852
        // manifest (never snapshotted) both flags are unknown and shown clear.
853
        let manifest = self.read_manifest()?;
854
        let conflicted = manifest.as_ref().map(|(c, _, _)| *c).unwrap_or(false);
855
        let dirty = self.quick_dirty()?.unwrap_or(false);
856
        let (ahead, behind) = match view.remotes.get("origin").and_then(|b| b.get(&bookmark)) {
857
            Some(st) => {
858
                let remote_heads = self.store.get_state(st)?.heads;
859
                let ahead = self.negotiate(&remote_heads, &heads).map(|v| v.len()).ok();
860
                let behind = self.negotiate(&heads, &remote_heads).map(|v| v.len()).ok();
861
                (ahead, behind)
862
            }
863
            None => (None, None),
864
        };
865
        Ok(PromptInfo {
866
            bookmark,
867
            change_id,
868
            heads: heads.len(),
869
            dirty,
870
            conflicted,
871
            ahead,
872
            behind,
873
        })
874
    }
875
}
876
877
#[cfg(test)]
878
mod tests {
879
    use super::*;
880
881
    #[test]
882
    fn placeholders_and_glyphs() {
883
        let clean = PromptInfo {
884
            bookmark: "main".into(),
885
            change_id: "658990aa".into(),
886
            heads: 1,
887
            dirty: false,
888
            conflicted: false,
889
            ahead: Some(0),
890
            behind: None,
891
        };
892
        assert_eq!(clean.format(DEFAULT_PROMPT_FORMAT), "main 658990aa");
893
        assert_eq!(clean.format(GLYPH_PROMPT_FORMAT), "⚛ main 658990aa ✔");
894
        let busy = PromptInfo {
895
            dirty: true,
896
            ahead: Some(2),
897
            behind: Some(3),
898
            ..clean.clone()
899
        };
900
        assert_eq!(busy.format(DEFAULT_PROMPT_FORMAT), "main 658990aa*+2-3");
901
        assert_eq!(busy.format(GLYPH_PROMPT_FORMAT), "⚛ main 658990aa ✎↑2↓3");
902
        let bad = PromptInfo {
903
            conflicted: true,
904
            ..busy.clone()
905
        };
906
        assert_eq!(bad.format("%s%x%d %h %a/%B %%"), "‼!* 1 2/3 %");
907
        assert_eq!(bad.state_color(), 31);
908
        assert_eq!(
909
            busy.format_colored("%b", ("\\[", "\\]")),
910
            "\\[\x1b[33m\\]main\\[\x1b[0m\\]"
911
        );
912
        assert_eq!(clean.format_colored("%b", ("", "")), "\x1b[36mmain\x1b[0m");
913
        assert_eq!(clean.format("%q"), "%q");
914
    }
915
}