//! Fast, side-effect-free status for shell prompts. //! //! Every snapshot and checkout writes a manifest of the working copy as it //! was last seen: `size`, `mtime`, and BLAKE3 of each file. `quick_status` //! walks the working copy and compares against that manifest, reading a //! file's content only when its size or mtime changed, and records no //! operation. Ahead/behind counts come from walking the store between the //! local bookmark and its `origin` tracking state. use super::*; use std::time::UNIX_EPOCH; #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PromptInfo { pub bookmark: String, /// Working-copy ChangeId prefix. pub change_id: String, pub heads: usize, pub dirty: bool, pub conflicted: bool, /// Changes local `main` has that `origin/main` lacks, when tracking exists. pub ahead: Option, pub behind: Option, } impl PromptInfo { /// The glyph for the working copy's state: `‼` conflicted, `✎` dirty, /// `✔` clean. pub fn state_glyph(&self) -> &'static str { if self.conflicted { "‼" } else if self.dirty { "✎" } else { "✔" } } /// The ANSI colour (SGR parameter) for the state: red, yellow, or cyan. pub fn state_color(&self) -> u8 { if self.conflicted { 31 } else if self.dirty { 33 } else { 36 } } /// `format` wrapped in the state colour. `guard` is `("\\[", "\\]")` for /// bash prompts, `("%{", "%}")` for zsh, or empty for raw output. pub fn format_colored(&self, fmt: &str, guard: (&str, &str)) -> String { format!( "{}\x1b[{}m{}{}{}\x1b[0m{}", guard.0, self.state_color(), guard.1, self.format(fmt), guard.0, guard.1 ) } /// Expand `%b` bookmark, `%c` change id, `%h` heads, `%d` dirty marker /// (`*`), `%x` conflict marker (`!`), `%a`/`%B` ahead/behind counts /// (empty when unknown), `%A`/`%D` `+n`/`-n` when non-zero, `%s` the /// state glyph, `%U`/`%O` `↑n`/`↓n` when non-zero, `%i` the repository /// icon, `%%` percent. pub fn format(&self, fmt: &str) -> String { let mut out = String::new(); let mut chars = fmt.chars(); while let Some(c) = chars.next() { if c != '%' { out.push(c); continue; } match chars.next() { Some('b') => out.push_str(&self.bookmark), Some('c') => out.push_str(&self.change_id), Some('h') => out.push_str(&self.heads.to_string()), Some('d') => { if self.dirty { out.push('*'); } } Some('x') => { if self.conflicted { out.push('!'); } } Some('a') => out.push_str(&self.ahead.map(|n| n.to_string()).unwrap_or_default()), Some('B') => out.push_str(&self.behind.map(|n| n.to_string()).unwrap_or_default()), Some('A') => { if let Some(n) = self.ahead.filter(|n| *n > 0) { out.push_str(&format!("+{n}")); } } Some('D') => { if let Some(n) = self.behind.filter(|n| *n > 0) { out.push_str(&format!("-{n}")); } } Some('s') => out.push_str(self.state_glyph()), Some('U') => { if let Some(n) = self.ahead.filter(|n| *n > 0) { out.push_str(&format!("↑{n}")); } } Some('O') => { if let Some(n) = self.behind.filter(|n| *n > 0) { out.push_str(&format!("↓{n}")); } } Some('i') => out.push_str(ICON), Some('%') => out.push('%'), Some(other) => { out.push('%'); out.push(other); } None => out.push('%'), } } out } } /// Path → (size, mtime in nanoseconds, BLAKE3 hex) as last seen. /// One manifest entry: the file's stat, its content hash, and the stored /// blob for an opaque file. #[derive(Clone, Debug)] pub(crate) struct ManifestEntry { pub size: u64, pub mtime_ns: u128, pub hash: String, pub blob: Option, /// The graph key (`State::graph_key`) of the base file this text file /// was found equal to, rendered, byte for byte. pub graph: Option, /// Whether the file was executable: a mode change alters neither size /// nor time. pub exec: bool, } #[derive(Clone, Debug, Default)] pub(crate) struct Manifest { /// When the manifest was written, for the racy-stat guard. pub written_ns: u128, pub entries: BTreeMap, } /// A parsed manifest and the identity (size, time, inode) of the file it /// came from: reused while the file is the same one. pub(crate) type CachedManifest = ((u64, u128, u64), (bool, bool, std::sync::Arc)); pub(crate) fn mtime_ns(m: &fs::Metadata) -> u128 { m.modified() .ok() .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) .map(|d| d.as_nanos()) .unwrap_or(0) } pub const DEFAULT_PROMPT_FORMAT: &str = "%b %c%d%x%A%D"; /// The repository icon for `%i` (an atom, for oxygen). pub const ICON: &str = "⚛"; /// A format with glyphs: icon, bookmark, change id, state, ahead/behind. pub const GLYPH_PROMPT_FORMAT: &str = "%i %b %c %s%U%O"; /// One manifest line per file: size, mtime in nanoseconds, hash, blob ref /// or `-`, graph key or `-`, `x` when executable or `-`, path. fn manifest_line( path: &str, size: u64, mtime_ns: u128, hash: &str, blob: Option, graph: Option<&str>, exec: bool, ) -> String { format!( "{size}\t{mtime_ns}\t{hash}\t{}\t{}\t{}\t{path}\n", blob.map(|b| b.to_hex()).unwrap_or_else(|| "-".into()), graph.unwrap_or("-"), if exec { "x" } else { "-" } ) } impl Repo { pub(crate) fn manifest_path(&self) -> PathBuf { self.oxy .join("workspaces") .join(self.workspace.to_hex()) .join("manifest") } /// Record the working copy as it is now (after a snapshot, commit, or /// checkout), with header lines saying whether the base state is /// conflicted and whether the working-copy change holds uncommitted work. pub(crate) fn write_manifest(&self, conflicted: bool, uncommitted: bool) -> Result<()> { let entries = self.walk_working_copy()?; self.write_manifest_entries(conflicted, uncommitted, &entries, &HashMap::new()) } /// Write the manifest from entries already walked; `blobs` names the /// stored blob of each opaque path so the next walk can skip reading it. pub(crate) fn write_manifest_entries( &self, conflicted: bool, uncommitted: bool, entries: &BTreeMap, blobs: &HashMap, ) -> Result<()> { self.manifest_cache.take(); let written = std::time::SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); let head = format!( "# conflicted={}\n# uncommitted={}\n", conflicted as u8, uncommitted as u8 ); let mut body = String::new(); let mut newest_ns: u128 = 0; for (path, e) in entries { let full = self.root.join(path); let (size, mtime_ns) = match e.stat { Some(known) => known, None => match fs::symlink_metadata(&full) { Ok(m) => (m.len(), mtime_ns(&m)), Err(_) => (e.content.len() as u64, 0), }, }; let hash = match &e.content_hash { Some(h) => h.clone(), None => blake3::hash(&e.content).to_hex().to_string(), }; let blob = blobs.get(path).copied().or(e.blob); newest_ns = newest_ns.max(mtime_ns); body.push_str(&manifest_line( path, size, mtime_ns, &hash, blob, e.graph_key.as_deref(), e.exec, )); } let p = self.manifest_path(); // Leave an unchanged manifest alone when its stamp already trusts // every entry (none is within the racy window): rewriting it would // change nothing but the file, which editors watching the tree // (and subrepos inside it) take as a change. if let Ok(old) = fs::read_to_string(&p) { let old_written: Option = old.lines().find_map(|l| { l.strip_prefix("# written=") .and_then(|v| v.trim().parse().ok()) }); let old_rest: String = old .lines() .filter(|l| !l.starts_with("# written=")) .map(|l| format!("{l}\n")) .collect(); if let Some(w) = old_written { if old_rest == format!("{head}{body}") && newest_ns + 2_000_000_000 < w { return Ok(()); } } } let text = format!("{head}# written={written}\n{body}"); fs::create_dir_all(p.parent().unwrap())?; write_atomic(&p, text.as_bytes()) } /// Change the header flags, and set the graph key of each path in /// `keys` (after a commit, the new base's keys of the files it holds as /// they are on disk). pub(crate) fn rewrite_manifest( &self, conflicted: bool, uncommitted: bool, keys: &HashMap, ) -> Result<()> { self.manifest_cache.take(); let p = self.manifest_path(); let text = match fs::read_to_string(&p) { Ok(t) => t, Err(_) => return self.write_manifest(conflicted, uncommitted), }; let mut out = String::with_capacity(text.len()); for line in text.lines() { if line.starts_with("# conflicted=") { out.push_str(&format!("# conflicted={}\n", conflicted as u8)); } else if line.starts_with("# uncommitted=") { out.push_str(&format!("# uncommitted={}\n", uncommitted as u8)); } else { let f: Vec<&str> = line.splitn(7, '\t').collect(); match (f.len() == 7).then(|| keys.get(f[6])).flatten() { Some(k) => out.push_str(&format!( "{}\t{}\t{}\t{}\t{k}\t{}\t{}\n", f[0], f[1], f[2], f[3], f[5], f[6] )), None => { out.push_str(line); out.push('\n'); } } } } write_atomic(&p, out.as_bytes()) } /// Forget the manifest after an operation that changed the working-copy /// change without a snapshot (undo, rewrites); the prompt shows blank /// markers until the next snapshot. pub(crate) fn drop_manifest(&self) { self.manifest_cache.take(); let _ = fs::remove_file(self.manifest_path()); } /// The manifest, with whether it records a conflict and uncommitted /// work; parsed once while the file stays the same one. pub(crate) fn read_manifest(&self) -> Result)>> { let path = self.manifest_path(); let id = match fs::metadata(&path) { Ok(m) => { #[cfg(unix)] let ino = std::os::unix::fs::MetadataExt::ino(&m); #[cfg(not(unix))] let ino = 0; (m.len(), mtime_ns(&m), ino) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => return Err(e.into()), }; if let Some((cached_id, m)) = &*self.manifest_cache.borrow() { if *cached_id == id { return Ok(Some(m.clone())); } } let Some((c, u, m)) = self.parse_manifest(&path)? else { return Ok(None); }; let m = (c, u, std::sync::Arc::new(m)); *self.manifest_cache.borrow_mut() = Some((id, m.clone())); Ok(Some(m)) } fn parse_manifest(&self, path: &std::path::Path) -> Result> { let text = match fs::read_to_string(path) { Ok(t) => t, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => return Err(e.into()), }; let mut out = Manifest::default(); let mut conflicted = false; let mut uncommitted = false; for line in text.lines() { if let Some(v) = line.strip_prefix("# conflicted=") { conflicted = v.trim() == "1"; continue; } if let Some(v) = line.strip_prefix("# uncommitted=") { uncommitted = v.trim() == "1"; continue; } if let Some(v) = line.strip_prefix("# written=") { out.written_ns = v.trim().parse().unwrap_or(u128::MAX); continue; } // A line of another shape (an older manifest) is no entry: the // file is then read, and the next snapshot rewrites the line. let f: Vec<&str> = line.splitn(7, '\t').collect(); if f.len() == 7 { out.entries.insert( f[6].to_string(), 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]), graph: (f[4] != "-").then(|| f[4].to_string()), exec: f[5] == "x", }, ); } } Ok(Some((conflicted, uncommitted, out))) } /// Whether the working copy is certainly clean, known from the manifest /// alone, without loading any history (spec §12.4): the last snapshot /// or commit found no uncommitted work and no conflict, there are no /// subrepos (whose state the manifest does not hold), and no file was /// added, removed or changed since. An entry inside the racy window, /// where an equal size and time do not prove equal content, is checked /// by hashing the file. `false` means only that a snapshot must decide. pub fn clean_by_manifest(&self) -> Result { let _t = crate::timing::phase("status: check the manifest"); let Some((conflicted, uncommitted, m)) = self.read_manifest()? else { return Ok(false); }; if conflicted || uncommitted || self.root.join(".oxymodules").exists() { return Ok(false); } for (rel, e) in &m.entries { if e.mtime_ns + 2_000_000_000 < m.written_ns { continue; } let path = self.root.join(rel); let same = match fs::symlink_metadata(&path) { Ok(md) if md.file_type().is_symlink() => fs::read_link(&path).is_ok_and(|t| { blake3::hash(t.to_string_lossy().as_bytes()) .to_hex() .as_str() == e.hash }), Ok(md) if md.is_file() => { md.len() == e.size && fs::read(&path) .is_ok_and(|b| blake3::hash(&b).to_hex().as_str() == e.hash) } _ => false, }; if !same { return Ok(false); } } Ok(self.quick_dirty()? == Some(false)) } /// Whether the working copy differs from the last manifest. Without a /// manifest the answer is `None`. pub fn quick_dirty(&self) -> Result> { let Some((_, uncommitted, manifest)) = self.read_manifest()? else { return Ok(None); }; if uncommitted { return Ok(Some(true)); } use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed}; let nested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let wb = self.walker(&nested); // Every file is checked on its own, so the walk runs in parallel and // stops at the first difference. let dirty = AtomicBool::new(false); let seen = AtomicUsize::new(0); let (root, sparse) = (&self.root, &self.config.sparse_paths); let in_scope = |p: &str| { sparse.is_empty() || sparse .iter() .any(|s| p == s || p.starts_with(&format!("{s}/"))) }; wb.build_parallel().run(|| { Box::new(|entry| { use ignore::WalkState; if dirty.load(Relaxed) { return WalkState::Quit; } let Ok(entry) = entry else { return WalkState::Continue; }; let Some(ft) = entry.file_type() else { return WalkState::Continue; }; if ft.is_dir() { return WalkState::Continue; } let under = entry.path().strip_prefix(root).unwrap(); let rel: String = match under.to_str() { Some(s) if std::path::MAIN_SEPARATOR == '/' => s.to_string(), _ => under .components() .map(|c| c.as_os_str().to_string_lossy().to_string()) .collect::>() .join("/"), }; if !in_scope(&rel) { return WalkState::Continue; } // Whether this file differs from its entry; an unreadable // one counts as different. let differs = match manifest.entries.get(&rel) { None => true, Some(me) => { seen.fetch_add(1, Relaxed); match fs::symlink_metadata(entry.path()) { Err(_) => true, Ok(_) if ft.is_symlink() => { !fs::read_link(entry.path()).is_ok_and(|t| { blake3::hash(t.to_string_lossy().as_bytes()) .to_hex() .as_str() == me.hash }) } Ok(m) => { m.len() != me.size || super::is_executable(&m) != me.exec || (mtime_ns(&m) != me.mtime_ns && !fs::read(entry.path()).is_ok_and(|b| { blake3::hash(&b).to_hex().as_str() == me.hash })) } } } }; if differs { dirty.store(true, Relaxed); return WalkState::Quit; } WalkState::Continue }) }); if dirty.load(Relaxed) { return Ok(Some(true)); } let seen = seen.load(Relaxed); if seen == manifest.entries.len() { return Ok(Some(false)); } // Manifest entries the walk skipped: tracked files that ignore rules // hide. They count as present when still on disk and unchanged. for (rel, me) in &manifest.entries { let path = self.root.join(rel); let Ok(m) = fs::symlink_metadata(&path) else { return Ok(Some(true)); }; if m.file_type().is_symlink() { continue; } if m.len() != me.size || super::is_executable(&m) != me.exec || (mtime_ns(&m) != me.mtime_ns && blake3::hash(&fs::read(&path)?).to_hex().as_str() != me.hash) { return Ok(Some(true)); } } Ok(Some(false)) } /// Prompt information without recording anything. pub fn quick_status(&self) -> Result { let view = self.view()?; let bookmark = self.config.bookmark.clone(); let heads = self.heads_of(&view, &bookmark)?; let wc_hash = view.workspaces.get(&self.workspace).copied(); let change_id = match wc_hash { Some(h) => self.store.get_change_meta(&h)?.change_id.short(8), None => String::new(), }; // Conflictedness was recorded by the last snapshot; without a // manifest (never snapshotted) both flags are unknown and shown clear. let manifest = self.read_manifest()?; let conflicted = manifest.as_ref().map(|(c, _, _)| *c).unwrap_or(false); let dirty = self.quick_dirty()?.unwrap_or(false); let (ahead, behind) = match view.remotes.get("origin").and_then(|b| b.get(&bookmark)) { Some(st) => { let remote_heads = self.store.get_state(st)?.heads; let ahead = self.negotiate(&remote_heads, &heads).map(|v| v.len()).ok(); let behind = self.negotiate(&heads, &remote_heads).map(|v| v.len()).ok(); (ahead, behind) } None => (None, None), }; Ok(PromptInfo { bookmark, change_id, heads: heads.len(), dirty, conflicted, ahead, behind, }) } } #[cfg(test)] mod tests { use super::*; #[test] fn placeholders_and_glyphs() { let clean = PromptInfo { bookmark: "main".into(), change_id: "658990aa".into(), heads: 1, dirty: false, conflicted: false, ahead: Some(0), behind: None, }; assert_eq!(clean.format(DEFAULT_PROMPT_FORMAT), "main 658990aa"); assert_eq!(clean.format(GLYPH_PROMPT_FORMAT), "⚛ main 658990aa ✔"); let busy = PromptInfo { dirty: true, ahead: Some(2), behind: Some(3), ..clean.clone() }; assert_eq!(busy.format(DEFAULT_PROMPT_FORMAT), "main 658990aa*+2-3"); assert_eq!(busy.format(GLYPH_PROMPT_FORMAT), "⚛ main 658990aa ✎↑2↓3"); let bad = PromptInfo { conflicted: true, ..busy.clone() }; assert_eq!(bad.format("%s%x%d %h %a/%B %%"), "‼!* 1 2/3 %"); assert_eq!(bad.state_color(), 31); assert_eq!( busy.format_colored("%b", ("\\[", "\\]")), "\\[\x1b[33m\\]main\\[\x1b[0m\\]" ); assert_eq!(clean.format_colored("%b", ("", "")), "\x1b[36mmain\x1b[0m"); assert_eq!(clean.format("%q"), "%q"); } }