OXYGENHUB
# 3604179679eb oxygen / crates / oxy-core / src / repo / prompt.rs
30.1 KiB · Rust · last changed Status with work in progress from a status cache: ahead of Git on the Linux tree (refs #41) 1 month ago source blame history raw
135212de Andy McCall ↶ 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 {
694805cc Andy McCall ↶ 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 {
75167672 Andy McCall 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
        )
694805cc Andy McCall ↶ 62
    }
63
135212de Andy McCall ↶ 64
    /// Expand `%b` bookmark, `%c` change id, `%h` heads, `%d` dirty marker
65
    /// (`*`), `%x` conflict marker (`!`), `%a`/`%B` ahead/behind counts
694805cc Andy McCall ↶ 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.
135212de Andy McCall ↶ 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
                }
694805cc Andy McCall ↶ 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),
135212de Andy McCall ↶ 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.
b7f102fc Andy McCall ↶ 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,
1966f5db Andy McCall ↶ 134
    pub hash: [u8; 32],
b7f102fc Andy McCall ↶ 135
    pub blob: Option<ObjectRef>,
cc7a0056 Andy McCall ↶ 136
    /// The graph key (`State::graph_key`) of the base file this text file
137
    /// was found equal to, rendered, byte for byte.
1966f5db Andy McCall ↶ 138
    pub graph: Option<[u8; 32]>,
da868d5d Andy McCall ↶ 139
    /// Whether the file was executable: a mode change alters neither size
140
    /// nor time.
141
    pub exec: bool,
