OXYGENHUB

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