OXYGENHUB
# 694805cca585 oxygen / crates / oxy-core / src / repo / prompt.rs
14.1 KiB · Rust · last changed Prompt: --glyphs and --color modes with %s %U %O %i placeholders; oh-my-bash powerline segment in contrib/oh-my-bash/oxy.sh 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 {
53
        format!("{}\x1b[{}m{}{}{}\x1b[0m{}", guard.0, self.state_color(), guard.1, self.format(fmt), guard.0, guard.1)
54
    }
55
135212de Andy McCall ↶ 56
    /// Expand `%b` bookmark, `%c` change id, `%h` heads, `%d` dirty marker
57
    /// (`*`), `%x` conflict marker (`!`), `%a`/`%B` ahead/behind counts
694805cc Andy McCall ↶ 58
    /// (empty when unknown), `%A`/`%D` `+n`/`-n` when non-zero, `%s` the
59
    /// state glyph, `%U`/`%O` `↑n`/`↓n` when non-zero, `%i` the repository
60
    /// icon, `%%` percent.
135212de Andy McCall ↶ 61
    pub fn format(&self, fmt: &str) -> String {
62
        let mut out = String::new();
63
        let mut chars = fmt.chars();
64
        while let Some(c) = chars.next() {
65
            if c != '%' {
66
                out.push(c);
67
                continue;
68
            }
69
            match chars.next() {
70
                Some('b') => out.push_str(&self.bookmark),
71
                Some('c') => out.push_str(&self.change_id),
72
                Some('h') => out.push_str(&self.heads.to_string()),
73
                Some('d') => {
74
                    if self.dirty {
75
                        out.push('*');
76
                    }
77
                }
78
                Some('x') => {
79
                    if self.conflicted {
80
                        out.push('!');
81
                    }
82
                }
83
                Some('a') => out.push_str(&self.ahead.map(|n| n.to_string()).unwrap_or_default()),
84
                Some('B') => out.push_str(&self.behind.map(|n| n.to_string()).unwrap_or_default()),
85
                Some('A') => {
86
                    if let Some(n) = self.ahead.filter(|n| *n > 0) {
87
                        out.push_str(&format!("+{n}"));
88
                    }
89
                }
90
                Some('D') => {
91
                    if let Some(n) = self.behind.filter(|n| *n > 0) {
92
                        out.push_str(&format!("-{n}"));
93
                    }
94
                }
694805cc Andy McCall ↶ 95
                Some('s') => out.push_str(self.state_glyph()),
96
                Some('U') => {
97
                    if let Some(n) = self.ahead.filter(|n| *n > 0) {
98
                        out.push_str(&format!("↑{n}"));
99
                    }
100
                }
101
                Some('O') => {
102
                    if let Some(n) = self.behind.filter(|n| *n > 0) {
103
                        out.push_str(&format!("↓{n}"));
104
                    }
105
                }
106
                Some('i') => out.push_str(ICON),
135212de Andy McCall ↶ 107
                Some('%') => out.push('%'),
108
                Some(other) => {
109
                    out.push('%');
110
                    out.push(other);
111
                }
112
                None => out.push('%'),
113
            }
114
        }
115
        out
116
    }