b7f102fc Andy McCall ↶ 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
}
135212de Andy McCall ↶ 150
d0990c2b Andy McCall ↶ 151
/// A parsed manifest and the identity (size, time, inode) of the file it
152
/// came from: reused while the file is the same one.
153
pub(crate) type CachedManifest = ((u64, u128, u64), (bool, bool, std::sync::Arc<Manifest>));
154
b7f102fc Andy McCall ↶ 155
pub(crate) fn mtime_ns(m: &fs::Metadata) -> u128 {
75167672 Andy McCall 156
    m.modified()
157
        .ok()
158
        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
159
        .map(|d| d.as_nanos())
160
        .unwrap_or(0)
b7f102fc Andy McCall ↶ 161
}
162
135212de Andy McCall ↶ 163
pub const DEFAULT_PROMPT_FORMAT: &str = "%b %c%d%x%A%D";
694805cc Andy McCall ↶ 164
/// The repository icon for `%i` (an atom, for oxygen).
165
pub const ICON: &str = "⚛";
166
/// A format with glyphs: icon, bookmark, change id, state, ahead/behind.
167
pub const GLYPH_PROMPT_FORMAT: &str = "%i %b %c %s%U%O";
135212de Andy McCall ↶ 168
1966f5db Andy McCall ↶ 169
/// The manifest file (oxygen#41): a flat binary layout read in one pass.
170
/// A magic number; the conflicted and uncommitted flags; when it was
171
/// written, at a fixed offset so an otherwise equal manifest can be
f9466021 Andy McCall ↶ 172
/// recognised; BLAKE3 of everything else; then each entry in path order:
173
/// path, size, mtime in nanoseconds, BLAKE3 of the content, the stored
174
/// blob of an opaque file, the graph key it was found equal to, and
175
/// whether it is executable. A file that does not check, or in any other
176
/// layout (an older manifest), reads as no manifest, and the next snapshot
177
/// writes it again: it is a cache, written without waiting for the disk.
178
const MANIFEST_MAGIC: &[u8; 8] = b"oxyman2\0";
1966f5db Andy McCall ↶ 179
/// Where the written time sits: after the magic and the two flags.
180
const MANIFEST_WRITTEN: std::ops::Range<usize> = 10..26;
f9466021 Andy McCall ↶ 181
/// Where the checksum sits, after the written time.
182
const MANIFEST_SUM: std::ops::Range<usize> = 26..58;
1966f5db Andy McCall ↶ 183
f9466021 Andy McCall ↶ 184
fn manifest_sum(buf: &[u8]) -> [u8; 32] {
185
    let mut h = blake3::Hasher::new();
186
    h.update(&buf[..MANIFEST_SUM.start]);
187
    h.update_rayon(&buf[MANIFEST_SUM.end..]);
188
    *h.finalize().as_bytes()
189
}
190
1966f5db Andy McCall ↶ 191
fn encode_manifest(
192
    conflicted: bool,
193
    uncommitted: bool,
194
    written_ns: u128,
195
    entries: &mut dyn Iterator<Item = (&str, ManifestEntry)>,
196
) -> Vec<u8> {
197
    let mut w = crate::flat::Writer::default();
198
    w.fixed(MANIFEST_MAGIC);
199
    w.u8(conflicted as u8);
200
    w.u8(uncommitted as u8);
201
    w.fixed(&written_ns.to_le_bytes());
f9466021 Andy McCall ↶ 202
    w.fixed(&[0; 32]);
1966f5db Andy McCall ↶ 203
    for (path, e) in entries {
204
        w.bytes(path.as_bytes());
205
        w.u64(e.size);
206
        w.fixed(&e.mtime_ns.to_le_bytes());
207
        w.fixed(&e.hash);
208
        w.opt_object(&e.blob);
209
        match e.graph {
210
            None => w.u8(0),
211
            Some(k) => {
212
                w.u8(1);
213
                w.fixed(&k);
214
            }
215
        }
216
        w.u8(e.exec as u8);
217
    }
f9466021 Andy McCall ↶ 218
    let sum = manifest_sum(&w.0);
219
    w.0[MANIFEST_SUM].copy_from_slice(&sum);
1966f5db Andy McCall ↶ 220
    w.0
135212de Andy McCall ↶ 221
}
222
1966f5db Andy McCall ↶ 223
fn decode_manifest(buf: &[u8]) -> Option<(bool, bool, Manifest)> {
224
    let mut r = crate::flat::Reader::new(buf);
225
    if &r.fixed::<8>().ok()? != MANIFEST_MAGIC {
226
        return None;
227
    }
228
    let conflicted = r.u8().ok()? == 1;
229
    let uncommitted = r.u8().ok()? == 1;
230
    let written_ns = u128::from_le_bytes(r.fixed().ok()?);
f9466021 Andy McCall ↶ 231
    if r.fixed::<32>().ok()? != manifest_sum(buf) {
232
        return None;
233
    }
1966f5db Andy McCall ↶ 234
    let mut entries = Vec::new();
235
    while !r.at_end() {
236
        let path = std::str::from_utf8(r.bytes().ok()?).ok()?.to_string();
237
        let e = ManifestEntry {
238
            size: r.u64().ok()?,
239
            mtime_ns: u128::from_le_bytes(r.fixed().ok()?),
240
            hash: r.fixed().ok()?,
241
            blob: r.opt_object().ok()?,
242
            graph: match r.u8().ok()? {
243
                0 => None,
244
                _ => Some(r.fixed().ok()?),
245
            },
246
            exec: r.u8().ok()? == 1,
247
        };
248
        entries.push((path, e));
249
    }
250
    // Written in path order, so this builds the map without searching.
251
    let entries = entries.into_iter().collect();
252
    Some((
253
        conflicted,
254
        uncommitted,
255
        Manifest {
256
            written_ns,
257
            entries,
258
        },
259
    ))
260
}
261
36041796 Andy McCall ↶ 262
/// The status cache: a magic number, the working-copy change, the
263
/// manifest checksum, then each status line (a status byte and a path).
264
const STATUS_MAGIC: &[u8; 8] = b"oxystat1";
265
266
fn status_byte(s: ChangeStatus) -> u8 {
267
    match s {
268
        ChangeStatus::Added => 0,
269
        ChangeStatus::Modified => 1,
270
        ChangeStatus::Removed => 2,
271
        ChangeStatus::Renamed => 3,
272
        ChangeStatus::ModeChanged => 4,
273
        ChangeStatus::ConflictEdited => 5,
274
        ChangeStatus::Resolved => 6,
275
    }
276
}
277
278
fn encode_status_cache(wc: &ChangeHash, stamp: &[u8; 32], statuses: &[PathStatus]) -> Vec<u8> {
279
    let mut out = Vec::with_capacity(80 + statuses.len() * 40);
280
    out.extend_from_slice(STATUS_MAGIC);
281
    out.extend_from_slice(&wc.0);
282
    out.extend_from_slice(stamp);
283
    out.extend_from_slice(&(statuses.len() as u32).to_le_bytes());
284
    for s in statuses {
285
        out.push(status_byte(s.status));
286
        out.extend_from_slice(&(s.path.len() as u32).to_le_bytes());
287
        out.extend_from_slice(s.path.as_bytes());
288
    }
289
    out
290
}
291
292
fn decode_status_cache(b: &[u8]) -> Option<(ChangeHash, [u8; 32], Vec<PathStatus>)> {
293
    let rest = b.strip_prefix(STATUS_MAGIC)?;
294
    let wc = ObjectRef(rest.get(..32)?.try_into().ok()?);
295
    let stamp: [u8; 32] = rest.get(32..64)?.try_into().ok()?;
296
    let n = u32::from_le_bytes(rest.get(64..68)?.try_into().ok()?) as usize;
297
    let mut at = 68;
298
    let mut out = Vec::with_capacity(n.min(1 << 16));
299
    for _ in 0..n {
300
        let status = match *rest.get(at)? {
301
            0 => ChangeStatus::Added,
302
            1 => ChangeStatus::Modified,
303
            2 => ChangeStatus::Removed,
304
            3 => ChangeStatus::Renamed,
305
            4 => ChangeStatus::ModeChanged,
306
            5 => ChangeStatus::ConflictEdited,
307
            6 => ChangeStatus::Resolved,
308
            _ => return None,
309
        };
310
        let len = u32::from_le_bytes(rest.get(at + 1..at + 5)?.try_into().ok()?) as usize;
311
        let path = std::str::from_utf8(rest.get(at + 5..at + 5 + len)?)
312
            .ok()?
313
            .to_string();
314
        out.push(PathStatus { path, status });
315
        at += 5 + len;
316
    }
317
    (at == rest.len()).then_some((wc, stamp, out))
318
}
319
135212de Andy McCall ↶ 320
impl Repo {
dfb23a3a Andy McCall ↶ 321
    pub(crate) fn manifest_path(&self) -> PathBuf {
75167672 Andy McCall 322
        self.oxy
323
            .join("workspaces")
324
            .join(self.workspace.to_hex())
325
            .join("manifest")
135212de Andy McCall ↶ 326
    }
327
328
    /// Record the working copy as it is now (after a snapshot, commit, or
329
    /// checkout), with header lines saying whether the base state is
330
    /// conflicted and whether the working-copy change holds uncommitted work.
331
    pub(crate) fn write_manifest(&self, conflicted: bool, uncommitted: bool) -> Result<()> {
b7f102fc Andy McCall ↶ 332
        let entries = self.walk_working_copy()?;
333
        self.write_manifest_entries(conflicted, uncommitted, &entries, &HashMap::new())
334
    }
335
336
    /// Write the manifest from entries already walked; `blobs` names the
337
    /// stored blob of each opaque path so the next walk can skip reading it.
75167672 Andy McCall 338
    pub(crate) fn write_manifest_entries(
339
        &self,
340
        conflicted: bool,
341
        uncommitted: bool,
342
        entries: &BTreeMap<String, FsEntry>,
343
        blobs: &HashMap<String, ObjectRef>,
344
    ) -> Result<()> {
d0990c2b Andy McCall ↶ 345
        self.manifest_cache.take();
76740263 Andy McCall ↶ 346
        self.listing_cache.take();
75167672 Andy McCall 347
        let written = std::time::SystemTime::now()
348
            .duration_since(UNIX_EPOCH)
349
            .map(|d| d.as_nanos())
350
            .unwrap_or(0);
dfb23a3a Andy McCall ↶ 351
        let mut newest_ns: u128 = 0;
1966f5db Andy McCall ↶ 352
        let mut rows = entries.iter().map(|(path, e)| {
0554aaa7 Andy McCall ↶ 353
            let (size, mtime_ns) = match e.stat {
354
                Some(known) => known,
1966f5db Andy McCall ↶ 355
                None => match fs::symlink_metadata(self.root.join(path)) {
0554aaa7 Andy McCall ↶ 356
                    Ok(m) => (m.len(), mtime_ns(&m)),
357
                    Err(_) => (e.content.len() as u64, 0),
358
                },
135212de Andy McCall ↶ 359
            };
dfb23a3a Andy McCall ↶ 360
            newest_ns = newest_ns.max(mtime_ns);
1966f5db Andy McCall ↶ 361
            let entry = ManifestEntry {
cc7a0056 Andy McCall ↶ 362
                size,
363
                mtime_ns,
1966f5db Andy McCall ↶ 364
                hash: e
365
                    .content_hash
366
                    .unwrap_or_else(|| *blake3::hash(&e.content).as_bytes()),
367
                blob: blobs.get(path).copied().or(e.blob),
368
                graph: e.graph_key,
369
                exec: e.exec,
370
            };
371
            (path.as_str(), entry)
372
        });
373
        let new = encode_manifest(conflicted, uncommitted, written, &mut rows);
374
        drop(rows);
135212de Andy McCall ↶ 375
        let p = self.manifest_path();
dfb23a3a Andy McCall ↶ 376
        // Leave an unchanged manifest alone when its stamp already trusts
377
        // every entry (none is within the racy window): rewriting it would
378
        // change nothing but the file, which editors watching the tree
379
        // (and subrepos inside it) take as a change.
1966f5db Andy McCall ↶ 380
        if let Ok(old) = fs::read(&p) {
f9466021 Andy McCall ↶ 381
            let (w, sum) = (MANIFEST_WRITTEN, MANIFEST_SUM);
1966f5db Andy McCall ↶ 382
            if old.len() == new.len()
383
                && old[..w.start] == new[..w.start]
f9466021 Andy McCall ↶ 384
                && old[sum.end..] == new[sum.end..]
1966f5db Andy McCall ↶ 385
            {
386
                let old_written = u128::from_le_bytes(old[w.clone()].try_into().unwrap());
387
                if newest_ns + 2_000_000_000 < old_written {
dfb23a3a Andy McCall ↶ 388
                    return Ok(());
389
                }
390
            }
391
        }
135212de Andy McCall ↶ 392
        fs::create_dir_all(p.parent().unwrap())?;
f9466021 Andy McCall ↶ 393
        write_cache(&p, &new)
135212de Andy McCall ↶ 394
    }
395
86e1824f Andy McCall ↶ 396
    /// Change the header flags, and set the graph key of each path in
397
    /// `keys` (after a commit, the new base's keys of the files it holds as
398
    /// they are on disk).
399
    pub(crate) fn rewrite_manifest(
400
        &self,
401
        conflicted: bool,
402
        uncommitted: bool,
1966f5db Andy McCall ↶ 403
        keys: &HashMap<String, [u8; 32]>,
86e1824f Andy McCall ↶ 404
    ) -> Result<()> {
d0990c2b Andy McCall ↶ 405
        self.manifest_cache.take();
76740263 Andy McCall ↶ 406
        self.listing_cache.take();
b7f102fc Andy McCall ↶ 407
        let p = self.manifest_path();
1966f5db Andy McCall ↶ 408
        let Some((_, _, m)) = fs::read(&p).ok().and_then(|b| decode_manifest(&b)) else {
409
            return self.write_manifest(conflicted, uncommitted);
b7f102fc Andy McCall ↶ 410
        };
1966f5db Andy McCall ↶ 411
        let mut rows = m.entries.iter().map(|(path, e)| {
412
            let mut e = e.clone();
413
            if let Some(k) = keys.get(path) {
414
                e.graph = Some(*k);
b7f102fc Andy McCall ↶ 415
            }
1966f5db Andy McCall ↶ 416
            (path.as_str(), e)
417
        });
f9466021 Andy McCall ↶ 418
        write_cache(
1966f5db Andy McCall ↶ 419
            &p,
420
            &encode_manifest(conflicted, uncommitted, m.written_ns, &mut rows),
421
        )
b7f102fc Andy McCall ↶ 422
    }
423
135212de Andy McCall ↶ 424
    /// Forget the manifest after an operation that changed the working-copy
425
    /// change without a snapshot (undo, rewrites); the prompt shows blank
426
    /// markers until the next snapshot.
427
    pub(crate) fn drop_manifest(&self) {
d0990c2b Andy McCall ↶ 428
        self.manifest_cache.take();
76740263 Andy McCall ↶ 429
        self.listing_cache.take();
135212de Andy McCall ↶ 430
        let _ = fs::remove_file(self.manifest_path());
d0990c2b Andy McCall ↶ 431
    }
432
433
    /// The manifest, with whether it records a conflict and uncommitted
434
    /// work; parsed once while the file stays the same one.
435
    pub(crate) fn read_manifest(&self) -> Result<Option<(bool, bool, std::sync::Arc<Manifest>)>> {
436
        let path = self.manifest_path();
437
        let id = match fs::metadata(&path) {
438
            Ok(m) => {
439
                #[cfg(unix)]
440
                let ino = std::os::unix::fs::MetadataExt::ino(&m);
441
                #[cfg(not(unix))]
442
                let ino = 0;
443
                (m.len(), mtime_ns(&m), ino)
444
            }
445
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
446
            Err(e) => return Err(e.into()),
447
        };
448
        if let Some((cached_id, m)) = &*self.manifest_cache.borrow() {
449
            if *cached_id == id {
450
                return Ok(Some(m.clone()));
451
            }
452
        }
453
        let Some((c, u, m)) = self.parse_manifest(&path)? else {
454
            return Ok(None);
455
        };
456
        let m = (c, u, std::sync::Arc::new(m));
457
        *self.manifest_cache.borrow_mut() = Some((id, m.clone()));
458
        Ok(Some(m))
135212de Andy McCall ↶ 459
    }
460
d0990c2b Andy McCall ↶ 461
    fn parse_manifest(&self, path: &std::path::Path) -> Result<Option<(bool, bool, Manifest)>> {
1966f5db Andy McCall ↶ 462
        let buf = match fs::read(path) {
463
            Ok(b) => b,
135212de Andy McCall ↶ 464
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
465
            Err(e) => return Err(e.into()),
466
        };
1966f5db Andy McCall ↶ 467
        Ok(decode_manifest(&buf))
135212de Andy McCall ↶ 468
    }
469
da868d5d Andy McCall ↶ 470
    /// Whether the working copy is certainly clean, known from the manifest
471
    /// alone, without loading any history (spec §12.4): the last snapshot
d3cd7ef9 Andy McCall ↶ 472
    /// or commit found no uncommitted work and no conflict, there are no
473
    /// subrepos (whose state the manifest does not hold), and no file was
474
    /// added, removed or changed since. An entry inside the racy window,
475
    /// where an equal size and time do not prove equal content, is checked
476
    /// by hashing the file. `false` means only that a snapshot must decide.
da868d5d Andy McCall ↶ 477
    pub fn clean_by_manifest(&self) -> Result<bool> {
478
        let _t = crate::timing::phase("status: check the manifest");
f9466021 Andy McCall ↶ 479
        // Why a snapshot must decide, with timings on.
480
        let not = |why: &str| {
481
            if crate::timing::enabled() {
482
                eprintln!("timing status: not clean by the manifest: {why}");
483
            }
484
            Ok(false)
485
        };
da868d5d Andy McCall ↶ 486
        let Some((conflicted, uncommitted, m)) = self.read_manifest()? else {
f9466021 Andy McCall ↶ 487
            return not("no manifest");
da868d5d Andy McCall ↶ 488
        };
489
        if conflicted || uncommitted || self.root.join(".oxymodules").exists() {
f9466021 Andy McCall ↶ 490
            return not("conflicted, uncommitted or subrepos");
da868d5d Andy McCall ↶ 491
        }
36041796 Andy McCall ↶ 492
        if let Some(rel) = self.racy_change(&m) {
493
            return not(&format!("{rel} changed within the racy window"));
494
        }
495
        if self.disk_differs(&m, true)? {
496
            return not("a file differs");
497
        }
498
        Ok(true)
499
    }
500
501
    /// A manifest entry written within the racy window whose file no
502
    /// longer matches it. Such entries are hashed, in parallel (a checkout
503
    /// just wrote them all).
504
    fn racy_change(&self, m: &Manifest) -> Option<String> {
638fb214 Andy McCall ↶ 505
        use rayon::prelude::*;
506
        let racy: Vec<(&String, &ManifestEntry)> = m
507
            .entries
508
            .iter()
509
            .filter(|(_, e)| e.mtime_ns + 2_000_000_000 >= m.written_ns)
510
            .collect();
511
        let root = &self.root;
36041796 Andy McCall ↶ 512
        racy.par_iter()
513
            .find_any(|(rel, e)| {
514
                let path = root.join(rel.as_str());
515
                let same = match fs::symlink_metadata(&path) {
516
                    Ok(md) if md.file_type().is_symlink() => fs::read_link(&path).is_ok_and(|t| {
517
                        *blake3::hash(t.to_string_lossy().as_bytes()).as_bytes() == e.hash
518
                    }),
519
                    Ok(md) if md.is_file() => {
520
                        md.len() == e.size
521
                            && fs::read(&path).is_ok_and(|b| *blake3::hash(&b).as_bytes() == e.hash)
522
                    }
523
                    _ => false,
524
                };
525
                !same
526
            })
527
            .map(|(rel, _)| rel.to_string())
528
    }
529
530
    /// The status lines of the last snapshot, when the working copy is as
531
    /// that snapshot left it: the cache it wrote names the working-copy
532
    /// change and the manifest it recorded, and every file still matches
533
    /// that manifest. `None` when a snapshot must decide (no cache, a file
534
    /// changed, conflicts or subrepos). An empty list is a clean working
535
    /// copy.
536
    pub fn status_by_manifest(&self) -> Result<Option<Vec<PathStatus>>> {
537
        // A listing left by an earlier check describes the files as they
538
        // were then; this check makes its own.
539
        self.listing_cache.take();
540
        let Some((conflicted, uncommitted, m)) = self.read_manifest()? else {
541
            return Ok(None);
542
        };
543
        if !uncommitted {
544
            return Ok(self.clean_by_manifest()?.then(Vec::new));
f9466021 Andy McCall ↶ 545
        }
36041796 Andy McCall ↶ 546
        let _t = crate::timing::phase("status: by the status cache");
547
        if conflicted || self.root.join(".oxymodules").exists() {
548
            return Ok(None);
549
        }
550
        let Some((wc, stamp, statuses)) = fs::read(self.status_cache_path())
551
            .ok()
552
            .and_then(|b| decode_status_cache(&b))
553
        else {
554
            return Ok(None);
555
        };
556
        let now = self.view()?.workspaces.get(&self.workspace).copied();
557
        if now != Some(wc) || self.manifest_stamp() != Some(stamp) {
558
            return Ok(None);
559
        }
560
        if self.racy_change(&m).is_some() || self.disk_differs(&m, true)? {
561
            return Ok(None);
562
        }
563
        // Test builds check the cache against a snapshot of the files.
564
        #[cfg(test)]
565
        {
566
            let snap = self.snapshot_status("status cache check")?;
567
            let key = |v: &[PathStatus]| {
568
                let mut k: Vec<(String, u8)> = v
569
                    .iter()
570
                    .map(|p| (p.path.clone(), status_byte(p.status)))
571
                    .collect();
572
                k.sort();
573
                k
574
            };
575
            assert_eq!(
576
                key(&statuses),
577
                key(&snap.statuses),
578
                "status cache disagrees with a snapshot"
579
            );
da868d5d Andy McCall ↶ 580
        }
36041796 Andy McCall ↶ 581
        Ok(Some(statuses))
582
    }
583
584
    fn status_cache_path(&self) -> PathBuf {
585
        self.manifest_path().with_file_name("status")
586
    }
587
588
    /// The manifest file's checksum, which names its content.
589
    fn manifest_stamp(&self) -> Option<[u8; 32]> {
590
        use std::io::Read;
591
        let mut head = [0u8; MANIFEST_SUM.end];
592
        fs::File::open(self.manifest_path())
593
            .and_then(|mut f| f.read_exact(&mut head))
594
            .ok()?;
595
        head[MANIFEST_SUM].try_into().ok()
da868d5d Andy McCall ↶ 596
    }
597
36041796 Andy McCall ↶ 598
    /// Record `statuses` as the status of working-copy change `wc` against
599
    /// the manifest as it is now; with no uncommitted work there is nothing
600
    /// to record, and the cache goes.
601
    pub(crate) fn write_status_cache(&self, wc: Option<&ChangeHash>, statuses: &[PathStatus]) {
602
        let path = self.status_cache_path();
603
        let (Some(wc), Some(stamp)) = (wc, self.manifest_stamp()) else {
604
            let _ = fs::remove_file(&path);
605
            return;
606
        };
607
        let _ = write_cache(&path, &encode_status_cache(wc, &stamp, statuses));
608
    }
609
135212de Andy McCall ↶ 610
    /// Whether the working copy differs from the last manifest. Without a
611
    /// manifest the answer is `None`.
612
    pub fn quick_dirty(&self) -> Result<Option<bool>> {
76740263 Andy McCall ↶ 613
        self.quick_dirty_keeping(false)
614
    }
615
616
    /// `quick_dirty`; with `keep`, the listing it made is left for the
617
    /// next walk of the working copy.
618
    fn quick_dirty_keeping(&self, keep: bool) -> Result<Option<bool>> {
75167672 Andy McCall 619
        let Some((_, uncommitted, manifest)) = self.read_manifest()? else {
620
            return Ok(None);
621
        };
135212de Andy McCall ↶ 622
        if uncommitted {
623
            return Ok(Some(true));
624
        }
36041796 Andy McCall ↶ 625
        Ok(Some(self.disk_differs(&manifest, keep)?))
626
    }
627
628
    /// Whether the working copy differs from `manifest`: a file added,
629
    /// gone or changed. With `keep`, the listing made is left for the next
630
    /// walk of the working copy when it does.
631
    fn disk_differs(&self, manifest: &Manifest, keep: bool) -> Result<bool> {
76740263 Andy McCall ↶ 632
        use rayon::prelude::*;
633
        let sparse = &self.config.sparse_paths;
eaf3e906 Andy McCall ↶ 634
        let in_scope = |p: &str| {
635
            sparse.is_empty()
636
                || sparse
637
                    .iter()
638
                    .any(|s| p == s || p.starts_with(&format!("{s}/")))
76740263 Andy McCall ↶ 639
        };
640
        // A working copy that cannot be listed counts as changed.
d4dbab97 Andy McCall ↶ 641
        // Known files are not matched against the ignore rules: with no
642
        // uncommitted work (the clean check), the manifest's files are the
643
        // base's, and a prompt's answer is the same either way.
644
        let known = |p: &str| manifest.entries.contains_key(p);
645
        let Ok(listing) = super::walk::list_knowing(&self.root, &in_scope, &known) else {
36041796 Andy McCall ↶ 646
            return Ok(true);
eaf3e906 Andy McCall ↶ 647
        };
76740263 Andy McCall ↶ 648
        let differs = || -> Result<bool> {
649
            let links_differ = listing.symlinks.iter().any(|(rel, target)| {
650
                manifest
651
                    .entries
652
                    .get(rel)
1966f5db Andy McCall ↶ 653
                    .is_none_or(|me| *blake3::hash(target).as_bytes() != me.hash)
76740263 Andy McCall ↶ 654
            });
655
            if links_differ {
656
                return Ok(true);
657
            }
658
            // Each file is checked on its own, in parallel, stopping at the
659
            // first difference; an unreadable one counts as different.
660
            let files_differ = listing.files.par_iter().any(|(rel, path, m)| {
661
                manifest.entries.get(rel).is_none_or(|me| {
662
                    m.len() != me.size
663
                        || super::is_executable(m) != me.exec
664
                        || (mtime_ns(m) != me.mtime_ns
665
                            && !fs::read(path)
1966f5db Andy McCall ↶ 666
                                .is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash))
76740263 Andy McCall ↶ 667
                })
668
            });
669
            if files_differ {
670
                return Ok(true);
671
            }
672
            let seen = listing.files.len() + listing.symlinks.len();
673
            if seen == manifest.entries.len() {
674
                return Ok(false);
675
            }
d4dbab97 Andy McCall ↶ 676
            // Manifest entries the walk skipped: tracked files that ignore
677
            // rules hide (Linux tracks some its own .gitignore names). Found
678
            // by merging the two sorted lists; they count as present when
679
            // still on disk and unchanged, checked on every core.
680
            let mut listed: Vec<&str> = listing
681
                .files
682
                .iter()
683
                .map(|(r, _, _)| r.as_str())
684
                .chain(listing.symlinks.iter().map(|(r, _)| r.as_str()))
685
                .collect();
686
            listed.sort_unstable();
687
            let mut at = listed.iter().peekable();
688
            let skipped: Vec<(&String, &ManifestEntry)> = manifest
689
                .entries
690
                .iter()
691
                .filter(|(rel, _)| {
692
                    while at.next_if(|l| **l < rel.as_str()).is_some() {}
693
                    at.peek().is_none_or(|l| **l != rel.as_str())
694
                })
695
                .collect();
696
            let root = &self.root;
697
            Ok(skipped.par_iter().any(|(rel, me)| {
698
                let path = root.join(rel.as_str());
76740263 Andy McCall ↶ 699
                let Ok(m) = fs::symlink_metadata(&path) else {
d4dbab97 Andy McCall ↶ 700
                    return true;
eaf3e906 Andy McCall ↶ 701
                };
d4dbab97 Andy McCall ↶ 702
                !m.file_type().is_symlink()
703
                    && (m.len() != me.size
704
                        || super::is_executable(&m) != me.exec
705
                        || (mtime_ns(&m) != me.mtime_ns
706
                            && !fs::read(&path)
707
                                .is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash)))
708
            }))
76740263 Andy McCall ↶ 709
        };
