OXYGENHUB
# b2ef93bb2366 oxygen / crates / oxy-core / src / repo / prompt.rs
8.9 KiB · Rust · last changed Sparse checkout: filter.paths, clone --sparse (implies lazy blobs), oxy sparse, scope-aware snapshot/checkout/prompt (spec 0.6f) 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
    /// Expand `%b` bookmark, `%c` change id, `%h` heads, `%d` dirty marker
28
    /// (`*`), `%x` conflict marker (`!`), `%a`/`%B` ahead/behind counts
29
    /// (empty when unknown), `%A`/`%D` `+n`/`-n` when non-zero, `%%` percent.
30
    pub fn format(&self, fmt: &str) -> String {
31
        let mut out = String::new();
32
        let mut chars = fmt.chars();
33
        while let Some(c) = chars.next() {
34
            if c != '%' {
35
                out.push(c);
36
                continue;
37
            }
38
            match chars.next() {
39
                Some('b') => out.push_str(&self.bookmark),
40
                Some('c') => out.push_str(&self.change_id),
41
                Some('h') => out.push_str(&self.heads.to_string()),
42
                Some('d') => {
43
                    if self.dirty {
44
                        out.push('*');
45
                    }
46
                }
47
                Some('x') => {
48
                    if self.conflicted {
49
                        out.push('!');
50
                    }
51
                }
52
                Some('a') => out.push_str(&self.ahead.map(|n| n.to_string()).unwrap_or_default()),
53
                Some('B') => out.push_str(&self.behind.map(|n| n.to_string()).unwrap_or_default()),
54
                Some('A') => {
55
                    if let Some(n) = self.ahead.filter(|n| *n > 0) {
56
                        out.push_str(&format!("+{n}"));
57
                    }
58
                }
59
                Some('D') => {
60
                    if let Some(n) = self.behind.filter(|n| *n > 0) {
61
                        out.push_str(&format!("-{n}"));
62
                    }
63
                }
64
                Some('%') => out.push('%'),
65
                Some(other) => {
66
                    out.push('%');
67
                    out.push(other);
68
                }
69
                None => out.push('%'),
70
            }
71
        }
72
        out
73
    }
