OXYGENHUB
# f97d3556db5f oxygen / crates / oxy-core / src / repo / prompt.rs
15.8 KiB · Rust · last changed Unbound hosted repositories adopt the first pusher's id 1 month ago source blame history raw
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: String,
135
    pub blob: Option<ObjectRef>,
136
}
137
138
#[derive(Clone, Debug, Default)]
139
pub(crate) struct Manifest {
140
    /// When the manifest was written, for the racy-stat guard.
141
    pub written_ns: u128,
142
    pub entries: BTreeMap<String, ManifestEntry>,
143
}
144
145
pub(crate) fn mtime_ns(m: &fs::Metadata) -> u128 {
146
    m.modified()
147
        .ok()
148
        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
149
        .map(|d| d.as_nanos())
150
        .unwrap_or(0)
151
}
152
153
pub const DEFAULT_PROMPT_FORMAT: &str = "%b %c%d%x%A%D";
154
/// The repository icon for `%i` (an atom, for oxygen).
155
pub const ICON: &str = "⚛";
156
/// A format with glyphs: icon, bookmark, change id, state, ahead/behind.
157
pub const GLYPH_PROMPT_FORMAT: &str = "%i %b %c %s%U%O";
158
159
/// One manifest line per file: size, mtime in nanoseconds, hash, blob ref
160
/// or `-`, path.
161
fn manifest_line(
162
    path: &str,
163
    size: u64,
164
    mtime_ns: u128,
165
    hash: &str,
166
    blob: Option<ObjectRef>,
167
) -> String {
168
    format!(
169
        "{size}\t{mtime_ns}\t{hash}\t{}\t{path}\n",
170
        blob.map(|b| b.to_hex()).unwrap_or_else(|| "-".into())
171
    )
172
}
173
174
impl Repo {
175
    fn manifest_path(&self) -> PathBuf {
176
        self.oxy
177
            .join("workspaces")
178
            .join(self.workspace.to_hex())
179
            .join("manifest")
180
    }
181
182
    /// Record the working copy as it is now (after a snapshot, commit, or
183
    /// checkout), with header lines saying whether the base state is
184
    /// conflicted and whether the working-copy change holds uncommitted work.
185
    pub(crate) fn write_manifest(&self, conflicted: bool, uncommitted: bool) -> Result<()> {
186
        let entries = self.walk_working_copy()?;
187
        self.write_manifest_entries(conflicted, uncommitted, &entries, &HashMap::new())
188
    }
189
190
    /// Write the manifest from entries already walked; `blobs` names the
191
    /// stored blob of each opaque path so the next walk can skip reading it.
192
    pub(crate) fn write_manifest_entries(
193
        &self,
194
        conflicted: bool,
195
        uncommitted: bool,
196
        entries: &BTreeMap<String, FsEntry>,
197
        blobs: &HashMap<String, ObjectRef>,
198
    ) -> Result<()> {
199
        let written = std::time::SystemTime::now()
200
            .duration_since(UNIX_EPOCH)
201
            .map(|d| d.as_nanos())
202
            .unwrap_or(0);
203
        let mut text = format!(
204
            "# conflicted={}\n# uncommitted={}\n# written={written}\n",
205
            conflicted as u8, uncommitted as u8
206
        );
207
        for (path, e) in entries {
208
            let full = self.root.join(path);
209
            let (size, mtime_ns) = match fs::symlink_metadata(&full) {
210
                Ok(m) => (m.len(), mtime_ns(&m)),
211
                Err(_) => (e.content.len() as u64, 0),
212
            };
213
            let hash = match &e.content_hash {
214
                Some(h) => h.clone(),
215
                None => blake3::hash(&e.content).to_hex().to_string(),
216
            };
217
            let blob = blobs.get(path).copied().or(e.blob);
218
            text.push_str(&manifest_line(path, size, mtime_ns, &hash, blob));
219
        }
220
        let p = self.manifest_path();
221
        fs::create_dir_all(p.parent().unwrap())?;
222
        write_atomic(&p, text.as_bytes())
223
    }
224
225
    /// Change only the header flags of the current manifest (after a commit
226
    /// the files are as the snapshot left them).
227
    pub(crate) fn rewrite_manifest_flags(&self, conflicted: bool, uncommitted: bool) -> Result<()> {
228
        let p = self.manifest_path();
229
        let text = match fs::read_to_string(&p) {
230
            Ok(t) => t,
231
            Err(_) => return self.write_manifest(conflicted, uncommitted),
232
        };
233
        let mut out = String::with_capacity(text.len());
234
        for line in text.lines() {
235
            if line.starts_with("# conflicted=") {
236
                out.push_str(&format!("# conflicted={}\n", conflicted as u8));
237
            } else if line.starts_with("# uncommitted=") {
238
                out.push_str(&format!("# uncommitted={}\n", uncommitted as u8));
239
            } else {
240
                out.push_str(line);
241
                out.push('\n');
242
            }
243
        }
244
        write_atomic(&p, out.as_bytes())
245
    }
246
247
    /// Forget the manifest after an operation that changed the working-copy
248
    /// change without a snapshot (undo, rewrites); the prompt shows blank
249
    /// markers until the next snapshot.
250
    pub(crate) fn drop_manifest(&self) {
251
        let _ = fs::remove_file(self.manifest_path());
252
    }
253
254
    pub(crate) fn read_manifest(&self) -> Result<Option<(bool, bool, Manifest)>> {
255
        let text = match fs::read_to_string(self.manifest_path()) {
256
            Ok(t) => t,
257
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
258
            Err(e) => return Err(e.into()),
259
        };
260
        let mut out = Manifest::default();
261
        let mut conflicted = false;
262
        let mut uncommitted = false;
263
        for line in text.lines() {
264
            if let Some(v) = line.strip_prefix("# conflicted=") {
265
                conflicted = v.trim() == "1";
266
                continue;
267
            }
268
            if let Some(v) = line.strip_prefix("# uncommitted=") {
269
                uncommitted = v.trim() == "1";
270
                continue;
271
            }
272
            if let Some(v) = line.strip_prefix("# written=") {
273
                out.written_ns = v.trim().parse().unwrap_or(u128::MAX);
274
                continue;
275
            }
276
            let f: Vec<&str> = line.splitn(5, '\t').collect();
277
            if f.len() == 5 {
278
                out.entries.insert(
279
                    f[4].to_string(),
280
                    ManifestEntry {
281
                        size: f[0].parse().unwrap_or(0),
282
                        mtime_ns: f[1].parse().unwrap_or(0),
283
                        hash: f[2].to_string(),
284
                        blob: ObjectRef::from_hex(f[3]),
285
                    },
286
                );
287
            }
288
        }
289
        Ok(Some((conflicted, uncommitted, out)))
290
    }
291
292
    /// Whether the working copy differs from the last manifest. Without a
293
    /// manifest the answer is `None`.
294
    pub fn quick_dirty(&self) -> Result<Option<bool>> {
295
        let Some((_, uncommitted, manifest)) = self.read_manifest()? else {
296
            return Ok(None);
297
        };
298
        if uncommitted {
299
            return Ok(Some(true));
300
        }
301
        let mut seen = 0usize;
302
        let nested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
303
        let wb = self.walker(&nested);
304
        for entry in wb.build() {
305
            let Ok(entry) = entry else { continue };
306
            let Some(ft) = entry.file_type() else {
307
                continue;
308
            };
309
            if ft.is_dir() {
310
                continue;
311
            }
312
            let rel: String = entry
313
                .path()
314
                .strip_prefix(&self.root)
315
                .unwrap()
316
                .components()
317
                .map(|c| c.as_os_str().to_string_lossy().to_string())
318
                .collect::<Vec<_>>()
319
                .join("/");
320
            if !self.in_scope(&rel) {
321
                continue;
322
            }
323
            let Some(me) = manifest.entries.get(&rel) else {
324
                return Ok(Some(true));
325
            };
326
            seen += 1;
327
            let m = fs::symlink_metadata(entry.path())?;
328
            let now_mtime = mtime_ns(&m);
329
            if ft.is_symlink() {
330
                let target = fs::read_link(entry.path())?;
331
                if blake3::hash(target.to_string_lossy().as_bytes())
332
                    .to_hex()
333
                    .as_str()
334
                    != me.hash
335
                {
336
                    return Ok(Some(true));
337
                }
338
                continue;
339
            }
340
            if m.len() != me.size {
341
                return Ok(Some(true));
342
            }
343
            if now_mtime != me.mtime_ns
344
                && blake3::hash(&fs::read(entry.path())?).to_hex().as_str() != me.hash
345
            {
346
                return Ok(Some(true));
347
            }
348
        }
349
        if seen == manifest.entries.len() {
350
            return Ok(Some(false));
351
        }
352
        // Manifest entries the walk skipped: tracked files that ignore rules
353
        // hide. They count as present when still on disk and unchanged.
354
        for (rel, me) in &manifest.entries {
355
            let path = self.root.join(rel);
356
            let Ok(m) = fs::symlink_metadata(&path) else {
357
                return Ok(Some(true));
358
            };
359
            if m.file_type().is_symlink() {
360
                continue;
361
            }
362
            if m.len() != me.size
363
                || (mtime_ns(&m) != me.mtime_ns
364
                    && blake3::hash(&fs::read(&path)?).to_hex().as_str() != me.hash)
365
            {
366
                return Ok(Some(true));
367
            }
368
        }
369
        Ok(Some(false))
370
    }
371
372
    /// Prompt information without recording anything.
373
    pub fn quick_status(&self) -> Result<PromptInfo> {
374
        let view = self.view()?;
375
        let bookmark = self.config.bookmark.clone();
376
        let heads = self.heads_of(&view, &bookmark)?;
377
        let wc_hash = view.workspaces.get(&self.workspace).copied();
378
        let change_id = match wc_hash {
379
            Some(h) => self.store.get_change(&h)?.change_id.short(8),
380
            None => String::new(),
381
        };
382
        // Conflictedness was recorded by the last snapshot; without a
383
        // manifest (never snapshotted) both flags are unknown and shown clear.
384
        let manifest = self.read_manifest()?;
385
        let conflicted = manifest.as_ref().map(|(c, _, _)| *c).unwrap_or(false);
386
        let dirty = self.quick_dirty()?.unwrap_or(false);
387
        let (ahead, behind) = match view.remotes.get("origin").and_then(|b| b.get(&bookmark)) {
388
            Some(st) => {
389
                let remote_heads = self.store.get_state(st)?.heads;
390
                let ahead = self.negotiate(&remote_heads, &heads).map(|v| v.len()).ok();
391
                let behind = self.negotiate(&heads, &remote_heads).map(|v| v.len()).ok();
392
                (ahead, behind)
393
            }
394
            None => (None, None),
395
        };
396
        Ok(PromptInfo {
397
            bookmark,
398
            change_id,
399
            heads: heads.len(),
400
            dirty,
401
            conflicted,
402
            ahead,
403
            behind,
404
        })
405
    }
406
}
407
408
#[cfg(test)]
409
mod tests {
410
    use super::*;
411
412
    #[test]
413
    fn placeholders_and_glyphs() {
414
        let clean = PromptInfo {
415
            bookmark: "main".into(),
416
            change_id: "658990aa".into(),
417
            heads: 1,
418
            dirty: false,
419
            conflicted: false,
420
            ahead: Some(0),
421
            behind: None,
422
        };
423
        assert_eq!(clean.format(DEFAULT_PROMPT_FORMAT), "main 658990aa");
424
        assert_eq!(clean.format(GLYPH_PROMPT_FORMAT), "⚛ main 658990aa ✔");
425
        let busy = PromptInfo {
426
            dirty: true,
427
            ahead: Some(2),
428
            behind: Some(3),
429
            ..clean.clone()
430
        };
431
        assert_eq!(busy.format(DEFAULT_PROMPT_FORMAT), "main 658990aa*+2-3");
432
        assert_eq!(busy.format(GLYPH_PROMPT_FORMAT), "⚛ main 658990aa ✎↑2↓3");
433
        let bad = PromptInfo {
434
            conflicted: true,
435
            ..busy.clone()
436
        };
437
        assert_eq!(bad.format("%s%x%d %h %a/%B %%"), "‼!* 1 2/3 %");
438
        assert_eq!(bad.state_color(), 31);
439
        assert_eq!(
440
            busy.format_colored("%b", ("\\[", "\\]")),
441
            "\\[\x1b[33m\\]main\\[\x1b[0m\\]"
442
        );
443
        assert_eq!(clean.format_colored("%b", ("", "")), "\x1b[36mmain\x1b[0m");
444
        assert_eq!(clean.format("%q"), "%q");
445
    }
446
}