710
        let dirty = differs()?;
d4dbab97 Andy McCall ↶ 711
        if !dirty {
712
            // Nested repositories the manifest has no entry for are not
713
            // subrepos: nothing in them is tracked (oxygen#43).
714
            *self.untracked_nested.borrow_mut() = listing
715
                .nested
716
                .iter()
717
                .map(|d| self.rel_path(d))
718
                .filter(|rel| !manifest.entries.contains_key(rel))
719
                .collect();
720
        }
76740263 Andy McCall ↶ 721
        if dirty && keep {
722
            *self.listing_cache.borrow_mut() = Some(listing);
9ac1fbe2 Andy McCall ↶ 723
        }
36041796 Andy McCall ↶ 724
        Ok(dirty)
135212de Andy McCall ↶ 725
    }
726
727
    /// Prompt information without recording anything.
728
    pub fn quick_status(&self) -> Result<PromptInfo> {
729
        let view = self.view()?;
730
        let bookmark = self.config.bookmark.clone();
731
        let heads = self.heads_of(&view, &bookmark)?;
732
        let wc_hash = view.workspaces.get(&self.workspace).copied();
733
        let change_id = match wc_hash {
69816d68 Andy McCall ↶ 734
            Some(h) => self.store.get_change_meta(&h)?.change_id.short(8),
135212de Andy McCall ↶ 735
            None => String::new(),
736
        };
737
        // Conflictedness was recorded by the last snapshot; without a
738
        // manifest (never snapshotted) both flags are unknown and shown clear.
739
        let manifest = self.read_manifest()?;
740
        let conflicted = manifest.as_ref().map(|(c, _, _)| *c).unwrap_or(false);
741
        let dirty = self.quick_dirty()?.unwrap_or(false);
742
        let (ahead, behind) = match view.remotes.get("origin").and_then(|b| b.get(&bookmark)) {
743
            Some(st) => {
744
                let remote_heads = self.store.get_state(st)?.heads;
745
                let ahead = self.negotiate(&remote_heads, &heads).map(|v| v.len()).ok();
746
                let behind = self.negotiate(&heads, &remote_heads).map(|v| v.len()).ok();
747
                (ahead, behind)
748
            }
749
            None => (None, None),
750
        };
75167672 Andy McCall 751
        Ok(PromptInfo {
752
            bookmark,
753
            change_id,
754
            heads: heads.len(),
755
            dirty,
756
            conflicted,
757
            ahead,
758
            behind,
759
        })
135212de Andy McCall ↶ 760
    }
