Log takes the state index's changes in order; a clean diff loads no base 74b4a3c62ea4
The state index now keeps its changes in log order (newest first), and the log reads them as a list: no map of 20 000 entries, no Arc each, and no sort when the order already holds (an older index is sorted as before). A diff whose working-copy change changes nothing, while the status cache holds, is empty without loading the base's tree and file table. On the 19,978-commit VS Code history, under load on Earth: diff 65 to 36 ms (now about status alone), log 42 to 36 ms, the sort 3.75 to 0.26 ms. Refs #94
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-17 03:09 UTC
- Signature
- ✔ signed by Claude Code
- Hash
74b4a3c62ea49a14291109dfc144c7f840f6af35c6a9e8f150ce53528fdf6e04- Depends on
- 10b4fd430eab 252e8cb23fbc 4cce912a0953 4d6c9c1b358c 75167672ebcd 77445673174b 79b5a90ffd43 982864068a59 ac03130a2b95 bf0247ee3590 c6aa5bd9d1c8 ce14f0d68b15 dd6147dcf9c2 f9575918477f fce44ae9ad29 ff0af1ee299d
- Depended on by
- nothing yet
- In bookmarks
- carried-not-removed doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state
- In tags
- v0.10.0 v0.11.0 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
- 29 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified crates/oxy-core/src/repo/mod.rs +36 −22
| … | 1507 unchanged lines |
||
| 1508 | 1508 | #[allow(clippy::type_complexity)] |
|
| 1509 | 1509 | pub(crate) fn read_state_index_changes( |
|
| 1510 | 1510 | &self, |
|
| 1511 | - | ) -> Option<( |
|
| 1512 | - | Vec<ChangeHash>, |
|
| 1513 | - | crate::fasthash::FastMap<ChangeHash, std::sync::Arc<crate::state::ChangeMeta>>, |
|
| 1514 | - | )> { |
|
| 1511 | + | ) -> Option<(Vec<ChangeHash>, Vec<(ChangeHash, crate::state::ChangeMeta)>)> { |
|
| 1515 | 1512 | use std::io::Read; |
|
| 1516 | 1513 | let _t = crate::timing::phase("load: state index changes"); |
|
| 1517 | 1514 | let mut f = fs::File::open(self.state_indexes().first()?).ok()?; |
|
| … | 19 unchanged lines |
||
| 1537 | 1534 | } |
|
| 1538 | 1535 | drop(checking); |
|
| 1539 | 1536 | let _decoding = crate::timing::phase("load: of which decoding"); |
|
| 1540 | - | Some((heads, State::read_index_changes(&changes[4..]).ok()?)) |
|
| 1537 | + | Some((heads, State::read_index_changes_listed(&changes[4..]).ok()?)) |
|
| 1541 | 1538 | } |
|
| 1542 | 1539 | ||
| 1543 | 1540 | /// Save `st`, the history-only state of `deps`, as this workspace's |
|
| … | 342 unchanged lines |
||
| 1886 | 1883 | let _t = crate::timing::phase("log: metadata"); |
|
| 1887 | 1884 | let mut metas = self.metas_of(&below)?; |
|
| 1888 | 1885 | if !metas.iter().any(|(h, _)| *h == wc_hash) { |
|
| 1889 | - | metas.push((wc_hash, crate::state::ChangeMeta::of(&wc))); |
|
| 1886 | + | // First, where the log puts it, so the order may hold. |
|
| 1887 | + | metas.insert(0, (wc_hash, crate::state::ChangeMeta::of(&wc))); |
|
| 1890 | 1888 | } |
|
| 1891 | 1889 | metas |
|
| 1892 | 1890 | }; |
|
| … | 7 unchanged lines |
||
| 1900 | 1898 | } |
|
| 1901 | 1899 | let _sorting = crate::timing::phase("log: sort"); |
|
| 1902 | 1900 | // The hash makes the order total, so an unstable sort gives the same. |
|
| 1903 | - | entries.sort_unstable_by(|a, b| { |
|
| 1901 | + | // From the state index the order already holds (oxygen#94). |
|
| 1902 | + | let order = |a: &LogEntry, b: &LogEntry| { |
|
| 1904 | 1903 | b.is_working_copy |
|
| 1905 | 1904 | .cmp(&a.is_working_copy) |
|
| 1906 | - | .then(b.change.hlc.cmp(&a.change.hlc)) |
|
| 1907 | - | .then(b.hash.cmp(&a.hash)) |
|
| 1908 | - | }); |
|
| 1905 | + | .then(crate::state::log_order( |
|
| 1906 | + | (&a.hash, &a.change.hlc), |
|
| 1907 | + | (&b.hash, &b.change.hlc), |
|
| 1908 | + | )) |
|
| 1909 | + | }; |
|
| 1910 | + | if !entries.is_sorted_by(|a, b| order(a, b).is_le()) { |
|
| 1911 | + | entries.sort_unstable_by(order); |
|
| 1912 | + | } |
|
| 1909 | 1913 | Ok(entries) |
|
| 1910 | 1914 | } |
|
| 1911 | 1915 | ||
| … | 31 unchanged lines |
||
| 1943 | 1947 | is_working_copy: false, |
|
| 1944 | 1948 | }) |
|
| 1945 | 1949 | .collect(); |
|
| 1946 | - | entries.sort_unstable_by(|a, b| b.change.hlc.cmp(&a.change.hlc).then(b.hash.cmp(&a.hash))); |
|
| 1950 | + | let order = |a: &LogEntry, b: &LogEntry| { |
|
| 1951 | + | crate::state::log_order((&a.hash, &a.change.hlc), (&b.hash, &b.change.hlc)) |
|
| 1952 | + | }; |
|
| 1953 | + | if !entries.is_sorted_by(|a, b| order(a, b).is_le()) { |
|
| 1954 | + | entries.sort_unstable_by(order); |
|
| 1955 | + | } |
|
| 1947 | 1956 | Ok(entries) |
|
| 1948 | 1957 | } |
|
| 1949 | 1958 | ||
| … | 14 unchanged lines |
||
| 1964 | 1973 | // (it is closed), and no walk is needed to find it. |
|
| 1965 | 1974 | if let Some((index_heads, all)) = &read { |
|
| 1966 | 1975 | let asked: crate::fasthash::FastSet<&ChangeHash> = heads.iter().collect(); |
|
| 1967 | - | if heads.iter().all(|h| all.contains_key(h)) |
|
| 1968 | - | && index_heads.iter().all(|h| asked.contains(h)) |
|
| 1969 | - | { |
|
| 1976 | + | let in_index = || { |
|
| 1977 | + | let index_heads: crate::fasthash::FastSet<&ChangeHash> = |
|
| 1978 | + | index_heads.iter().collect(); |
|
| 1979 | + | // Usually the heads asked are the index's: no set of all. |
|
| 1980 | + | heads.iter().all(|h| index_heads.contains(h)) || { |
|
| 1981 | + | let all: crate::fasthash::FastSet<&ChangeHash> = |
|
| 1982 | + | all.iter().map(|(h, _)| h).collect(); |
|
| 1983 | + | heads.iter().all(|h| all.contains(h)) |
|
| 1984 | + | } |
|
| 1985 | + | }; |
|
| 1986 | + | if index_heads.iter().all(|h| asked.contains(h)) && in_index() { |
|
| 1970 | 1987 | let (_, all) = read.expect("matched above"); |
|
| 1971 | 1988 | #[cfg(test)] |
|
| 1972 | 1989 | { |
|
| … | 5 unchanged lines |
||
| 1978 | 1995 | stack.extend(self.store.get_change_meta(&h)?.deps); |
|
| 1979 | 1996 | } |
|
| 1980 | 1997 | } |
|
| 1981 | - | assert_eq!(seen, all.keys().copied().collect::<HashSet<_>>()); |
|
| 1998 | + | assert_eq!(seen, all.iter().map(|(h, _)| *h).collect::<HashSet<_>>()); |
|
| 1982 | 1999 | } |
|
| 1983 | - | return Ok(all |
|
| 1984 | - | .into_iter() |
|
| 1985 | - | .map(|(h, m)| (h, std::sync::Arc::unwrap_or_clone(m))) |
|
| 1986 | - | .collect()); |
|
| 2000 | + | return Ok(all); |
|
| 1987 | 2001 | } |
|
| 1988 | 2002 | } |
|
| 1989 | - | let mut indexed = read.map(|(_, all)| all); |
|
| 2003 | + | let mut indexed: Option<crate::fasthash::FastMap<ChangeHash, crate::state::ChangeMeta>> = |
|
| 2004 | + | read.map(|(_, all)| all.into_iter().collect()); |
|
| 1990 | 2005 | let _walk = crate::timing::phase("metadata: walk"); |
|
| 1991 | 2006 | let (mut stored, mut from_store) = (0usize, std::time::Duration::ZERO); |
|
| 1992 | 2007 | let mut seen: crate::fasthash::FastSet<ChangeHash> = Default::default(); |
|
| … | 7 unchanged lines |
||
| 2000 | 2015 | if let Some(meta) = indexed.as_mut().and_then(|m| m.remove(&h)) { |
|
| 2001 | 2016 | #[cfg(test)] |
|
| 2002 | 2017 | assert_eq!( |
|
| 2003 | - | *meta, |
|
| 2018 | + | meta, |
|
| 2004 | 2019 | self.store.get_change_meta(&h)?, |
|
| 2005 | 2020 | "the state index holds a change's metadata" |
|
| 2006 | 2021 | ); |
|
| 2007 | - | let meta = std::sync::Arc::unwrap_or_clone(meta); |
|
| 2008 | 2022 | stack.extend(meta.deps.iter().copied()); |
|
| 2009 | 2023 | out.push((h, meta)); |
|
| 2010 | 2024 | continue; |
|
| … | 360 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +26 −3
| … | 1668 unchanged lines |
||
| 1669 | 1669 | let (base, snapshot) = self.change_base(&c)?; |
|
| 1670 | 1670 | (c, base, snapshot) |
|
| 1671 | 1671 | } |
|
| 1672 | - | None => self.working_copy_with_base()?, |
|
| 1672 | + | None => match self.working_copy_with_base_unless_unchanged()? { |
|
| 1673 | + | Some(all) => all, |
|
| 1674 | + | None => return Ok(Vec::new()), |
|
| 1675 | + | }, |
|
| 1673 | 1676 | }; |
|
| 1674 | 1677 | Ok(diff_files_of(&c, &base, &base_snapshot)) |
|
| 1675 | 1678 | } |
|
| … | 52 unchanged lines |
||
| 1728 | 1731 | /// answer, no snapshot is taken, and the base's snapshot holds its tree |
|
| 1729 | 1732 | /// but no file; otherwise a snapshot makes all three. |
|
| 1730 | 1733 | pub fn working_copy_with_base(&self) -> Result<(Change, State, Snapshot)> { |
|
| 1734 | + | match self.working_copy_with_base_unless_unchanged()? { |
|
| 1735 | + | Some(all) => Ok(all), |
|
| 1736 | + | None => { |
|
| 1737 | + | let (_, wc) = self.working_copy()?; |
|
| 1738 | + | let base = self.load_state_indexed(&wc.deps)?; |
|
| 1739 | + | let (base, snapshot) = self.tree_base_for(&wc, base)?; |
|
| 1740 | + | Ok((wc, base, snapshot)) |
|
| 1741 | + | } |
|
| 1742 | + | } |
|
| 1743 | + | } |
|
| 1744 | + | ||
| 1745 | + | /// [`Repo::working_copy_with_base`], or `None` when the status cache |
|
| 1746 | + | /// holds and the working-copy change changes nothing: its diff is |
|
| 1747 | + | /// empty, and the base is not loaded for it (oxygen#94). |
|
| 1748 | + | pub fn working_copy_with_base_unless_unchanged( |
|
| 1749 | + | &self, |
|
| 1750 | + | ) -> Result<Option<(Change, State, Snapshot)>> { |
|
| 1731 | 1751 | if self.status_by_manifest()?.is_some() { |
|
| 1732 | 1752 | let (_, wc) = self.working_copy()?; |
|
| 1753 | + | if wc.tree_ops.is_empty() && wc.edits.is_empty() { |
|
| 1754 | + | return Ok(None); |
|
| 1755 | + | } |
|
| 1733 | 1756 | let base = self.load_state_indexed(&wc.deps)?; |
|
| 1734 | 1757 | let (base, snapshot) = self.tree_base_for(&wc, base)?; |
|
| 1735 | - | return Ok((wc, base, snapshot)); |
|
| 1758 | + | return Ok(Some((wc, base, snapshot))); |
|
| 1736 | 1759 | } |
|
| 1737 | 1760 | let snap = self.snapshot_status("oxy diff")?; |
|
| 1738 | - | Ok((snap.wc, snap.base, snap.base_snapshot)) |
|
| 1761 | + | Ok(Some((snap.wc, snap.base, snap.base_snapshot))) |
|
| 1739 | 1762 | } |
|
| 1740 | 1763 | ||
| 1741 | 1764 | /// The log restricted to changes touching `path` (its node, so renames |
|
| … | 1306 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +13 −0
| … | 6040 unchanged lines |
||
| 6041 | 6041 | let (wc, base, snapshot) = repo.working_copy_with_base().unwrap(); |
|
| 6042 | 6042 | assert_eq!(render_change(&wc, &base, &snapshot), slow); |
|
| 6043 | 6043 | assert_eq!(repo.diff_files(None).unwrap().len(), 3); |
|
| 6044 | + | ||
| 6045 | + | // Committed, nothing changes: the diff is empty without loading the |
|
| 6046 | + | // base (oxygen#94), and the same as rendering it. |
|
| 6047 | + | repo.commit("all", &[]).unwrap(); |
|
| 6048 | + | repo.snapshot_status("oxy status").unwrap(); |
|
| 6049 | + | assert!(repo.status_by_manifest().unwrap().is_some()); |
|
| 6050 | + | assert!(repo |
|
| 6051 | + | .working_copy_with_base_unless_unchanged() |
|
| 6052 | + | .unwrap() |
|
| 6053 | + | .is_none()); |
|
| 6054 | + | let (wc, base, snapshot) = repo.working_copy_with_base().unwrap(); |
|
| 6055 | + | assert_eq!(render_change(&wc, &base, &snapshot), ""); |
|
| 6056 | + | assert!(repo.diff_files(None).unwrap().is_empty()); |
|
| 6044 | 6057 | } |
|
| 6045 | 6058 | ||
| 6046 | 6059 | /// oxygen#41: a manifest written from a walk keeps every directory's time; |
|
| … | 1936 unchanged lines |
modified crates/oxy-core/src/state.rs +24 −5
| … | 39 unchanged lines |
||
| 40 | 40 | pub kind: ChangeKind, |
|
| 41 | 41 | } |
|
| 42 | 42 | ||
| 43 | + | /// The log's order of changes: newest first by clock, then by hash, so |
|
| 44 | + | /// it is total. |
|
| 45 | + | pub fn log_order(a: (&ChangeHash, &Hlc), b: (&ChangeHash, &Hlc)) -> std::cmp::Ordering { |
|
| 46 | + | b.1.cmp(a.1).then(b.0.cmp(a.0)) |
|
| 47 | + | } |
|
| 48 | + | ||
| 43 | 49 | impl ChangeMeta { |
|
| 44 | 50 | /// In the flat layout of local indexes and caches ([`crate::flat`]). |
|
| 45 | 51 | pub(crate) fn write_flat(&self, w: &mut crate::flat::Writer) { |
|
| … | 172 unchanged lines |
||
| 218 | 224 | // [`State::read_index_changes`]). |
|
| 219 | 225 | let mut w = Writer::default(); |
|
| 220 | 226 | w.fixed(Self::INDEX_MAGIC); |
|
| 227 | + | // In log order, newest first (oxygen#94): the log takes them as |
|
| 228 | + | // they come, with no map and no sort. |
|
| 221 | 229 | let mut changes: Vec<(&ChangeHash, &Arc<ChangeMeta>)> = self.changes.iter().collect(); |
|
| 222 | - | changes.sort_by_key(|(h, _)| **h); |
|
| 230 | + | changes.sort_unstable_by(|a, b| log_order((a.0, &a.1.hlc), (b.0, &b.1.hlc))); |
|
| 223 | 231 | w.count(changes.len()); |
|
| 224 | 232 | for (h, m) in changes { |
|
| 225 | 233 | w.object(h); |
|
| … | 172 unchanged lines |
||
| 398 | 406 | } |
|
| 399 | 407 | ||
| 400 | 408 | /// The changes of a state index: its first block, which a reader can |
|
| 401 | - | /// take without the rest (the log needs no more). |
|
| 409 | + | /// take without the rest. |
|
| 402 | 410 | pub fn read_index_changes( |
|
| 403 | 411 | bytes: &[u8], |
|
| 404 | 412 | ) -> Result<crate::fasthash::FastMap<ChangeHash, Arc<ChangeMeta>>, ObjectError> { |
|
| 413 | + | Ok(Self::read_index_changes_listed(bytes)? |
|
| 414 | + | .into_iter() |
|
| 415 | + | .map(|(h, m)| (h, Arc::new(m))) |
|
| 416 | + | .collect()) |
|
| 417 | + | } |
|
| 418 | + | ||
| 419 | + | /// The same changes as a list in the order the index keeps them, which |
|
| 420 | + | /// a current index keeps in log order ([`log_order`]): what the log |
|
| 421 | + | /// needs (oxygen#94). |
|
| 422 | + | pub fn read_index_changes_listed( |
|
| 423 | + | bytes: &[u8], |
|
| 424 | + | ) -> Result<Vec<(ChangeHash, ChangeMeta)>, ObjectError> { |
|
| 405 | 425 | use crate::flat::Reader; |
|
| 406 | 426 | let mut r = Reader::new(bytes); |
|
| 407 | 427 | if r.fixed::<18>()?.as_slice() != Self::INDEX_MAGIC { |
|
| 408 | 428 | return Err(ObjectError::Schema("state index: not a state index".into())); |
|
| 409 | 429 | } |
|
| 410 | 430 | let n = r.count()?; |
|
| 411 | - | let mut changes = crate::fasthash::FastMap::default(); |
|
| 412 | - | changes.reserve(n); |
|
| 431 | + | let mut changes = Vec::with_capacity(n); |
|
| 413 | 432 | for _ in 0..n { |
|
| 414 | 433 | let h = r.object()?; |
|
| 415 | - | changes.insert(h, Arc::new(ChangeMeta::read_flat(&mut r)?)); |
|
| 434 | + | changes.push((h, ChangeMeta::read_flat(&mut r)?)); |
|
| 416 | 435 | } |
|
| 417 | 436 | if !r.at_end() { |
|
| 418 | 437 | return Err(ObjectError::Schema("state index: trailing bytes".into())); |
|
| … | 2321 unchanged lines |
modified src/cli/workspace.rs +4 −1
| … | 991 unchanged lines |
||
| 992 | 992 | print!("{}", render_change(&c, &base, &snapshot)); |
|
| 993 | 993 | } |
|
| 994 | 994 | None => { |
|
| 995 | - | let (wc, base, snapshot) = repo.working_copy_with_base()?; |
|
| 995 | + | // Nothing changed: nothing to show, and no base to load. |
|
| 996 | + | let Some((wc, base, snapshot)) = repo.working_copy_with_base_unless_unchanged()? else { |
|
| 997 | + | return Ok(0); |
|
| 998 | + | }; |
|
| 996 | 999 | let text = { |
|
| 997 | 1000 | let _t = oxy_core::timing::phase("diff: render the change"); |
|
| 998 | 1001 | render_change(&wc, &base, &snapshot) |
|
| … | 1777 unchanged lines |