117
}
118
119
/// Path → (size, mtime in nanoseconds, BLAKE3 hex) as last seen.
b7f102fc Andy McCall ↶ 120
/// One manifest entry: the file's stat, its content hash, and the stored
121
/// blob for an opaque file.
122
#[derive(Clone, Debug)]
123
pub(crate) struct ManifestEntry {
124
    pub size: u64,
125
    pub mtime_ns: u128,
126
    pub hash: String,
127
    pub blob: Option<ObjectRef>,
128
}
129
130
#[derive(Clone, Debug, Default)]
131
pub(crate) struct Manifest {
132
    /// When the manifest was written, for the racy-stat guard.
133
    pub written_ns: u128,
134
    pub entries: BTreeMap<String, ManifestEntry>,
135
}
135212de Andy McCall ↶ 136
b7f102fc Andy McCall ↶ 137
pub(crate) fn mtime_ns(m: &fs::Metadata) -> u128 {
138
    m.modified().ok().and_then(|t| t.duration_since(UNIX_EPOCH).ok()).map(|d| d.as_nanos()).unwrap_or(0)
139
}
140
135212de Andy McCall ↶ 141
pub const DEFAULT_PROMPT_FORMAT: &str = "%b %c%d%x%A%D";
694805cc Andy McCall ↶ 142
/// The repository icon for `%i` (an atom, for oxygen).
143
pub const ICON: &str = "⚛";
144
/// A format with glyphs: icon, bookmark, change id, state, ahead/behind.
145
pub const GLYPH_PROMPT_FORMAT: &str = "%i %b %c %s%U%O";
135212de Andy McCall ↶ 146
b7f102fc Andy McCall ↶ 147
/// One manifest line per file: size, mtime in nanoseconds, hash, blob ref
148
/// or `-`, path.
149
fn manifest_line(path: &str, size: u64, mtime_ns: u128, hash: &str, blob: Option<ObjectRef>) -> String {
150
    format!("{size}\t{mtime_ns}\t{hash}\t{}\t{path}\n", blob.map(|b| b.to_hex()).unwrap_or_else(|| "-".into()))
135212de Andy McCall ↶ 151
}
152
153
impl Repo {
154
    fn manifest_path(&self) -> PathBuf {
155
        self.oxy.join("workspaces").join(self.workspace.to_hex()).join("manifest")
156
    }
157
158
    /// Record the working copy as it is now (after a snapshot, commit, or
159
    /// checkout), with header lines saying whether the base state is
160
    /// conflicted and whether the working-copy change holds uncommitted work.
161
    pub(crate) fn write_manifest(&self, conflicted: bool, uncommitted: bool) -> Result<()> {
b7f102fc Andy McCall ↶ 162
        let entries = self.walk_working_copy()?;
163
        self.write_manifest_entries(conflicted, uncommitted, &entries, &HashMap::new())
164
    }
165
166
    /// Write the manifest from entries already walked; `blobs` names the
167
    /// stored blob of each opaque path so the next walk can skip reading it.
168
    pub(crate) fn write_manifest_entries(&self, conflicted: bool, uncommitted: bool, entries: &BTreeMap<String, FsEntry>, blobs: &HashMap<String, ObjectRef>) -> Result<()> {
169
        let written = std::time::SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
170
        let mut text = format!("# conflicted={}\n# uncommitted={}\n# written={written}\n", conflicted as u8, uncommitted as u8);
171
        for (path, e) in entries {
172
            let full = self.root.join(path);
135212de Andy McCall ↶ 173
            let (size, mtime_ns) = match fs::symlink_metadata(&full) {
b7f102fc Andy McCall ↶ 174
                Ok(m) => (m.len(), mtime_ns(&m)),
135212de Andy McCall ↶ 175
                Err(_) => (e.content.len() as u64, 0),
176
            };
b7f102fc Andy McCall ↶ 177
            let hash = match &e.content_hash {
178
                Some(h) => h.clone(),
179
                None => blake3::hash(&e.content).to_hex().to_string(),
180
            };
181
            let blob = blobs.get(path).copied().or(e.blob);
182
            text.push_str(&manifest_line(path, size, mtime_ns, &hash, blob));
135212de Andy McCall ↶ 183
        }
184
        let p = self.manifest_path();
185
        fs::create_dir_all(p.parent().unwrap())?;
186
        write_atomic(&p, text.as_bytes())
187
    }
188
b7f102fc Andy McCall ↶ 189
    /// Change only the header flags of the current manifest (after a commit
190
    /// the files are as the snapshot left them).
191
    pub(crate) fn rewrite_manifest_flags(&self, conflicted: bool, uncommitted: bool) -> Result<()> {
192
        let p = self.manifest_path();
193
        let text = match fs::read_to_string(&p) {
194
            Ok(t) => t,
195
            Err(_) => return self.write_manifest(conflicted, uncommitted),
196
        };
197
        let mut out = String::with_capacity(text.len());
198
        for line in text.lines() {
199
            if line.starts_with("# conflicted=") {
200
                out.push_str(&format!("# conflicted={}\n", conflicted as u8));
201
            } else if line.starts_with("# uncommitted=") {
202
                out.push_str(&format!("# uncommitted={}\n", uncommitted as u8));
203
            } else {
204
                out.push_str(line);
205
                out.push('\n');
206
            }
207
        }
208
        write_atomic(&p, out.as_bytes())
209
    }
210
135212de Andy McCall ↶ 211
    /// Forget the manifest after an operation that changed the working-copy
212
    /// change without a snapshot (undo, rewrites); the prompt shows blank
213
    /// markers until the next snapshot.
214
    pub(crate) fn drop_manifest(&self) {
215
        let _ = fs::remove_file(self.manifest_path());
216
    }
217
b7f102fc Andy McCall ↶ 218
    pub(crate) fn read_manifest(&self) -> Result<Option<(bool, bool, Manifest)>> {
135212de Andy McCall ↶ 219
        let text = match fs::read_to_string(self.manifest_path()) {
220
            Ok(t) => t,
221
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
222
            Err(e) => return Err(e.into()),
223
        };
b7f102fc Andy McCall ↶ 224
        let mut out = Manifest::default();
135212de Andy McCall ↶ 225
        let mut conflicted = false;
226
        let mut uncommitted = false;
227
        for line in text.lines() {
228
            if let Some(v) = line.strip_prefix("# conflicted=") {
229
                conflicted = v.trim() == "1";
230
                continue;
231
            }
232
            if let Some(v) = line.strip_prefix("# uncommitted=") {
233
                uncommitted = v.trim() == "1";
234
                continue;
235
            }
b7f102fc Andy McCall ↶ 236
            if let Some(v) = line.strip_prefix("# written=") {
237
                out.written_ns = v.trim().parse().unwrap_or(u128::MAX);
238
                continue;
239
            }
240
            let f: Vec<&str> = line.splitn(5, '\t').collect();
241
            if f.len() == 5 {
242
                out.entries.insert(
243
                    f[4].to_string(),
244
                    ManifestEntry { size: f[0].parse().unwrap_or(0), mtime_ns: f[1].parse().unwrap_or(0), hash: f[2].to_string(), blob: ObjectRef::from_hex(f[3]) },
245
                );
135212de Andy McCall ↶ 246
            }
247
        }
248
        Ok(Some((conflicted, uncommitted, out)))
249
    }
250
251
    /// Whether the working copy differs from the last manifest. Without a
252
    /// manifest the answer is `None`.
253
    pub fn quick_dirty(&self) -> Result<Option<bool>> {
254
        let Some((_, uncommitted, manifest)) = self.read_manifest()? else { return Ok(None) };
255
        if uncommitted {
256
            return Ok(Some(true));
257
        }
258
        let mut seen = 0usize;
259
        let mut wb = ignore::WalkBuilder::new(&self.root);
260
        wb.hidden(false).git_ignore(false).git_global(false).git_exclude(false).require_git(false).follow_links(false).add_custom_ignore_filename(".oxyignore");
261
        wb.filter_entry(|e| e.file_name() != OXY_DIR);
262
        for entry in wb.build() {
263
            let Ok(entry) = entry else { continue };
264
            let Some(ft) = entry.file_type() else { continue };
265
            if ft.is_dir() {
266
                continue;
267
            }
268
            let rel: String = entry.path().strip_prefix(&self.root).unwrap().components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/");
7421309f Andy McCall ↶ 269
            if !self.in_scope(&rel) {
270
                continue;
271
            }
b7f102fc Andy McCall ↶ 272
            let Some(me) = manifest.entries.get(&rel) else { return Ok(Some(true)) };
135212de Andy McCall ↶ 273
            seen += 1;
274
            let m = fs::symlink_metadata(entry.path())?;
b7f102fc Andy McCall ↶ 275
            let now_mtime = mtime_ns(&m);
135212de Andy McCall ↶ 276
            if ft.is_symlink() {
277
                let target = fs::read_link(entry.path())?;
b7f102fc Andy McCall ↶ 278
                if blake3::hash(target.to_string_lossy().as_bytes()).to_hex().as_str() != me.hash {
135212de Andy McCall ↶ 279
                    return Ok(Some(true));
280
                }
281
                continue;
282
            }
b7f102fc Andy McCall ↶ 283
            if m.len() != me.size {
135212de Andy McCall ↶ 284
                return Ok(Some(true));
285
            }
b7f102fc Andy McCall ↶ 286
            if now_mtime != me.mtime_ns && blake3::hash(&fs::read(entry.path())?).to_hex().as_str() != me.hash {
135212de Andy McCall ↶ 287
                return Ok(Some(true));
288
            }
289
        }
b7f102fc Andy McCall ↶ 290
        Ok(Some(seen != manifest.entries.len()))
135212de Andy McCall ↶ 291
    }
292
293
    /// Prompt information without recording anything.
294
    pub fn quick_status(&self) -> Result<PromptInfo> {
295
        let view = self.view()?;
296
        let bookmark = self.config.bookmark.clone();
297
        let heads = self.heads_of(&view, &bookmark)?;
298
        let wc_hash = view.workspaces.get(&self.workspace).copied();
299
        let change_id = match wc_hash {
300
            Some(h) => self.store.get_change(&h)?.change_id.short(8),
301
            None => String::new(),
302
        };
303
        // Conflictedness was recorded by the last snapshot; without a
304
        // manifest (never snapshotted) both flags are unknown and shown clear.
305
        let manifest = self.read_manifest()?;
306
        let conflicted = manifest.as_ref().map(|(c, _, _)| *c).unwrap_or(false);
307
        let dirty = self.quick_dirty()?.unwrap_or(false);
308
        let (ahead, behind) = match view.remotes.get("origin").and_then(|b| b.get(&bookmark)) {
309
            Some(st) => {
310
                let remote_heads = self.store.get_state(st)?.heads;
311
                let ahead = self.negotiate(&remote_heads, &heads).map(|v| v.len()).ok();
312
                let behind = self.negotiate(&heads, &remote_heads).map(|v| v.len()).ok();
313
                (ahead, behind)
314
            }
315
            None => (None, None),
316
        };
317
        Ok(PromptInfo { bookmark, change_id, heads: heads.len(), dirty, conflicted, ahead, behind })
318
    }
319
}
694805cc Andy McCall ↶ 320
321
#[cfg(test)]
322
mod tests {
323
    use super::*;
324
325
    #[test]
326
    fn placeholders_and_glyphs() {
327
        let clean = PromptInfo { bookmark: "main".into(), change_id: "658990aa".into(), heads: 1, dirty: false, conflicted: false, ahead: Some(0), behind: None };
328
        assert_eq!(clean.format(DEFAULT_PROMPT_FORMAT), "main 658990aa");
329
        assert_eq!(clean.format(GLYPH_PROMPT_FORMAT), "⚛ main 658990aa ✔");
330
        let busy = PromptInfo { dirty: true, ahead: Some(2), behind: Some(3), ..clean.clone() };
331
        assert_eq!(busy.format(DEFAULT_PROMPT_FORMAT), "main 658990aa*+2-3");
332
        assert_eq!(busy.format(GLYPH_PROMPT_FORMAT), "⚛ main 658990aa ✎↑2↓3");
333
        let bad = PromptInfo { conflicted: true, ..busy.clone() };
334
        assert_eq!(bad.format("%s%x%d %h %a/%B %%"), "‼!* 1 2/3 %");
335
        assert_eq!(bad.state_color(), 31);
336
        assert_eq!(busy.format_colored("%b", ("\\[", "\\]")), "\\[\x1b[33m\\]main\\[\x1b[0m\\]");
337
        assert_eq!(clean.format_colored("%b", ("", "")), "\x1b[36mmain\x1b[0m");
338
        assert_eq!(clean.format("%q"), "%q");
339
    }
340
}
341