74
}
75
76
/// Path → (size, mtime in nanoseconds, BLAKE3 hex) as last seen.
77
type Manifest = BTreeMap<String, (u64, u128, String)>;
78
79
pub const DEFAULT_PROMPT_FORMAT: &str = "%b %c%d%x%A%D";
80
81
/// One manifest line per file: size, mtime in nanoseconds, hash, path.
82
fn manifest_line(path: &str, size: u64, mtime_ns: u128, hash: &blake3::Hash) -> String {
83
    format!("{size}\t{mtime_ns}\t{}\t{path}\n", hash.to_hex())
84
}
85
86
impl Repo {
87
    fn manifest_path(&self) -> PathBuf {
88
        self.oxy.join("workspaces").join(self.workspace.to_hex()).join("manifest")
89
    }
90
91
    /// Record the working copy as it is now (after a snapshot, commit, or
92
    /// checkout), with header lines saying whether the base state is
93
    /// conflicted and whether the working-copy change holds uncommitted work.
94
    pub(crate) fn write_manifest(&self, conflicted: bool, uncommitted: bool) -> Result<()> {
95
        let mut text = format!("# conflicted={}\n# uncommitted={}\n", conflicted as u8, uncommitted as u8);
96
        for (path, e) in self.walk_working_copy()? {
97
            let full = self.root.join(&path);
98
            let (size, mtime_ns) = match fs::symlink_metadata(&full) {
99
                Ok(m) => (m.len(), m.modified().ok().and_then(|t| t.duration_since(UNIX_EPOCH).ok()).map(|d| d.as_nanos()).unwrap_or(0)),
100
                Err(_) => (e.content.len() as u64, 0),
101
            };
102
            text.push_str(&manifest_line(&path, size, mtime_ns, &blake3::hash(&e.content)));
103
        }
104
        let p = self.manifest_path();
105
        fs::create_dir_all(p.parent().unwrap())?;
106
        write_atomic(&p, text.as_bytes())
107
    }
108
109
    /// Forget the manifest after an operation that changed the working-copy
110
    /// change without a snapshot (undo, rewrites); the prompt shows blank
111
    /// markers until the next snapshot.
112
    pub(crate) fn drop_manifest(&self) {
113
        let _ = fs::remove_file(self.manifest_path());
114
    }
115
116
    fn read_manifest(&self) -> Result<Option<(bool, bool, Manifest)>> {
117
        let text = match fs::read_to_string(self.manifest_path()) {
118
            Ok(t) => t,
119
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
120
            Err(e) => return Err(e.into()),
121
        };
122
        let mut out = BTreeMap::new();
123
        let mut conflicted = false;
124
        let mut uncommitted = false;
125
        for line in text.lines() {
126
            if let Some(v) = line.strip_prefix("# conflicted=") {
127
                conflicted = v.trim() == "1";
128
                continue;
129
            }
130
            if let Some(v) = line.strip_prefix("# uncommitted=") {
131
                uncommitted = v.trim() == "1";
132
                continue;
133
            }
134
            let f: Vec<&str> = line.splitn(4, '\t').collect();
135
            if f.len() == 4 {
136
                out.insert(f[3].to_string(), (f[0].parse().unwrap_or(0), f[1].parse().unwrap_or(0), f[2].to_string()));
137
            }
138
        }
139
        Ok(Some((conflicted, uncommitted, out)))
140
    }
141
142
    /// Whether the working copy differs from the last manifest. Without a
143
    /// manifest the answer is `None`.
144
    pub fn quick_dirty(&self) -> Result<Option<bool>> {
145
        let Some((_, uncommitted, manifest)) = self.read_manifest()? else { return Ok(None) };
146
        if uncommitted {
147
            return Ok(Some(true));
148
        }
149
        let mut seen = 0usize;
150
        let mut wb = ignore::WalkBuilder::new(&self.root);
151
        wb.hidden(false).git_ignore(false).git_global(false).git_exclude(false).require_git(false).follow_links(false).add_custom_ignore_filename(".oxyignore");
152
        wb.filter_entry(|e| e.file_name() != OXY_DIR);
153
        for entry in wb.build() {
154
            let Ok(entry) = entry else { continue };
155
            let Some(ft) = entry.file_type() else { continue };
156
            if ft.is_dir() {
157
                continue;
158
            }
159
            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("/");
160
            if !self.in_scope(&rel) {
161
                continue;
162
            }
163
            let Some((size, mtime_ns, hash)) = manifest.get(&rel) else { return Ok(Some(true)) };
164
            seen += 1;
165
            let m = fs::symlink_metadata(entry.path())?;
166
            let now_mtime = m.modified().ok().and_then(|t| t.duration_since(UNIX_EPOCH).ok()).map(|d| d.as_nanos()).unwrap_or(0);
167
            if ft.is_symlink() {
168
                let target = fs::read_link(entry.path())?;
169
                if blake3::hash(target.to_string_lossy().as_bytes()).to_hex().as_str() != hash {
170
                    return Ok(Some(true));
171
                }
172
                continue;
173
            }
174
            if m.len() != *size {
175
                return Ok(Some(true));
176
            }
177
            if now_mtime != *mtime_ns && blake3::hash(&fs::read(entry.path())?).to_hex().as_str() != hash {
178
                return Ok(Some(true));
179
            }
180
        }
181
        Ok(Some(seen != manifest.len()))
182
    }
183
184
    /// Prompt information without recording anything.
185
    pub fn quick_status(&self) -> Result<PromptInfo> {
186
        let view = self.view()?;
187
        let bookmark = self.config.bookmark.clone();
188
        let heads = self.heads_of(&view, &bookmark)?;
189
        let wc_hash = view.workspaces.get(&self.workspace).copied();
190
        let change_id = match wc_hash {
191
            Some(h) => self.store.get_change(&h)?.change_id.short(8),
192
            None => String::new(),
193
        };
194
        // Conflictedness was recorded by the last snapshot; without a
195
        // manifest (never snapshotted) both flags are unknown and shown clear.
196
        let manifest = self.read_manifest()?;
197
        let conflicted = manifest.as_ref().map(|(c, _, _)| *c).unwrap_or(false);
198
        let dirty = self.quick_dirty()?.unwrap_or(false);
199
        let (ahead, behind) = match view.remotes.get("origin").and_then(|b| b.get(&bookmark)) {
200
            Some(st) => {
201
                let remote_heads = self.store.get_state(st)?.heads;
202
                let ahead = self.negotiate(&remote_heads, &heads).map(|v| v.len()).ok();
203
                let behind = self.negotiate(&heads, &remote_heads).map(|v| v.len()).ok();
204
                (ahead, behind)
205
            }
206
            None => (None, None),
207
        };
208
        Ok(PromptInfo { bookmark, change_id, heads: heads.len(), dirty, conflicted, ahead, behind })
209
    }
210
}