//! 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: [u8; 32], 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<[u8; 32]>, /// 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, /// Every directory the walk listed, with its time then; empty when the /// manifest was not written from a walk (a checkout), and then the /// check lists the tree. pub dirs: Vec<(String, u128)>, /// Directories holding a repository of their own, as the walk found. pub nested: Vec, } /// 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"; /// The manifest file (oxygen#41): a flat binary layout read in one pass. /// A magic number; the conflicted and uncommitted flags; when it was /// written, at a fixed offset so an otherwise equal manifest can be /// recognised; BLAKE3 of everything else; then each entry in path order: /// path, size, mtime in nanoseconds, BLAKE3 of the content, the stored /// blob of an opaque file, the graph key it was found equal to, and /// whether it is executable. Format 3 puts before the entries every /// directory the walk listed with its time, and the nested repositories. A file that does not check, or in any other /// layout (an older manifest), reads as no manifest, and the next snapshot /// writes it again: it is a cache, written without waiting for the disk. const MANIFEST_MAGIC: &[u8; 8] = b"oxyman3\0"; /// Where the written time sits: after the magic and the two flags. const MANIFEST_WRITTEN: std::ops::Range = 10..26; /// Where the checksum sits, after the written time. const MANIFEST_SUM: std::ops::Range = 26..58; fn manifest_sum(buf: &[u8]) -> [u8; 32] { let mut h = blake3::Hasher::new(); h.update(&buf[..MANIFEST_SUM.start]); h.update_rayon(&buf[MANIFEST_SUM.end..]); *h.finalize().as_bytes() } fn encode_manifest( conflicted: bool, uncommitted: bool, written_ns: u128, dirs: &[(String, u128)], nested: &[String], entries: &mut dyn Iterator, ) -> Vec { let mut w = crate::flat::Writer::default(); w.fixed(MANIFEST_MAGIC); w.u8(conflicted as u8); w.u8(uncommitted as u8); w.fixed(&written_ns.to_le_bytes()); w.fixed(&[0; 32]); w.count(dirs.len()); for (d, t) in dirs { w.bytes(d.as_bytes()); w.fixed(&t.to_le_bytes()); } w.count(nested.len()); for n in nested { w.bytes(n.as_bytes()); } for (path, e) in entries { w.bytes(path.as_bytes()); w.u64(e.size); w.fixed(&e.mtime_ns.to_le_bytes()); w.fixed(&e.hash); w.opt_object(&e.blob); match e.graph { None => w.u8(0), Some(k) => { w.u8(1); w.fixed(&k); } } w.u8(e.exec as u8); } let sum = manifest_sum(&w.0); w.0[MANIFEST_SUM].copy_from_slice(&sum); w.0 } fn decode_manifest(buf: &[u8]) -> Option<(bool, bool, Manifest)> { let mut r = crate::flat::Reader::new(buf); if &r.fixed::<8>().ok()? != MANIFEST_MAGIC { return None; } let conflicted = r.u8().ok()? == 1; let uncommitted = r.u8().ok()? == 1; let written_ns = u128::from_le_bytes(r.fixed().ok()?); if r.fixed::<32>().ok()? != manifest_sum(buf) { return None; } let mut dirs = Vec::new(); for _ in 0..r.count().ok()? { let d = std::str::from_utf8(r.bytes().ok()?).ok()?.to_string(); dirs.push((d, u128::from_le_bytes(r.fixed().ok()?))); } let mut nested = Vec::new(); for _ in 0..r.count().ok()? { nested.push(std::str::from_utf8(r.bytes().ok()?).ok()?.to_string()); } let mut entries = Vec::new(); while !r.at_end() { let path = std::str::from_utf8(r.bytes().ok()?).ok()?.to_string(); let e = ManifestEntry { size: r.u64().ok()?, mtime_ns: u128::from_le_bytes(r.fixed().ok()?), hash: r.fixed().ok()?, blob: r.opt_object().ok()?, graph: match r.u8().ok()? { 0 => None, _ => Some(r.fixed().ok()?), }, exec: r.u8().ok()? == 1, }; entries.push((path, e)); } // Written in path order, so this builds the map without searching. let entries = entries.into_iter().collect(); Some(( conflicted, uncommitted, Manifest { written_ns, entries, dirs, nested, }, )) } /// A tracked path as it is now: a file's stat, or a symlink's target. enum Tracked { File(super::walk::Stat), Link(Vec), } /// The stat of every path in `rels` (relative to `root`; `None` for one /// gone or neither a file nor a symlink), in the same order. Paths are /// grouped by directory and each directory is opened once: a stat relative /// to it resolves one name, where a stat by full path walks every /// component from the root again (most of a clean status's time, in the /// kernel's path lookup, on the Linux tree). fn stat_tracked(root: &Path, rels: &[&String]) -> Vec> { use rayon::prelude::*; let mut by_dir: HashMap<&str, Vec<(usize, &str)>> = HashMap::new(); for (i, rel) in rels.iter().enumerate() { let (dir, name) = rel.rsplit_once('/').unwrap_or(("", rel.as_str())); by_dir.entry(dir).or_default().push((i, name)); } let groups: Vec<(&str, Vec<(usize, &str)>)> = by_dir.into_iter().collect(); let found: Vec<(usize, Option)> = groups .par_iter() .flat_map_iter(|(dir, names)| { let handle = fs::File::open(root.join(dir)).ok(); names .iter() .map(|(i, name)| { let rel = rels[*i]; let now = match &handle { Some(d) => stat_in(d, name, || root.join(rel)), None => None, }; (*i, now) }) .collect::>() }) .collect(); let mut out: Vec> = (0..rels.len()).map(|_| None).collect(); for (i, now) in found { out[i] = now; } out } /// One entry of the open directory `dir`; `path` builds its full path, /// needed only for a symlink's target. #[cfg(unix)] fn stat_in(dir: &fs::File, name: &str, path: impl Fn() -> PathBuf) -> Option { use std::os::fd::AsRawFd; let c = std::ffi::CString::new(name).ok()?; let mut st: libc::stat = unsafe { std::mem::zeroed() }; // SAFETY: a valid directory descriptor, a NUL-terminated name, and a // stat buffer of the right type that outlives the call. let r = unsafe { libc::fstatat( dir.as_raw_fd(), c.as_ptr(), &mut st, libc::AT_SYMLINK_NOFOLLOW, ) }; if r != 0 { return None; } match st.st_mode & libc::S_IFMT { libc::S_IFREG => Some(Tracked::File(super::walk::Stat { size: st.st_size as u64, // As `mtime_ns` reads it: nanoseconds since the epoch, 0 before it. mtime_ns: if st.st_mtime < 0 { 0 } else { st.st_mtime as u128 * 1_000_000_000 + st.st_mtime_nsec as u128 }, exec: st.st_mode & 0o111 != 0, })), libc::S_IFLNK => { let t = fs::read_link(path()).ok()?; Some(Tracked::Link(t.to_string_lossy().as_bytes().to_vec())) } _ => None, } } #[cfg(not(unix))] fn stat_in(_dir: &fs::File, _name: &str, path: impl Fn() -> PathBuf) -> Option { let p = path(); let m = fs::symlink_metadata(&p).ok()?; if m.file_type().is_symlink() { let t = fs::read_link(&p).ok()?; Some(Tracked::Link(t.to_string_lossy().as_bytes().to_vec())) } else if m.is_file() { Some(Tracked::File(super::walk::Stat::of(&m))) } else { None } } /// The status cache: a magic number, the working-copy change, the /// manifest checksum, then each status line (a status byte and a path). const STATUS_MAGIC: &[u8; 8] = b"oxystat1"; fn status_byte(s: ChangeStatus) -> u8 { match s { ChangeStatus::Added => 0, ChangeStatus::Modified => 1, ChangeStatus::Removed => 2, ChangeStatus::Renamed => 3, ChangeStatus::ModeChanged => 4, ChangeStatus::ConflictEdited => 5, ChangeStatus::Resolved => 6, } } fn encode_status_cache(wc: &ChangeHash, stamp: &[u8; 32], statuses: &[PathStatus]) -> Vec { let mut out = Vec::with_capacity(80 + statuses.len() * 40); out.extend_from_slice(STATUS_MAGIC); out.extend_from_slice(&wc.0); out.extend_from_slice(stamp); out.extend_from_slice(&(statuses.len() as u32).to_le_bytes()); for s in statuses { out.push(status_byte(s.status)); out.extend_from_slice(&(s.path.len() as u32).to_le_bytes()); out.extend_from_slice(s.path.as_bytes()); } out } fn decode_status_cache(b: &[u8]) -> Option<(ChangeHash, [u8; 32], Vec)> { let rest = b.strip_prefix(STATUS_MAGIC)?; let wc = ObjectRef(rest.get(..32)?.try_into().ok()?); let stamp: [u8; 32] = rest.get(32..64)?.try_into().ok()?; let n = u32::from_le_bytes(rest.get(64..68)?.try_into().ok()?) as usize; let mut at = 68; let mut out = Vec::with_capacity(n.min(1 << 16)); for _ in 0..n { let status = match *rest.get(at)? { 0 => ChangeStatus::Added, 1 => ChangeStatus::Modified, 2 => ChangeStatus::Removed, 3 => ChangeStatus::Renamed, 4 => ChangeStatus::ModeChanged, 5 => ChangeStatus::ConflictEdited, 6 => ChangeStatus::Resolved, _ => return None, }; let len = u32::from_le_bytes(rest.get(at + 1..at + 5)?.try_into().ok()?) as usize; let path = std::str::from_utf8(rest.get(at + 5..at + 5 + len)?) .ok()? .to_string(); out.push(PathStatus { path, status }); at += 5 + len; } (at == rest.len()).then_some((wc, stamp, out)) } 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(); self.listing_cache.take(); let written = std::time::SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); let mut newest_ns: u128 = 0; let mut rows = entries.iter().map(|(path, e)| { let (size, mtime_ns) = match e.stat { Some(known) => known, None => match fs::symlink_metadata(self.root.join(path)) { Ok(m) => (m.len(), mtime_ns(&m)), Err(_) => (e.content.len() as u64, 0), }, }; newest_ns = newest_ns.max(mtime_ns); let entry = ManifestEntry { size, mtime_ns, hash: e .content_hash .unwrap_or_else(|| *blake3::hash(&e.content).as_bytes()), blob: blobs.get(path).copied().or(e.blob), graph: e.graph_key, exec: e.exec, }; (path.as_str(), entry) }); // The directories of the walk these entries came from, if any. let (dirs, nested) = self.walked_dirs.take().unwrap_or_default(); let new = encode_manifest(conflicted, uncommitted, written, &dirs, &nested, &mut rows); drop(rows); 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(&p) { let (w, sum) = (MANIFEST_WRITTEN, MANIFEST_SUM); if old.len() == new.len() && old[..w.start] == new[..w.start] && old[sum.end..] == new[sum.end..] { let old_written = u128::from_le_bytes(old[w.clone()].try_into().unwrap()); if newest_ns + 2_000_000_000 < old_written { return Ok(()); } } } fs::create_dir_all(p.parent().unwrap())?; write_cache(&p, &new) } /// 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(); self.listing_cache.take(); let p = self.manifest_path(); let Some((_, _, m)) = fs::read(&p).ok().and_then(|b| decode_manifest(&b)) else { return self.write_manifest(conflicted, uncommitted); }; let mut rows = m.entries.iter().map(|(path, e)| { let mut e = e.clone(); if let Some(k) = keys.get(path) { e.graph = Some(*k); } (path.as_str(), e) }); write_cache( &p, &encode_manifest( conflicted, uncommitted, m.written_ns, &m.dirs, &m.nested, &mut rows, ), ) } /// 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(); self.listing_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. /// Whether the working copy was conflicted when last recorded (the /// manifest's flag), without looking at the files. pub fn recorded_conflicted(&self) -> Result { Ok(self.read_manifest()?.is_some_and(|(c, _, _)| c)) } 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 buf = match fs::read(path) { Ok(b) => b, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => return Err(e.into()), }; Ok(decode_manifest(&buf)) } /// 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"); // Why a snapshot must decide, with timings on. let not = |why: &str| { if crate::timing::enabled() { eprintln!("timing status: not clean by the manifest: {why}"); } Ok(false) }; let Some((conflicted, uncommitted, m)) = self.read_manifest()? else { return not("no manifest"); }; if conflicted || uncommitted || self.root.join(".oxymodules").exists() { return not("conflicted, uncommitted or subrepos"); } if self.disk_differs(&m, true)? { return not("a file differs"); } Ok(true) } /// A manifest entry written within the racy window whose file no /// longer matches it. Such entries are hashed, in parallel (a checkout /// just wrote them all). fn racy_change(&self, m: &Manifest) -> Option { use rayon::prelude::*; let racy: Vec<(&String, &ManifestEntry)> = m .entries .iter() .filter(|(_, e)| e.mtime_ns + 2_000_000_000 >= m.written_ns) .collect(); let root = &self.root; racy.par_iter() .find_any(|(rel, e)| { let path = root.join(rel.as_str()); 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()).as_bytes() == e.hash }), Ok(md) if md.is_file() => { md.len() == e.size && fs::read(&path).is_ok_and(|b| *blake3::hash(&b).as_bytes() == e.hash) } _ => false, }; !same }) .map(|(rel, _)| rel.to_string()) } /// The status lines of the last snapshot, when the working copy is as /// that snapshot left it: the cache it wrote names the working-copy /// change and the manifest it recorded, and every file still matches /// that manifest. `None` when a snapshot must decide (no cache, a file /// changed, conflicts or subrepos). An empty list is a clean working /// copy. pub fn status_by_manifest(&self) -> Result>> { // A listing left by an earlier check describes the files as they // were then; this check makes its own. self.listing_cache.take(); let Some((conflicted, uncommitted, m)) = self.read_manifest()? else { return Ok(None); }; if !uncommitted { return Ok(self.clean_by_manifest()?.then(Vec::new)); } let _t = crate::timing::phase("status: by the status cache"); if conflicted || self.root.join(".oxymodules").exists() { return Ok(None); } let Some((wc, stamp, statuses)) = fs::read(self.status_cache_path()) .ok() .and_then(|b| decode_status_cache(&b)) else { return Ok(None); }; let now = self.view()?.workspaces.get(&self.workspace).copied(); if now != Some(wc) || self.manifest_stamp() != Some(stamp) { return Ok(None); } if self.disk_differs(&m, true)? { return Ok(None); } // Test builds check the cache against a snapshot of the files. #[cfg(test)] { let snap = self.snapshot_status("status cache check")?; let key = |v: &[PathStatus]| { let mut k: Vec<(String, u8)> = v .iter() .map(|p| (p.path.clone(), status_byte(p.status))) .collect(); k.sort(); k }; assert_eq!( key(&statuses), key(&snap.statuses), "status cache disagrees with a snapshot" ); } Ok(Some(statuses)) } fn status_cache_path(&self) -> PathBuf { self.manifest_path().with_file_name("status") } /// The manifest file's checksum, which names its content. fn manifest_stamp(&self) -> Option<[u8; 32]> { use std::io::Read; let mut head = [0u8; MANIFEST_SUM.end]; fs::File::open(self.manifest_path()) .and_then(|mut f| f.read_exact(&mut head)) .ok()?; head[MANIFEST_SUM].try_into().ok() } /// Record `statuses` as the status of working-copy change `wc` against /// the manifest as it is now; with no uncommitted work there is nothing /// to record, and the cache goes. pub(crate) fn write_status_cache(&self, wc: Option<&ChangeHash>, statuses: &[PathStatus]) { let path = self.status_cache_path(); let (Some(wc), Some(stamp)) = (wc, self.manifest_stamp()) else { let _ = fs::remove_file(&path); return; }; let _ = write_cache(&path, &encode_status_cache(wc, &stamp, statuses)); } /// Whether the working copy differs from the last manifest. Without a /// manifest the answer is `None`. pub fn quick_dirty(&self) -> Result> { self.quick_dirty_keeping(false) } /// `quick_dirty`; with `keep`, the listing it made is left for the /// next walk of the working copy. fn quick_dirty_keeping(&self, keep: bool) -> Result> { let Some((_, uncommitted, manifest)) = self.read_manifest()? else { return Ok(None); }; if uncommitted { return Ok(Some(true)); } Ok(Some(self.disk_differs(&manifest, keep)?)) } /// Whether the working copy differs from `manifest`: a file added, /// gone or changed. With `keep`, the listing made is left for the next /// walk of the working copy when it does. fn disk_differs(&self, manifest: &Manifest, keep: bool) -> Result { // A file written within the racy window can change without its size // or time moving (a coarse clock): those entries are hashed. One // that changed still lets the directory check below hand its stats // to the snapshot that follows. let racy = self.racy_change(manifest).is_some(); if let Some(differs) = self.disk_differs_by_dirs_keeping(manifest, keep, racy) { return Ok(differs || racy); } if racy { return Ok(true); } self.disk_differs_listing(manifest, keep) } /// [`Repo::disk_differs`] without listing the tree, when the manifest /// knows its directories: a directory's time moves when an entry is /// added, removed or renamed in it, so with every directory's time as /// recorded (and none within the racy window) the files are the /// manifest's, and each is checked by its stat. `None` when a /// directory changed or the manifest has none: the tree must be listed. #[cfg(test)] pub(crate) fn disk_differs_by_dirs(&self, manifest: &Manifest) -> Option { self.disk_differs_by_dirs_keeping(manifest, false, false) } /// [`Repo::disk_differs_by_dirs`]; with `keep`, when a file differs /// (or `racy` says one does), the stats taken become the listing of the /// snapshot that follows: no directory's entries changed, so the /// manifest's files are the tree's. fn disk_differs_by_dirs_keeping( &self, manifest: &Manifest, keep: bool, racy: bool, ) -> Option { use rayon::prelude::*; if manifest.dirs.is_empty() || !self.config.sparse_paths.is_empty() { return None; } let _t = crate::timing::phase("status: directories and files by stat"); let root = &self.root; let dirs_same = manifest.dirs.par_iter().all(|(rel, t)| { *t + 2_000_000_000 < manifest.written_ns && fs::metadata(root.join(rel)).is_ok_and(|m| m.is_dir() && mtime_ns(&m) == *t) }); if !dirs_same { return None; } let rels: Vec<&String> = manifest.entries.keys().collect(); let now = stat_tracked(root, &rels); let differs_from = |me: &ManifestEntry, rel: &str, now: &Tracked| match now { Tracked::Link(t) => *blake3::hash(t).as_bytes() != me.hash, Tracked::File(m) => { m.size != me.size || m.exec != me.exec || (m.mtime_ns != me.mtime_ns && !fs::read(root.join(rel)) .is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash)) } }; // A path gone or no longer a file: its directory moved, unless // within a clock tick; list the tree to be sure. if now.iter().any(Option::is_none) { return None; } // The entries in the order `rels` was taken in: no lookup by path. let entries: Vec<&ManifestEntry> = manifest.entries.values().collect(); let differs = rels .par_iter() .zip(entries.par_iter()) .zip(now.par_iter()) .any(|((rel, me), n)| differs_from(me, rel, n.as_ref().unwrap())); if keep && (differs || racy) { let mut listing = super::walk::Listing { dirs: manifest.dirs.clone(), nested: manifest.nested.iter().map(|n| root.join(n)).collect(), ..Default::default() }; for (rel, n) in rels.into_iter().zip(now) { match n.unwrap() { Tracked::File(m) => listing.files.push((rel.clone(), root.join(rel), m)), Tracked::Link(t) => listing.symlinks.push((rel.clone(), t)), } } *self.listing_cache.borrow_mut() = Some(listing); } if !differs { // Nested repositories the manifest has no entry for are not // subrepos: nothing in them is tracked (oxygen#43). *self.untracked_nested.borrow_mut() = manifest .nested .iter() .filter(|rel| !manifest.entries.contains_key(*rel)) .cloned() .collect(); } Some(differs) } /// [`Repo::disk_differs`] by listing the tree. fn disk_differs_listing(&self, manifest: &Manifest, keep: bool) -> Result { use rayon::prelude::*; let sparse = &self.config.sparse_paths; let in_scope = |p: &str| { sparse.is_empty() || sparse .iter() .any(|s| p == s || p.starts_with(&format!("{s}/"))) }; // A working copy that cannot be listed counts as changed. // Known files are not matched against the ignore rules: with no // uncommitted work (the clean check), the manifest's files are the // base's, and a prompt's answer is the same either way. let known = |p: &str| manifest.entries.contains_key(p); let Ok(listing) = super::walk::list_knowing(&self.root, &in_scope, &known) else { return Ok(true); }; let differs = || -> Result { let links_differ = listing.symlinks.iter().any(|(rel, target)| { manifest .entries .get(rel) .is_none_or(|me| *blake3::hash(target).as_bytes() != me.hash) }); if links_differ { return Ok(true); } // Each file is checked on its own, in parallel, stopping at the // first difference; an unreadable one counts as different. let files_differ = listing.files.par_iter().any(|(rel, path, m)| { manifest.entries.get(rel).is_none_or(|me| { m.size != me.size || m.exec != me.exec || (m.mtime_ns != me.mtime_ns && !fs::read(path) .is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash)) }) }); if files_differ { return Ok(true); } let seen = listing.files.len() + listing.symlinks.len(); if seen == manifest.entries.len() { return Ok(false); } // Manifest entries the walk skipped: tracked files that ignore // rules hide (Linux tracks some its own .gitignore names). Found // by merging the two sorted lists; they count as present when // still on disk and unchanged, checked on every core. let mut listed: Vec<&str> = listing .files .iter() .map(|(r, _, _)| r.as_str()) .chain(listing.symlinks.iter().map(|(r, _)| r.as_str())) .collect(); listed.sort_unstable(); let mut at = listed.iter().peekable(); let skipped: Vec<(&String, &ManifestEntry)> = manifest .entries .iter() .filter(|(rel, _)| { while at.next_if(|l| **l < rel.as_str()).is_some() {} at.peek().is_none_or(|l| **l != rel.as_str()) }) .collect(); let root = &self.root; Ok(skipped.par_iter().any(|(rel, me)| { let path = root.join(rel.as_str()); let Ok(m) = fs::symlink_metadata(&path) else { return true; }; !m.file_type().is_symlink() && (m.len() != me.size || super::is_executable(&m) != me.exec || (mtime_ns(&m) != me.mtime_ns && !fs::read(&path) .is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash))) })) }; let dirty = differs()?; if !dirty { // Nested repositories the manifest has no entry for are not // subrepos: nothing in them is tracked (oxygen#43). *self.untracked_nested.borrow_mut() = listing .nested .iter() .map(|d| self.rel_path(d)) .filter(|rel| !manifest.entries.contains_key(rel)) .collect(); } if dirty && keep { *self.listing_cache.borrow_mut() = Some(listing); } Ok(dirty) } /// 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(&self.default_remote()) .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"); } }