761
}
694805cc Andy McCall ↶ 762
763
#[cfg(test)]
764
mod tests {
765
    use super::*;
766
767
    #[test]
768
    fn placeholders_and_glyphs() {
75167672 Andy McCall 769
        let clean = PromptInfo {
770
            bookmark: "main".into(),
771
            change_id: "658990aa".into(),
772
            heads: 1,
773
            dirty: false,
774
            conflicted: false,
775
            ahead: Some(0),
776
            behind: None,
777
        };
694805cc Andy McCall ↶ 778
        assert_eq!(clean.format(DEFAULT_PROMPT_FORMAT), "main 658990aa");
779
        assert_eq!(clean.format(GLYPH_PROMPT_FORMAT), "⚛ main 658990aa ✔");
75167672 Andy McCall 780
        let busy = PromptInfo {
781
            dirty: true,
782
            ahead: Some(2),
783
            behind: Some(3),
784
            ..clean.clone()
785
        };
694805cc Andy McCall ↶ 786
        assert_eq!(busy.format(DEFAULT_PROMPT_FORMAT), "main 658990aa*+2-3");
787
        assert_eq!(busy.format(GLYPH_PROMPT_FORMAT), "⚛ main 658990aa ✎↑2↓3");
75167672 Andy McCall 788
        let bad = PromptInfo {
789
            conflicted: true,
790
            ..busy.clone()
791
        };
694805cc Andy McCall ↶ 792
        assert_eq!(bad.format("%s%x%d %h %a/%B %%"), "‼!* 1 2/3 %");
793
        assert_eq!(bad.state_color(), 31);
75167672 Andy McCall 794
        assert_eq!(
795
            busy.format_colored("%b", ("\\[", "\\]")),
796
            "\\[\x1b[33m\\]main\\[\x1b[0m\\]"
797
        );
694805cc Andy McCall ↶ 798
        assert_eq!(clean.format_colored("%b", ("", "")), "\x1b[36mmain\x1b[0m");
799
        assert_eq!(clean.format("%q"), "%q");
800
    }
801
}