log --path and touches() without decoding all of history 170bb436e1d5
oxy log --path rendered every file of the tree twice to find the path,
and both it and touches() decoded every change of the history, its text
included, to see what each one touched. The path now comes from the
tree alone (the base from the workspace's state index), and the nodes
each change touches are kept in .oxy/index/touched: derived, checked
with a BLAKE3 sum, rebuilt when missing or damaged, and never holding a
working-copy change, which every snapshot replaces. A first run decodes
change skeletons only, on every core.
VS Code at 19,978 commits: log --path package.json 53.6 s to 0.16 s
(0.47 s building the cache), touches("package.json") 3.4 s to 0.16 s,
touches("src/vs/base/**") 3.4 s to 0.2 s; output unchanged.
closes #91
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-09 21:39 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
170bb436e1d529fb62a41b18bbccdc0f1deb6dee13e8dec207c6165ab859eb0b- Depends on
- 47ce8b8e2d4b 75167672ebcd 76740263089c 8fd78eb52ad2 982864068a59 bc489b6c7f69 c6aa5bd9d1c8 d0bf2da15094 e02bf0cd0533 f6dccc4533c8 ff0af1ee299d
- Depended on by
- c2e1d09047db 4b9d06778581 e2a5edbf1395 d6e0d5b8b339 aedfe465b611 cb8b8082fa73 5552e69c7325 e8e6e0cac26a 79b5a90ffd43 7505194116e7 19303b4a06fb
- In bookmarks
- bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.10.0 v0.11.0 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 15 atoms, 1 tree operation, 7 files
Browse the tree at this change
added crates/oxy-core/src/repo/touched.rs +163 −0
| 1 | + | //! The nodes each change touches, kept beside the store (oxygen#91). |
|
| 2 | + | //! |
|
| 3 | + | //! Finding the changes that touched a file (`oxy log --path`, the |
|
| 4 | + | //! `touches()` revset) otherwise reads and decodes every change of the |
|
| 5 | + | //! history, its lines included. A change is immutable, so its entry never |
|
| 6 | + | //! goes stale: one missing is read from the store once and added. Like |
|
| 7 | + | //! Git's changed-path filters it is derived data, and deleting |
|
| 8 | + | //! `.oxy/index/touched` loses nothing but time. |
|
| 9 | + | ||
| 10 | + | use super::*; |
|
| 11 | + | use crate::fasthash::FastMap; |
|
| 12 | + | use crate::ids::NodeId; |
|
| 13 | + | ||
| 14 | + | /// The cache, loaded; [`Touched::save`] writes it back when it grew. |
|
| 15 | + | pub(crate) struct Touched { |
|
| 16 | + | nodes: FastMap<ChangeHash, Box<[NodeId]>>, |
|
| 17 | + | /// Working-copy changes, which a snapshot replaces: kept for this |
|
| 18 | + | /// command only, so the cache does not fill with dead entries. |
|
| 19 | + | transient: FastMap<ChangeHash, Box<[NodeId]>>, |
|
| 20 | + | added: usize, |
|
| 21 | + | path: PathBuf, |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | impl Touched { |
|
| 25 | + | /// The nodes `h` touches, from the cache or else from the store; `None` |
|
| 26 | + | /// when the change is not stored (beyond a shallow boundary). |
|
| 27 | + | pub(crate) fn of(&mut self, repo: &Repo, h: &ChangeHash) -> Option<&[NodeId]> { |
|
| 28 | + | if !self.nodes.contains_key(h) && !self.transient.contains_key(h) { |
|
| 29 | + | // The skeleton names every node; no text is read. |
|
| 30 | + | let (c, _) = repo.store.get_change_lazy(h).ok()?; |
|
| 31 | + | self.keep(*h, &c); |
|
| 32 | + | } |
|
| 33 | + | self.nodes |
|
| 34 | + | .get(h) |
|
| 35 | + | .or_else(|| self.transient.get(h)) |
|
| 36 | + | .map(|n| &n[..]) |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | fn keep(&mut self, h: ChangeHash, c: &Change) { |
|
| 40 | + | if c.kind == ChangeKind::WorkingCopy { |
|
| 41 | + | self.transient.insert(h, nodes_of(c)); |
|
| 42 | + | } else { |
|
| 43 | + | self.nodes.insert(h, nodes_of(c)); |
|
| 44 | + | self.added += 1; |
|
| 45 | + | } |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | /// Read every one of `hashes` the cache lacks. |
|
| 49 | + | pub(crate) fn fill(&mut self, repo: &Repo, hashes: &[ChangeHash]) { |
|
| 50 | + | use rayon::prelude::*; |
|
| 51 | + | let missing: Vec<ChangeHash> = hashes |
|
| 52 | + | .iter() |
|
| 53 | + | .filter(|h| !self.nodes.contains_key(*h) && !self.transient.contains_key(*h)) |
|
| 54 | + | .copied() |
|
| 55 | + | .collect(); |
|
| 56 | + | // Read a batch at a time, then decode skeletons (no text) on every |
|
| 57 | + | // core. A change the store lacks is left out, as `of` leaves it. |
|
| 58 | + | for batch in missing.chunks(8192) { |
|
| 59 | + | let read: Vec<(ChangeHash, Change)> = repo |
|
| 60 | + | .store |
|
| 61 | + | .get_many(batch) |
|
| 62 | + | .into_par_iter() |
|
| 63 | + | .zip(batch.par_iter()) |
|
| 64 | + | .filter_map(|(got, h)| match got { |
|
| 65 | + | Ok((ObjectType::Change, bytes)) => { |
|
| 66 | + | Some((*h, Change::decode_skeleton(&bytes, *h).ok()?.0)) |
|
| 67 | + | } |
|
| 68 | + | _ => None, |
|
| 69 | + | }) |
|
| 70 | + | .collect(); |
|
| 71 | + | for (h, c) in &read { |
|
| 72 | + | self.keep(*h, c); |
|
| 73 | + | } |
|
| 74 | + | } |
|
| 75 | + | } |
|
| 76 | + | ||
| 77 | + | /// Whether `h` touches any of `nodes` (false when it is not stored). |
|
| 78 | + | pub(crate) fn touches(&mut self, repo: &Repo, h: &ChangeHash, nodes: &HashSet<NodeId>) -> bool { |
|
| 79 | + | self.of(repo, h) |
|
| 80 | + | .is_some_and(|n| n.iter().any(|n| nodes.contains(n))) |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | /// Write the cache back if it grew; quietly nothing when it cannot be. |
|
| 84 | + | pub(crate) fn save(self) { |
|
| 85 | + | if self.added == 0 { |
|
| 86 | + | return; |
|
| 87 | + | } |
|
| 88 | + | let _t = crate::timing::phase("write: touched nodes"); |
|
| 89 | + | let mut w = crate::flat::Writer::default(); |
|
| 90 | + | w.count(self.nodes.len()); |
|
| 91 | + | for (h, nodes) in &self.nodes { |
|
| 92 | + | w.object(h); |
|
| 93 | + | w.count(nodes.len()); |
|
| 94 | + | for n in nodes.iter() { |
|
| 95 | + | w.fixed(&n.0); |
|
| 96 | + | } |
|
| 97 | + | } |
|
| 98 | + | // Checked whole on reading: a damaged entry would hide a change. |
|
| 99 | + | let sum = *blake3::hash(&w.0).as_bytes(); |
|
| 100 | + | w.fixed(&sum); |
|
| 101 | + | let _ = fs::create_dir_all(self.path.parent().unwrap_or(&self.path)); |
|
| 102 | + | let _ = write_cache(&self.path, &w.0); |
|
| 103 | + | } |
|
| 104 | + | } |
|
| 105 | + | ||
| 106 | + | /// The nodes a change's tree operations and edits name, each once. |
|
| 107 | + | fn nodes_of(c: &Change) -> Box<[NodeId]> { |
|
| 108 | + | let mut nodes: Vec<NodeId> = c |
|
| 109 | + | .tree_ops |
|
| 110 | + | .iter() |
|
| 111 | + | .map(|o| o.node()) |
|
| 112 | + | .chain(c.edits.iter().map(|e| e.node())) |
|
| 113 | + | .collect(); |
|
| 114 | + | nodes.sort(); |
|
| 115 | + | nodes.dedup(); |
|
| 116 | + | nodes.into_boxed_slice() |
|
| 117 | + | } |
|
| 118 | + | ||
| 119 | + | impl Repo { |
|
| 120 | + | /// The touched-nodes cache: empty when there is none yet or it does |
|
| 121 | + | /// not read back whole. |
|
| 122 | + | pub(crate) fn touched(&self) -> Touched { |
|
| 123 | + | let _t = crate::timing::phase("load: touched nodes"); |
|
| 124 | + | let path = self.oxy.join("index").join("touched"); |
|
| 125 | + | let nodes = fs::read(&path) |
|
| 126 | + | .ok() |
|
| 127 | + | .and_then(|b| read_touched(&b)) |
|
| 128 | + | .unwrap_or_default(); |
|
| 129 | + | Touched { |
|
| 130 | + | nodes, |
|
| 131 | + | transient: FastMap::default(), |
|
| 132 | + | added: 0, |
|
| 133 | + | path, |
|
| 134 | + | } |
|
| 135 | + | } |
|
| 136 | + | } |
|
| 137 | + | ||
| 138 | + | fn read_touched(bytes: &[u8]) -> Option<FastMap<ChangeHash, Box<[NodeId]>>> { |
|
| 139 | + | let (body, sum) = bytes.split_at_checked(bytes.len().checked_sub(32)?)?; |
|
| 140 | + | if blake3::hash(body).as_bytes() != sum { |
|
| 141 | + | return None; |
|
| 142 | + | } |
|
| 143 | + | let mut r = crate::flat::Reader::new(body); |
|
| 144 | + | let n = r.count().ok()?; |
|
| 145 | + | let mut out = FastMap::default(); |
|
| 146 | + | out.reserve(n); |
|
| 147 | + | for _ in 0..n { |
|
| 148 | + | let h = r.object().ok()?; |
|
| 149 | + | let k = r.count().ok()?; |
|
| 150 | + | let mut nodes = Vec::with_capacity(k); |
|
| 151 | + | for _ in 0..k { |
|
| 152 | + | nodes.push(NodeId(r.fixed::<16>().ok()?)); |
|
| 153 | + | } |
|
| 154 | + | out.insert(h, nodes.into_boxed_slice()); |
|
| 155 | + | } |
|
| 156 | + | r.at_end().then_some(out) |
|
| 157 | + | } |
|
| 158 | + | ||
| 159 | + | /// The changes a cache file holds, for tests. |
|
| 160 | + | #[cfg(test)] |
|
| 161 | + | pub(crate) fn read_touched_for_test(bytes: &[u8]) -> Option<HashSet<ChangeHash>> { |
|
| 162 | + | read_touched(bytes).map(|m| m.into_keys().collect()) |
|
| 163 | + | } |
modified CHANGELOG.md +4 −0
| 1 | 1 | # Changelog |
|
| 2 | 2 | ||
| 3 | + | ## Unreleased |
|
| 4 | + | ||
| 5 | + | - **A file's history in a fraction of a second (#91).** `oxy log --path` rendered every file of the tree twice just to find the path, and both it and the `touches()` revset decoded every change of the history, its text included, to see what each one touched. They now read the path from the tree alone and keep the nodes each change touches in a cache beside the store (`.oxy/index/touched`, rebuilt if missing). On VS Code at 20 000 commits, `log --path package.json` went from 53.6 s to 0.16 s (Git: 0.15 s) and `touches("package.json")` from 3.4 s to 0.16 s, with the same output. |
|
| 6 | + | ||
| 3 | 7 | ## 0.4.2 (2026-10-09) |
|
| 4 | 8 | ||
| 5 | 9 | - **`pull` brings a working copy that fell behind up to its bookmark (#89).** The working copy followed a pull only from exactly the bookmark's old heads, and not at all when the pull brought nothing new, so one left behind by `bookmark set`, a landing or a pull from another workspace stayed on old code while `status` said clean. It now follows whenever its base is part of the bookmark's state, clean or carrying uncommitted work, and `status` says when the working copy is behind its bookmark and by how many changes. |
|
| … | 144 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +1 −0
| … | 127 unchanged lines |
||
| 128 | 128 | mod tags; |
|
| 129 | 129 | #[cfg(test)] |
|
| 130 | 130 | mod tests; |
|
| 131 | + | mod touched; |
|
| 131 | 132 | mod walk; |
|
| 132 | 133 | mod workspace; |
|
| 133 | 134 | ||
| … | 2153 unchanged lines |
modified crates/oxy-core/src/repo/revset.rs +17 −15
| … | 267 unchanged lines |
||
| 268 | 268 | .build() |
|
| 269 | 269 | .map_err(|e| err(format!("touches({glob:?}): {e}")))? |
|
| 270 | 270 | .compile_matcher(); |
|
| 271 | - | let heads = match self.working_copy() { |
|
| 272 | - | Some(wc) => self |
|
| 273 | - | .graph |
|
| 274 | - | .get(&wc) |
|
| 275 | - | .map(|m| m.deps.clone()) |
|
| 276 | - | .or_else(|| self.repo.working_copy().ok().map(|(_, c)| c.deps)) |
|
| 277 | - | .unwrap_or_default(), |
|
| 278 | - | None => self.repo.bookmark_heads()?, |
|
| 271 | + | // The tree alone names the nodes: no file graph is built. The |
|
| 272 | + | // working copy's base comes from the workspace's state index. |
|
| 273 | + | let state = match self.working_copy() { |
|
| 274 | + | Some(wc) => { |
|
| 275 | + | let deps = self |
|
| 276 | + | .graph |
|
| 277 | + | .get(&wc) |
|
| 278 | + | .map(|m| m.deps.clone()) |
|
| 279 | + | .or_else(|| self.repo.working_copy().ok().map(|(_, c)| c.deps)) |
|
| 280 | + | .unwrap_or_default(); |
|
| 281 | + | self.repo.load_state_indexed(&deps)? |
|
| 282 | + | } |
|
| 283 | + | None => self.repo.load_state_history(&self.repo.bookmark_heads()?)?, |
|
| 279 | 284 | }; |
|
| 280 | - | let state = self.repo.load_state(&heads)?; |
|
| 281 | 285 | let nodes: HashSet<NodeId> = state |
|
| 282 | 286 | .tree() |
|
| 283 | 287 | .view() |
|
| … | 6 unchanged lines |
||
| 290 | 294 | return Ok(Set::new()); |
|
| 291 | 295 | } |
|
| 292 | 296 | let candidates = self.universe(within)?; |
|
| 297 | + | let mut touched = self.repo.touched(); |
|
| 298 | + | touched.fill(self.repo, &candidates.iter().copied().collect::<Vec<_>>()); |
|
| 293 | 299 | let mut out = Set::new(); |
|
| 294 | 300 | for (i, h) in candidates.iter().enumerate() { |
|
| 295 | - | let Ok(c) = self.repo.store().get_change(h) else { |
|
| 296 | - | continue; |
|
| 297 | - | }; |
|
| 298 | - | if c.tree_ops.iter().any(|o| nodes.contains(&o.node())) |
|
| 299 | - | || c.edits.iter().any(|e| nodes.contains(&e.node())) |
|
| 300 | - | { |
|
| 301 | + | if touched.touches(self.repo, h, &nodes) { |
|
| 301 | 302 | out.insert(*h); |
|
| 302 | 303 | } |
|
| 303 | 304 | if i % 256 == 255 { |
|
| 304 | 305 | self.tick()?; |
|
| 305 | 306 | } |
|
| 306 | 307 | } |
|
| 308 | + | touched.save(); |
|
| 307 | 309 | Ok(out) |
|
| 308 | 310 | } |
|
| 309 | 311 | ||
| … | 453 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +16 −13
| … | 1701 unchanged lines |
||
| 1702 | 1702 | } |
|
| 1703 | 1703 | let entries = self.log()?; |
|
| 1704 | 1704 | let (_, wc) = self.working_copy()?; |
|
| 1705 | - | let base_snapshot = self.load_state(&wc.deps)?.materialize(); |
|
| 1706 | - | let state_snapshot = self.load_state(&heads)?.materialize(); |
|
| 1707 | - | let node = base_snapshot |
|
| 1708 | - | .files |
|
| 1709 | - | .get(path) |
|
| 1710 | - | .or_else(|| state_snapshot.files.get(path)) |
|
| 1711 | - | .map(|f| f.node); |
|
| 1705 | + | // The path's node from the trees alone: the base's (from the |
|
| 1706 | + | // workspace's state index), else the bookmark's. Nothing is |
|
| 1707 | + | // rendered. |
|
| 1708 | + | let node_in = |state: State| state.tree().view().entries.get(path).map(|e| e.node); |
|
| 1709 | + | let node = match node_in(self.load_state_indexed(&wc.deps)?) { |
|
| 1710 | + | Some(n) => Some(n), |
|
| 1711 | + | None => node_in(self.load_state_history(&heads)?), |
|
| 1712 | + | }; |
|
| 1712 | 1713 | let Some(node) = node else { |
|
| 1713 | 1714 | return fail(format!("{path} is not tracked")); |
|
| 1714 | 1715 | }; |
|
| … | 31 unchanged lines |
||
| 1746 | 1747 | node: NodeId, |
|
| 1747 | 1748 | limit: Option<usize>, |
|
| 1748 | 1749 | ) -> Vec<LogEntry> { |
|
| 1750 | + | let mut touched = self.touched(); |
|
| 1751 | + | if limit.is_none() { |
|
| 1752 | + | let hashes: Vec<ChangeHash> = entries.iter().map(|e| e.hash).collect(); |
|
| 1753 | + | touched.fill(self, &hashes); |
|
| 1754 | + | } |
|
| 1755 | + | let wanted = HashSet::from([node]); |
|
| 1749 | 1756 | let mut out = Vec::new(); |
|
| 1750 | 1757 | for e in entries { |
|
| 1751 | 1758 | if limit.is_some_and(|n| out.len() >= n) { |
|
| 1752 | 1759 | break; |
|
| 1753 | 1760 | } |
|
| 1754 | - | let Ok(c) = self.store.get_change(&e.hash) else { |
|
| 1755 | - | continue; |
|
| 1756 | - | }; |
|
| 1757 | - | if c.tree_ops.iter().any(|o| o.node() == node) |
|
| 1758 | - | || c.edits.iter().any(|x| x.node() == node) |
|
| 1759 | - | { |
|
| 1761 | + | if touched.touches(self, &e.hash, &wanted) { |
|
| 1760 | 1762 | out.push(e); |
|
| 1761 | 1763 | } |
|
| 1762 | 1764 | } |
|
| 1765 | + | touched.save(); |
|
| 1763 | 1766 | out |
|
| 1764 | 1767 | } |
|
| 1765 | 1768 | } |
|
| … | 1234 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +48 −0
| … | 7931 unchanged lines |
||
| 7932 | 7932 | assert!(root.join("one/b.txt").exists()); |
|
| 7933 | 7933 | assert_eq!(one.working_copy_behind().unwrap(), None); |
|
| 7934 | 7934 | } |
|
| 7935 | + | ||
| 7936 | + | #[test] |
|
| 7937 | + | fn touched_nodes_cache_answers_as_the_changes_do() { |
|
| 7938 | + | hermetic(); |
|
| 7939 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 7940 | + | let repo = Repo::init(dir.path()).unwrap(); |
|
| 7941 | + | write(dir.path(), "a.txt", b"one\n"); |
|
| 7942 | + | write(dir.path(), "b.txt", b"x\n"); |
|
| 7943 | + | repo.snapshot("t").unwrap(); |
|
| 7944 | + | let c1 = repo.commit("first", &[]).unwrap(); |
|
| 7945 | + | write(dir.path(), "b.txt", b"y\n"); |
|
| 7946 | + | repo.snapshot("t").unwrap(); |
|
| 7947 | + | repo.commit("b only", &[]).unwrap(); |
|
| 7948 | + | fs::rename(dir.path().join("a.txt"), dir.path().join("c.txt")).unwrap(); |
|
| 7949 | + | write(dir.path(), "c.txt", b"one\ntwo\n"); |
|
| 7950 | + | repo.snapshot("t").unwrap(); |
|
| 7951 | + | let c3 = repo.commit("rename and edit", &[]).unwrap(); |
|
| 7952 | + | // Uncommitted work, so the working copy touches a file too. |
|
| 7953 | + | write(dir.path(), "c.txt", b"one\ntwo\nthree\n"); |
|
| 7954 | + | repo.snapshot("t").unwrap(); |
|
| 7955 | + | ||
| 7956 | + | let path = dir.path().join(".oxy/index/touched"); |
|
| 7957 | + | let hashes = |es: Vec<LogEntry>| es.iter().map(|e| e.hash).collect::<Vec<_>>(); |
|
| 7958 | + | let cold = hashes(repo.log_for_path("c.txt").unwrap()); |
|
| 7959 | + | assert!(path.exists(), "the first query writes the cache"); |
|
| 7960 | + | let warm = hashes(repo.log_for_path("c.txt").unwrap()); |
|
| 7961 | + | assert_eq!(cold, warm, "the cache answers as the changes do"); |
|
| 7962 | + | let (wc, _) = repo.working_copy().unwrap(); |
|
| 7963 | + | assert_eq!( |
|
| 7964 | + | cold, |
|
| 7965 | + | vec![wc, c3, c1], |
|
| 7966 | + | "renames followed, the working copy included" |
|
| 7967 | + | ); |
|
| 7968 | + | ||
| 7969 | + | // Working-copy changes are not kept: they are replaced by every snapshot. |
|
| 7970 | + | let cached = super::touched::read_touched_for_test(&fs::read(&path).unwrap()).unwrap(); |
|
| 7971 | + | assert!(!cached.contains(&wc), "a working-copy change is not cached"); |
|
| 7972 | + | assert!(cached.contains(&c1) && cached.contains(&c3)); |
|
| 7973 | + | ||
| 7974 | + | // A damaged cache is ignored, not trusted. |
|
| 7975 | + | let mut bytes = fs::read(&path).unwrap(); |
|
| 7976 | + | let last = bytes.len() - 40; |
|
| 7977 | + | bytes[last] ^= 1; |
|
| 7978 | + | fs::write(&path, &bytes).unwrap(); |
|
| 7979 | + | assert_eq!(hashes(repo.log_for_path("c.txt").unwrap()), cold); |
|
| 7980 | + | let touches = repo.log_revset("touches(\"c.txt\") ~ @").unwrap(); |
|
| 7981 | + | assert_eq!(hashes(touches), vec![c3, c1]); |
|
| 7982 | + | } |
modified docs/spec.md +2 −0
| … | 518 unchanged lines |
||
| 519 | 519 | ops/ operation objects and op heads |
|
| 520 | 520 | refs/ current view cache (derived from ops; rebuildable) |
|
| 521 | 521 | workspaces/<id>/ snapshot cache, fsmonitor cookie |
|
| 522 | + | index/ derived caches, rebuilt when missing or damaged: each |
|
| 523 | + | workspace's state indexes, and the nodes each change touches |
|
| 522 | 524 | policy/ cached policy and identities |
|
| 523 | 525 | ``` |
|
| 524 | 526 | ||
| … | 526 unchanged lines |