//! 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 { /// 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, `%%` 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('%') => 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, } #[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, } 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"; /// One manifest line per file: size, mtime in nanoseconds, hash, blob ref /// or `-`, path. fn manifest_line(path: &str, size: u64, mtime_ns: u128, hash: &str, blob: Option) -> String { format!("{size}\t{mtime_ns}\t{hash}\t{}\t{path}\n", blob.map(|b| b.to_hex()).unwrap_or_else(|| "-".into())) } impl Repo { 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<()> { let written = std::time::SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); let mut text = format!("# conflicted={}\n# uncommitted={}\n# written={written}\n", conflicted as u8, uncommitted as u8); for (path, e) in entries { let full = self.root.join(path); let (size, mtime_ns) = 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); text.push_str(&manifest_line(path, size, mtime_ns, &hash, blob)); } let p = self.manifest_path(); fs::create_dir_all(p.parent().unwrap())?; write_atomic(&p, text.as_bytes()) } /// Change only the header flags of the current manifest (after a commit /// the files are as the snapshot left them). pub(crate) fn rewrite_manifest_flags(&self, conflicted: bool, uncommitted: bool) -> Result<()> { 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 { 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) { let _ = fs::remove_file(self.manifest_path()); } pub(crate) fn read_manifest(&self) -> Result> { let text = match fs::read_to_string(self.manifest_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; } let f: Vec<&str> = line.splitn(5, '\t').collect(); if f.len() == 5 { out.entries.insert( f[4].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]) }, ); } } Ok(Some((conflicted, uncommitted, out))) } /// 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)); } let mut seen = 0usize; let mut wb = ignore::WalkBuilder::new(&self.root); wb.hidden(false).git_ignore(false).git_global(false).git_exclude(false).require_git(false).follow_links(false).add_custom_ignore_filename(".oxyignore"); wb.filter_entry(|e| e.file_name() != OXY_DIR); for entry in wb.build() { let Ok(entry) = entry else { continue }; let Some(ft) = entry.file_type() else { continue }; if ft.is_dir() { continue; } let rel: String = entry.path().strip_prefix(&self.root).unwrap().components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::>().join("/"); if !self.in_scope(&rel) { continue; } let Some(me) = manifest.entries.get(&rel) else { return Ok(Some(true)) }; seen += 1; let m = fs::symlink_metadata(entry.path())?; let now_mtime = mtime_ns(&m); if ft.is_symlink() { let target = fs::read_link(entry.path())?; if blake3::hash(target.to_string_lossy().as_bytes()).to_hex().as_str() != me.hash { return Ok(Some(true)); } continue; } if m.len() != me.size { return Ok(Some(true)); } if now_mtime != me.mtime_ns && blake3::hash(&fs::read(entry.path())?).to_hex().as_str() != me.hash { return Ok(Some(true)); } } Ok(Some(seen != manifest.entries.len())) } /// 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(&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 }) } }