Tree views updated in place, and a faster render of edited files e83354cdac22
A tree view is kept up to date as tree ops apply where that is simple (adding a node under a known directory, removing or moving a file or an empty directory, changing a mode, all without conflicts) rather than being worked out again from the whole tree; a test build checks every such update against a full one. Its entries are shared, so a view is cheap to clone. A file graph whose live atoms, with the dead ones contracted away, have one order only (no ghost, one vertex ready at every step) is rendered by a Kahn pass over dense indices; conflicts still take the general algorithm, which it agrees with. A 20 000-line file with 5 000 deleted lines renders in 5.7 ms rather than 26 ms. A merge's other-side changes join a history-only import base without their text unless a built graph needs it. refs #25, refs #35
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-30 07:03 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
e83354cdac22857dd749467f29cdd03e21c92ad1343c1275534671edd9f2b71e- Depends on
- 206c76cad4bf 2306a4d24779 252e8cb23fbc 598009a75f6c 62794ecc7f74 75167672ebcd 79ab8528ff62 86e1824f2d0b 958fb27dda7c 982864068a59 a87c7b9d563a c3069537d7c5 c5ce0367eddf cc7a00565161 d0d52107b074 e8d38e47e9d7 ff0af1ee299d
- Depended on by
- 36e51aa806c6 930e5f3f1682 05059d434aca f9575918477f 7eac74abc586 dbf06d342e1d
- In bookmarks
- agents-in-readme 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.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 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
- 29 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified crates/oxy-core/src/graph.rs +204 −1
| … | 382 unchanged lines |
||
| 383 | 383 | ||
| 384 | 384 | /// Order the live atoms and locate conflicts. |
|
| 385 | 385 | pub fn materialize(&self, deps: &dyn DepOracle) -> Materialized { |
|
| 386 | - | if let Some(order) = self.as_chain() { |
|
| 386 | + | if let Some(order) = self.as_chain().or_else(|| self.as_order(deps)) { |
|
| 387 | 387 | return Materialized { |
|
| 388 | 388 | segments: if order.is_empty() { |
|
| 389 | 389 | vec![] |
|
| … | 5 unchanged lines |
||
| 395 | 395 | self.materialize_general(deps) |
|
| 396 | 396 | } |
|
| 397 | 397 | ||
| 398 | + | /// The live atoms in order when, with the dead ones contracted away, |
|
| 399 | + | /// the graph orders them one way only: no deleted atom is a ghost, and |
|
| 400 | + | /// at every step of the topological sort one vertex is next. A file |
|
| 401 | + | /// edited without concurrent edits is such a graph, however long its |
|
| 402 | + | /// history. `None` otherwise, for [`FileGraph::materialize_general`] |
|
| 403 | + | /// to find the conflicts; where this answers, it gives the same order. |
|
| 404 | + | fn as_order(&self, deps: &dyn DepOracle) -> Option<Vec<AtomKey>> { |
|
| 405 | + | // Kept vertices by dense index: Start, End, then the live atoms. |
|
| 406 | + | let mut index: HashMap<Vertex, u32> = |
|
| 407 | + | HashMap::with_capacity_and_hasher(self.atoms.len() + 2, Default::default()); |
|
| 408 | + | let mut verts: Vec<Vertex> = Vec::with_capacity(self.atoms.len() + 2); |
|
| 409 | + | verts.extend([Vertex::Start, Vertex::End]); |
|
| 410 | + | index.insert(Vertex::Start, 0); |
|
| 411 | + | index.insert(Vertex::End, 1); |
|
| 412 | + | for (k, info) in &self.atoms { |
|
| 413 | + | if info.content.is_none() { |
|
| 414 | + | continue; |
|
| 415 | + | } |
|
| 416 | + | if info.deleted_by.is_empty() { |
|
| 417 | + | index.insert(Vertex::Atom(*k), verts.len() as u32); |
|
| 418 | + | verts.push(Vertex::Atom(*k)); |
|
| 419 | + | } else if self.is_ghost(k, info, deps) { |
|
| 420 | + | return None; |
|
| 421 | + | } |
|
| 422 | + | } |
|
| 423 | + | // Each kept vertex's successors, contracting the others: what a |
|
| 424 | + | // contracted vertex reaches is worked out once (post-order). |
|
| 425 | + | let mut reach: HashMap<Vertex, Vec<u32>> = HashMap::default(); |
|
| 426 | + | let contracted = |v: Vertex, reach: &mut HashMap<Vertex, Vec<u32>>| { |
|
| 427 | + | let mut stack: Vec<(Vertex, bool)> = vec![(v, false)]; |
|
| 428 | + | while let Some((x, done)) = stack.pop() { |
|
| 429 | + | if reach.contains_key(&x) { |
|
| 430 | + | continue; |
|
| 431 | + | } |
|
| 432 | + | let next = self.succ.get(&x).map(Edges::as_slice).unwrap_or_default(); |
|
| 433 | + | if !done { |
|
| 434 | + | stack.push((x, true)); |
|
| 435 | + | for s in next { |
|
| 436 | + | if !index.contains_key(s) && !reach.contains_key(s) { |
|
| 437 | + | stack.push((*s, false)); |
|
| 438 | + | } |
|
| 439 | + | } |
|
| 440 | + | continue; |
|
| 441 | + | } |
|
| 442 | + | let mut out: Vec<u32> = Vec::new(); |
|
| 443 | + | for s in next { |
|
| 444 | + | match index.get(s) { |
|
| 445 | + | Some(i) => out.push(*i), |
|
| 446 | + | None => out.extend(reach.get(s).into_iter().flatten()), |
|
| 447 | + | } |
|
| 448 | + | } |
|
| 449 | + | out.sort_unstable(); |
|
| 450 | + | out.dedup(); |
|
| 451 | + | reach.insert(x, out); |
|
| 452 | + | } |
|
| 453 | + | }; |
|
| 454 | + | // Successors of vertex `i`: `succ[starts[i]..starts[i + 1]]`. |
|
| 455 | + | let mut succ: Vec<u32> = Vec::with_capacity(verts.len() * 2); |
|
| 456 | + | let mut starts: Vec<usize> = Vec::with_capacity(verts.len() + 1); |
|
| 457 | + | let mut indeg: Vec<u32> = vec![0; verts.len()]; |
|
| 458 | + | for v in &verts { |
|
| 459 | + | let from = succ.len(); |
|
| 460 | + | starts.push(from); |
|
| 461 | + | for s in self.succ.get(v).map(Edges::as_slice).unwrap_or_default() { |
|
| 462 | + | match index.get(s) { |
|
| 463 | + | Some(i) => succ.push(*i), |
|
| 464 | + | None => { |
|
| 465 | + | contracted(*s, &mut reach); |
|
| 466 | + | succ.extend(reach.get(s).into_iter().flatten()); |
|
| 467 | + | } |
|
| 468 | + | } |
|
| 469 | + | } |
|
| 470 | + | if succ.len() - from > 1 { |
|
| 471 | + | succ[from..].sort_unstable(); |
|
| 472 | + | let mut kept = from; |
|
| 473 | + | for i in from..succ.len() { |
|
| 474 | + | if i == from || succ[i] != succ[kept - 1] { |
|
| 475 | + | succ[kept] = succ[i]; |
|
| 476 | + | kept += 1; |
|
| 477 | + | } |
|
| 478 | + | } |
|
| 479 | + | succ.truncate(kept); |
|
| 480 | + | } |
|
| 481 | + | for &w in &succ[from..] { |
|
| 482 | + | indeg[w as usize] += 1; |
|
| 483 | + | } |
|
| 484 | + | } |
|
| 485 | + | starts.push(succ.len()); |
|
| 486 | + | // Kahn's algorithm, giving up where two vertices are ready at once. |
|
| 487 | + | let mut ready: Vec<u32> = (0..verts.len() as u32) |
|
| 488 | + | .filter(|&i| indeg[i as usize] == 0) |
|
| 489 | + | .collect(); |
|
| 490 | + | let mut order: Vec<AtomKey> = Vec::with_capacity(verts.len()); |
|
| 491 | + | let mut seen = 0; |
|
| 492 | + | while let Some(v) = ready.pop() { |
|
| 493 | + | if !ready.is_empty() { |
|
| 494 | + | return None; |
|
| 495 | + | } |
|
| 496 | + | seen += 1; |
|
| 497 | + | if let Vertex::Atom(k) = verts[v as usize] { |
|
| 498 | + | order.push(k); |
|
| 499 | + | } |
|
| 500 | + | for &w in &succ[starts[v as usize]..starts[v as usize + 1]] { |
|
| 501 | + | indeg[w as usize] -= 1; |
|
| 502 | + | if indeg[w as usize] == 0 { |
|
| 503 | + | ready.push(w); |
|
| 504 | + | } |
|
| 505 | + | } |
|
| 506 | + | if ready.len() > 1 { |
|
| 507 | + | return None; |
|
| 508 | + | } |
|
| 509 | + | } |
|
| 510 | + | (seen == verts.len()).then_some(order) |
|
| 511 | + | } |
|
| 512 | + | ||
| 398 | 513 | /// [`FileGraph::materialize`] for any graph: contraction, then a |
|
| 399 | 514 | /// topological order with conflict regions. |
|
| 400 | 515 | fn materialize_general(&self, deps: &dyn DepOracle) -> Materialized { |
|
| … | 401 unchanged lines |
||
| 802 | 917 | } |
|
| 803 | 918 | } |
|
| 804 | 919 | false |
|
| 920 | + | } |
|
| 921 | + | } |
|
| 922 | + | ||
| 923 | + | /// Linear history: each change depends on every earlier one. |
|
| 924 | + | struct Linear; |
|
| 925 | + | impl DepOracle for Linear { |
|
| 926 | + | fn depends_on(&self, a: &ChangeHash, b: &ChangeHash) -> bool { |
|
| 927 | + | a.0[..2] >= b.0[..2] |
|
| 928 | + | } |
|
| 929 | + | } |
|
| 930 | + | ||
| 931 | + | /// A file of `n` lines edited `revs` times, `per` lines replaced each |
|
| 932 | + | /// time, as a long-lived file is: most atoms dead, one live chain. |
|
| 933 | + | fn edited_file(n: usize, revs: usize, per: usize) -> FileGraph { |
|
| 934 | + | let hn = |i: usize| { |
|
| 935 | + | let mut b = [0u8; 32]; |
|
| 936 | + | b[..2].copy_from_slice(&(i as u16).to_be_bytes()); |
|
| 937 | + | ObjectRef(b) |
|
| 938 | + | }; |
|
| 939 | + | let mut g = FileGraph::new(); |
|
| 940 | + | let first: Vec<String> = (0..n).map(|i| format!("line {i}\n")).collect(); |
|
| 941 | + | let first: Vec<&str> = first.iter().map(String::as_str).collect(); |
|
| 942 | + | g.insert(hn(1), 0, Vertex::Start, Vertex::End, &lines(&first)); |
|
| 943 | + | for r in 2..revs + 2 { |
|
| 944 | + | let order = g.materialize(&Linear).live_atoms(); |
|
| 945 | + | let step = order.len() / per; |
|
| 946 | + | for j in 0..per { |
|
| 947 | + | let at = j * step + (r % step.max(1)); |
|
| 948 | + | let k = order[at]; |
|
| 949 | + | g.delete( |
|
| 950 | + | hn(r), |
|
| 951 | + | &AtomRange { |
|
| 952 | + | change: k.0, |
|
| 953 | + | from: k.1, |
|
| 954 | + | to: k.1 + 1, |
|
| 955 | + | }, |
|
| 956 | + | ); |
|
| 957 | + | let after = if at == 0 { |
|
| 958 | + | Vertex::Start |
|
| 959 | + | } else { |
|
| 960 | + | Vertex::Atom(order[at - 1]) |
|
| 961 | + | }; |
|
| 962 | + | let before = order |
|
| 963 | + | .get(at + 1) |
|
| 964 | + | .map(|k| Vertex::Atom(*k)) |
|
| 965 | + | .unwrap_or(Vertex::End); |
|
| 966 | + | g.insert(hn(r), j as u32, after, before, &lines(&["new\n"])); |
|
| 967 | + | } |
|
| 805 | 968 | } |
|
| 969 | + | g |
|
| 970 | + | } |
|
| 971 | + | ||
| 972 | + | /// The single-order shortcut gives what the general algorithm gives. |
|
| 973 | + | #[test] |
|
| 974 | + | fn the_order_shortcut_agrees_with_the_general_order() { |
|
| 975 | + | let g = edited_file(500, 10, 20); |
|
| 976 | + | assert!(g.as_chain().is_none()); |
|
| 977 | + | let fast = g.as_order(&Linear).expect("a single order"); |
|
| 978 | + | assert_eq!( |
|
| 979 | + | Materialized { |
|
| 980 | + | segments: vec![Segment::Lines(fast)] |
|
| 981 | + | }, |
|
| 982 | + | g.materialize_general(&Linear) |
|
| 983 | + | ); |
|
| 984 | + | // Two changes inserting at one place concurrently: a conflict, |
|
| 985 | + | // which the shortcut leaves to the general algorithm. |
|
| 986 | + | let mut g = FileGraph::new(); |
|
| 987 | + | g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n"])); |
|
| 988 | + | let a = Vertex::Atom((h(1), 0)); |
|
| 989 | + | g.insert(h(2), 0, a, Vertex::End, &lines(&["b\n"])); |
|
| 990 | + | g.insert(h(3), 0, a, Vertex::End, &lines(&["c\n"])); |
|
| 991 | + | assert!(g.as_order(&NoDeps).is_none()); |
|
| 992 | + | assert!(g.materialize(&NoDeps).is_conflicted()); |
|
| 993 | + | } |
|
| 994 | + | ||
| 995 | + | /// How long rendering a long-lived file takes (run by hand, in release). |
|
| 996 | + | #[test] |
|
| 997 | + | #[ignore] |
|
| 998 | + | fn bench_materialize_edited_file() { |
|
| 999 | + | let g = edited_file(20_000, 50, 100); |
|
| 1000 | + | let t = std::time::Instant::now(); |
|
| 1001 | + | let mut n = 0; |
|
| 1002 | + | for _ in 0..20 { |
|
| 1003 | + | n += g.materialize(&Linear).live_atoms().len(); |
|
| 1004 | + | } |
|
| 1005 | + | eprintln!( |
|
| 1006 | + | "materialize: {:.2} ms each ({n} atoms)", |
|
| 1007 | + | t.elapsed().as_secs_f64() * 1000.0 / 20.0 |
|
| 1008 | + | ); |
|
| 806 | 1009 | } |
|
| 807 | 1010 | ||
| 808 | 1011 | /// The chain shortcut agrees with the general algorithm wherever it |
|
| … | 479 unchanged lines |
modified crates/oxy-core/src/repo/checkout.rs +1 −1
| … | 105 unchanged lines |
||
| 106 | 106 | let hex = |b: &[u8]| blake3::hash(b).to_hex().to_string(); |
|
| 107 | 107 | // Text files to render, attribute files first (the others need them). |
|
| 108 | 108 | let mut texts: Vec<(String, NodeId, Mode)> = Vec::new(); |
|
| 109 | - | for (path, e) in &tview.entries { |
|
| 109 | + | for (path, e) in tview.entries.iter() { |
|
| 110 | 110 | if e.kind == NodeKind::Dir || !self.in_scope(path) { |
|
| 111 | 111 | continue; |
|
| 112 | 112 | } |
|
| … | 306 unchanged lines |
modified crates/oxy-core/src/repo/git.rs +27 −5
| … | 1048 unchanged lines |
||
| 1049 | 1049 | .contributors |
|
| 1050 | 1050 | .extend(base.live_replaces(&f.node).into_iter().map(|(c, _)| c)); |
|
| 1051 | 1051 | } |
|
| 1052 | + | // It shares the base's tree view, which applying the change |
|
| 1053 | + | // then updates in place rather than copying. |
|
| 1054 | + | drop(base_snapshot); |
|
| 1055 | + | drop(entries); |
|
| 1052 | 1056 | let mut deps: BTreeSet<ChangeHash> = built.contributors.iter().copied().collect(); |
|
| 1053 | 1057 | let mut message = gc.message.clone(); |
|
| 1054 | 1058 | if !message.ends_with('\n') { |
|
| … | 135 unchanged lines |
||
| 1190 | 1194 | let out = git(git_dir, &full)?; |
|
| 1191 | 1195 | let mut add = |st: &mut State, h: &ChangeHash| -> Result<()> { |
|
| 1192 | 1196 | if st.contains(h) { |
|
| 1197 | + | return Ok(()); |
|
| 1198 | + | } |
|
| 1199 | + | if !st.is_history_only() { |
|
| 1200 | + | let c = self.store.get_change(h)?; |
|
| 1201 | + | touched.extend(c.edits.iter().map(|e| e.node())); |
|
| 1202 | + | touched.extend(c.tree_ops.iter().map(|o| o.node())); |
|
| 1203 | + | st.add_unchecked(c); |
|
| 1193 | 1204 | return Ok(()); |
|
| 1205 | + | } |
|
| 1206 | + | // History only: the text of an edit waits unread unless its |
|
| 1207 | + | // file's graph is built. |
|
| 1208 | + | let (skeleton, texts) = self.store.get_change_lazy(h)?; |
|
| 1209 | + | touched.extend(skeleton.edits.iter().map(|e| e.node())); |
|
| 1210 | + | touched.extend(skeleton.tree_ops.iter().map(|o| o.node())); |
|
| 1211 | + | let built = skeleton |
|
| 1212 | + | .edits |
|
| 1213 | + | .iter() |
|
| 1214 | + | .any(|e| !matches!(e, Edit::Replace { .. }) && st.is_built(&e.node())); |
|
| 1215 | + | if built { |
|
| 1216 | + | let full = self.store.get_change(h)?; |
|
| 1217 | + | st.add_known_mixed(*h, full, skeleton, texts); |
|
| 1218 | + | } else { |
|
| 1219 | + | st.add_known(*h, skeleton, texts); |
|
| 1194 | 1220 | } |
|
| 1195 | - | let c = self.store.get_change(h)?; |
|
| 1196 | - | touched.extend(c.edits.iter().map(|e| e.node())); |
|
| 1197 | - | touched.extend(c.tree_ops.iter().map(|o| o.node())); |
|
| 1198 | - | st.add_unchecked(c); |
|
| 1199 | 1221 | Ok(()) |
|
| 1200 | 1222 | }; |
|
| 1201 | 1223 | for sha in out.lines() { |
|
| … | 322 unchanged lines |
||
| 1524 | 1546 | // Import records submodules in `.oxymodules` as well (spec §23), |
|
| 1525 | 1547 | // and export leaves it out again. |
|
| 1526 | 1548 | let has_submodules = tree.values().any(|(m, _)| m == "160000"); |
|
| 1527 | - | for (path, e) in &view.entries { |
|
| 1549 | + | for (path, e) in view.entries.iter() { |
|
| 1528 | 1550 | if e.kind != NodeKind::Dir |
|
| 1529 | 1551 | && !tree.contains_key(path) |
|
| 1530 | 1552 | && !(has_submodules && path == ".oxymodules") |
|
| … | 36 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +2 −2
| … | 329 unchanged lines |
||
| 330 | 330 | let tracked: BTreeMap<String, (NodeKind, Option<ObjectRef>)> = { |
|
| 331 | 331 | let _t = crate::timing::phase("snapshot: tracked paths"); |
|
| 332 | 332 | let mut t = BTreeMap::new(); |
|
| 333 | - | for (p, e) in &view.entries { |
|
| 333 | + | for (p, e) in view.entries.iter() { |
|
| 334 | 334 | if e.kind == NodeKind::Dir { |
|
| 335 | 335 | continue; |
|
| 336 | 336 | } |
|
| … | 1253 unchanged lines |
||
| 1590 | 1590 | ) -> Self { |
|
| 1591 | 1591 | let mut dir_nodes: HashMap<String, NodeId> = HashMap::new(); |
|
| 1592 | 1592 | dir_nodes.insert(String::new(), NodeId::ROOT); |
|
| 1593 | - | for (p, e) in &base_snapshot.tree.entries { |
|
| 1593 | + | for (p, e) in base_snapshot.tree.entries.iter() { |
|
| 1594 | 1594 | if e.kind == NodeKind::Dir { |
|
| 1595 | 1595 | dir_nodes.insert(p.clone(), e.node); |
|
| 1596 | 1596 | } |
|
| … | 924 unchanged lines |
modified crates/oxy-core/src/state.rs +5 −0
| … | 242 unchanged lines |
||
| 243 | 243 | } |
|
| 244 | 244 | } |
|
| 245 | 245 | ||
| 246 | + | /// Whether `node`'s file graph is built (always, in a full state). |
|
| 247 | + | pub fn is_built(&self, node: &NodeId) -> bool { |
|
| 248 | + | self.deferred.is_none() || self.files.contains_key(node) |
|
| 249 | + | } |
|
| 250 | + | ||
| 246 | 251 | /// Whether this state builds file graphs only when asked |
|
| 247 | 252 | /// ([`State::new_history_only`]), even if it has none waiting. |
|
| 248 | 253 | pub fn is_history_only(&self) -> bool { |
|
| … | 1751 unchanged lines |
modified crates/oxy-core/src/tree.rs +266 −7
| … | 32 unchanged lines |
||
| 33 | 33 | /// apply, so the view walks what is live rather than all history. |
|
| 34 | 34 | live_placements_of: HashMap<NodeId, BTreeSet<OpKey>>, |
|
| 35 | 35 | live_modes_of: HashMap<NodeId, BTreeSet<OpKey>>, |
|
| 36 | - | /// The view, once computed; cleared by every change to the graph. |
|
| 37 | - | view_cache: std::sync::OnceLock<TreeView>, |
|
| 36 | + | /// The view, once computed, with the path of each directory in it; |
|
| 37 | + | /// kept up to date as ops apply when that is simple (see |
|
| 38 | + | /// `update_view`), cleared otherwise. |
|
| 39 | + | view_cache: std::sync::OnceLock<CachedView>, |
|
| 38 | 40 | } |
|
| 39 | 41 | ||
| 40 | 42 | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| … | 28 unchanged lines |
||
| 69 | 71 | pub placement: OpKey, |
|
| 70 | 72 | /// The live mode atom (for building `SetMode`). |
|
| 71 | 73 | pub mode_atom: Option<OpKey>, |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | #[derive(Clone, Debug, Default)] |
|
| 77 | + | struct CachedView { |
|
| 78 | + | view: TreeView, |
|
| 79 | + | dirs: HashMap<NodeId, String>, |
|
| 72 | 80 | } |
|
| 73 | 81 | ||
| 82 | + | impl CachedView { |
|
| 83 | + | fn new(view: TreeView) -> CachedView { |
|
| 84 | + | let mut dirs: HashMap<NodeId, String> = view |
|
| 85 | + | .entries |
|
| 86 | + | .values() |
|
| 87 | + | .filter(|e| e.kind == NodeKind::Dir) |
|
| 88 | + | .map(|e| (e.node, e.path.clone())) |
|
| 89 | + | .collect(); |
|
| 90 | + | dirs.insert(NodeId::ROOT, String::new()); |
|
| 91 | + | CachedView { view, dirs } |
|
| 92 | + | } |
|
| 93 | + | } |
|
| 94 | + | ||
| 74 | 95 | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
|
| 75 | 96 | pub struct TreeView { |
|
| 76 | 97 | /// Path → entry, paths use `/` and have no leading slash; conflicted |
|
| 77 | - | /// entries carry a `.oxy-conflict-<n>` suffix. |
|
| 78 | - | pub entries: BTreeMap<String, TreeEntry>, |
|
| 98 | + | /// entries carry a `.oxy-conflict-<n>` suffix. Shared, so a view is |
|
| 99 | + | /// cheap to clone. |
|
| 100 | + | pub entries: std::sync::Arc<BTreeMap<String, TreeEntry>>, |
|
| 79 | 101 | pub conflicts: Vec<TreeConflict>, |
|
| 80 | 102 | } |
|
| 81 | 103 | ||
| … | 13 unchanged lines |
||
| 95 | 117 | ||
| 96 | 118 | /// Apply tree op `index` of `change`; `OpRef` self references must already be resolved. |
|
| 97 | 119 | pub fn apply(&mut self, change: ChangeHash, index: u32, op: &TreeOp) { |
|
| 98 | - | self.view_cache = std::sync::OnceLock::new(); |
|
| 120 | + | let cached = self.view_cache.take(); |
|
| 121 | + | // Whether the placement or mode atom this op retires was live. |
|
| 122 | + | let was_live = match op { |
|
| 123 | + | TreeOp::RemoveNode { placement, .. } => self.placement_live(&op_key(*placement)), |
|
| 124 | + | TreeOp::Move { old, .. } => self.placement_live(&op_key(*old)), |
|
| 125 | + | TreeOp::SetMode { old, .. } => self.mode_live(&op_key(*old)), |
|
| 126 | + | TreeOp::AddNode { .. } => false, |
|
| 127 | + | }; |
|
| 128 | + | self.apply_to_graph(change, index, op); |
|
| 129 | + | if let Some(mut c) = cached { |
|
| 130 | + | if self.update_view(&mut c, (change, index), op, was_live) { |
|
| 131 | + | #[cfg(test)] |
|
| 132 | + | assert_eq!( |
|
| 133 | + | c.view, |
|
| 134 | + | self.compute_view(), |
|
| 135 | + | "view kept up to date by {op:?}" |
|
| 136 | + | ); |
|
| 137 | + | self.view_cache = std::sync::OnceLock::from(c); |
|
| 138 | + | } |
|
| 139 | + | } |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | fn apply_to_graph(&mut self, change: ChangeHash, index: u32, op: &TreeOp) { |
|
| 99 | 143 | let key = (change, index); |
|
| 100 | 144 | match op { |
|
| 101 | 145 | TreeOp::AddNode { |
|
| … | 63 unchanged lines |
||
| 165 | 209 | } |
|
| 166 | 210 | } |
|
| 167 | 211 | ||
| 212 | + | /// Bring the cached view `c` up to date with `op` (already applied as |
|
| 213 | + | /// `key`) where that is simple and the view has no conflicts; false |
|
| 214 | + | /// when it must be computed again. `was_live`: whether the placement or |
|
| 215 | + | /// mode atom `op` retires was live before it. |
|
| 216 | + | fn update_view(&self, c: &mut CachedView, key: OpKey, op: &TreeOp, was_live: bool) -> bool { |
|
| 217 | + | if !c.view.conflicts.is_empty() { |
|
| 218 | + | return false; |
|
| 219 | + | } |
|
| 220 | + | match op { |
|
| 221 | + | TreeOp::AddNode { node, .. } => self.view_add(c, *node, key), |
|
| 222 | + | TreeOp::RemoveNode { node, placement } => { |
|
| 223 | + | !was_live || self.view_remove(c, *node, op_key(*placement)) |
|
| 224 | + | } |
|
| 225 | + | TreeOp::Move { node, old, .. } => { |
|
| 226 | + | (!was_live || self.view_remove(c, *node, op_key(*old))) |
|
| 227 | + | && self.view_add(c, *node, key) |
|
| 228 | + | } |
|
| 229 | + | TreeOp::SetMode { node, .. } => { |
|
| 230 | + | let live = self.live_placements(node); |
|
| 231 | + | match live.as_slice() { |
|
| 232 | + | [] => true, |
|
| 233 | + | [(k, p)] => { |
|
| 234 | + | let Some(path) = c.dirs.get(&p.parent).map(|d| join(d, &p.name)) else { |
|
| 235 | + | return false; |
|
| 236 | + | }; |
|
| 237 | + | let Some(mode) = self.entry_mode(node) else { |
|
| 238 | + | return false; |
|
| 239 | + | }; |
|
| 240 | + | let entries = std::sync::Arc::make_mut(&mut c.view.entries); |
|
| 241 | + | match entries.get_mut(&path) { |
|
| 242 | + | Some(e) if e.node == *node && e.placement == *k => { |
|
| 243 | + | (e.mode_atom, e.mode) = mode; |
|
| 244 | + | true |
|
| 245 | + | } |
|
| 246 | + | _ => false, |
|
| 247 | + | } |
|
| 248 | + | } |
|
| 249 | + | _ => false, |
|
| 250 | + | } |
|
| 251 | + | } |
|
| 252 | + | } |
|
| 253 | + | } |
|
| 254 | + | ||
| 255 | + | /// The view entry for `node`'s placement `key`, just made live. |
|
| 256 | + | fn view_add(&self, c: &mut CachedView, node: NodeId, key: OpKey) -> bool { |
|
| 257 | + | if !self.placement_live(&key) { |
|
| 258 | + | return true; |
|
| 259 | + | } |
|
| 260 | + | if self |
|
| 261 | + | .live_placements_of |
|
| 262 | + | .get(&node) |
|
| 263 | + | .is_none_or(|s| s.len() != 1) |
|
| 264 | + | { |
|
| 265 | + | return false; |
|
| 266 | + | } |
|
| 267 | + | let Some(p) = self.placements.get(&key) else { |
|
| 268 | + | return false; |
|
| 269 | + | }; |
|
| 270 | + | let Some(path) = c.dirs.get(&p.parent).map(|d| join(d, &p.name)) else { |
|
| 271 | + | return false; |
|
| 272 | + | }; |
|
| 273 | + | let Some((mode_atom, mode)) = self.entry_mode(&node) else { |
|
| 274 | + | return false; |
|
| 275 | + | }; |
|
| 276 | + | let kind = self.kind(&node).unwrap_or(NodeKind::File); |
|
| 277 | + | let entries = std::sync::Arc::make_mut(&mut c.view.entries); |
|
| 278 | + | if entries.contains_key(&path) || node == NodeId::ROOT { |
|
| 279 | + | return false; |
|
| 280 | + | } |
|
| 281 | + | // Nothing can be placed under a directory that was not in the view |
|
| 282 | + | // yet: it would have been an orphan, and the view had no conflicts. |
|
| 283 | + | if kind == NodeKind::Dir { |
|
| 284 | + | c.dirs.insert(node, path.clone()); |
|
| 285 | + | } |
|
| 286 | + | entries.insert( |
|
| 287 | + | path.clone(), |
|
| 288 | + | TreeEntry { |
|
| 289 | + | path, |
|
| 290 | + | node, |
|
| 291 | + | kind, |
|
| 292 | + | mode, |
|
| 293 | + | placement: key, |
|
| 294 | + | mode_atom, |
|
| 295 | + | }, |
|
| 296 | + | ); |
|
| 297 | + | true |
|
| 298 | + | } |
|
| 299 | + | ||
| 300 | + | /// Drop the view entry of `node`'s placement `key`, just retired. |
|
| 301 | + | fn view_remove(&self, c: &mut CachedView, node: NodeId, key: OpKey) -> bool { |
|
| 302 | + | if self.live_placements_of.contains_key(&node) { |
|
| 303 | + | return false; |
|
| 304 | + | } |
|
| 305 | + | let Some(p) = self.placements.get(&key) else { |
|
| 306 | + | return false; |
|
| 307 | + | }; |
|
| 308 | + | let Some(path) = c.dirs.get(&p.parent).map(|d| join(d, &p.name)) else { |
|
| 309 | + | return false; |
|
| 310 | + | }; |
|
| 311 | + | let entries = std::sync::Arc::make_mut(&mut c.view.entries); |
|
| 312 | + | match entries.get(&path) { |
|
| 313 | + | Some(e) if e.node == node && e.placement == key => {} |
|
| 314 | + | _ => return false, |
|
| 315 | + | } |
|
| 316 | + | if entries.get(&path).is_some_and(|e| e.kind == NodeKind::Dir) { |
|
| 317 | + | let prefix = format!("{path}/"); |
|
| 318 | + | if entries |
|
| 319 | + | .range(prefix.clone()..) |
|
| 320 | + | .next() |
|
| 321 | + | .is_some_and(|(k, _)| k.starts_with(&prefix)) |
|
| 322 | + | { |
|
| 323 | + | return false; |
|
| 324 | + | } |
|
| 325 | + | c.dirs.remove(&node); |
|
| 326 | + | } |
|
| 327 | + | entries.remove(&path); |
|
| 328 | + | true |
|
| 329 | + | } |
|
| 330 | + | ||
| 331 | + | /// The mode atom and mode of `node`'s entry, as `compute_view` finds |
|
| 332 | + | /// them; `None` when its live modes conflict. |
|
| 333 | + | fn entry_mode(&self, node: &NodeId) -> Option<(Option<OpKey>, Mode)> { |
|
| 334 | + | let lm = self.live_modes(node); |
|
| 335 | + | let distinct: BTreeSet<Mode> = lm.iter().map(|(_, m)| *m).collect(); |
|
| 336 | + | if distinct.len() > 1 { |
|
| 337 | + | return None; |
|
| 338 | + | } |
|
| 339 | + | let mode_atom = lm.iter().map(|(k, _)| *k).max(); |
|
| 340 | + | let mode = mode_atom |
|
| 341 | + | .and_then(|k| self.modes.get(&k).map(|(_, m)| *m)) |
|
| 342 | + | .unwrap_or_default(); |
|
| 343 | + | Some((mode_atom, mode)) |
|
| 344 | + | } |
|
| 345 | + | ||
| 168 | 346 | /// Bring placement `k` in the live index up to date. |
|
| 169 | 347 | fn index_placement(&mut self, k: OpKey) { |
|
| 170 | 348 | let Some(node) = self.placements.get(&k).map(|p| p.node) else { |
|
| … | 117 unchanged lines |
||
| 288 | 466 | /// The materialized tree (spec §7.1): computed once per graph and |
|
| 289 | 467 | /// cloned for each caller. |
|
| 290 | 468 | pub fn view(&self) -> TreeView { |
|
| 291 | - | self.view_cache.get_or_init(|| self.compute_view()).clone() |
|
| 469 | + | self.view_cache |
|
| 470 | + | .get_or_init(|| CachedView::new(self.compute_view())) |
|
| 471 | + | .view |
|
| 472 | + | .clone() |
|
| 292 | 473 | } |
|
| 293 | 474 | ||
| 294 | 475 | fn compute_view(&self) -> TreeView { |
|
| 295 | 476 | let mut view = TreeView::default(); |
|
| 477 | + | let mut entries: BTreeMap<String, TreeEntry> = BTreeMap::new(); |
|
| 296 | 478 | ||
| 297 | 479 | // Live placements grouped by parent. |
|
| 298 | 480 | let mut children: HashMap<NodeId, Vec<(OpKey, &Placement)>> = HashMap::new(); |
|
| … | 96 unchanged lines |
||
| 395 | 577 | }; |
|
| 396 | 578 | let kind = self.kind(&node).unwrap_or(NodeKind::File); |
|
| 397 | 579 | let (mode_atom, mode) = modes.get(&node).copied().unwrap_or_default(); |
|
| 398 | - | view.entries.insert( |
|
| 580 | + | entries.insert( |
|
| 399 | 581 | path.clone(), |
|
| 400 | 582 | TreeEntry { |
|
| 401 | 583 | path: path.clone(), |
|
| … | 50 unchanged lines |
||
| 452 | 634 | view.conflicts.push(conflict); |
|
| 453 | 635 | } |
|
| 454 | 636 | } |
|
| 637 | + | view.entries = std::sync::Arc::new(entries); |
|
| 455 | 638 | view |
|
| 456 | 639 | } |
|
| 457 | 640 | } |
|
| … | 156 unchanged lines |
||
| 614 | 797 | g.index_mode(k); |
|
| 615 | 798 | } |
|
| 616 | 799 | Ok(g) |
|
| 800 | + | } |
|
| 801 | + | } |
|
| 802 | + | ||
| 803 | + | fn join(dir: &str, name: &str) -> String { |
|
| 804 | + | if dir.is_empty() { |
|
| 805 | + | name.to_string() |
|
| 806 | + | } else { |
|
| 807 | + | format!("{dir}/{name}") |
|
| 617 | 808 | } |
|
| 618 | 809 | } |
|
| 619 | 810 | ||
| … | 27 unchanged lines |
||
| 647 | 838 | } |
|
| 648 | 839 | fn paths(v: &TreeView) -> Vec<String> { |
|
| 649 | 840 | v.entries.keys().cloned().collect() |
|
| 841 | + | } |
|
| 842 | + | ||
| 843 | + | /// Each op applied to a graph whose view is cached keeps it up to date |
|
| 844 | + | /// or drops it: `apply` checks every kept view against a full one. |
|
| 845 | + | #[test] |
|
| 846 | + | fn cached_view_follows_ops() { |
|
| 847 | + | let at = |c: u8, i: u32| OpRef { |
|
| 848 | + | change: h(c), |
|
| 849 | + | index: i, |
|
| 850 | + | }; |
|
| 851 | + | let mut g = TreeGraph::new(); |
|
| 852 | + | let ops = [ |
|
| 853 | + | TreeOp::AddNode { |
|
| 854 | + | node: nid(1), |
|
| 855 | + | kind: NodeKind::Dir, |
|
| 856 | + | mode: Mode::TEXT, |
|
| 857 | + | parent: NodeId::ROOT, |
|
| 858 | + | name: "src".into(), |
|
| 859 | + | }, |
|
| 860 | + | TreeOp::AddNode { |
|
| 861 | + | node: nid(2), |
|
| 862 | + | kind: NodeKind::File, |
|
| 863 | + | mode: Mode::TEXT, |
|
| 864 | + | parent: nid(1), |
|
| 865 | + | name: "a.rs".into(), |
|
| 866 | + | }, |
|
| 867 | + | TreeOp::SetMode { |
|
| 868 | + | node: nid(2), |
|
| 869 | + | old: at(1, 1), |
|
| 870 | + | mode: Mode { |
|
| 871 | + | exec: true, |
|
| 872 | + | opaque: false, |
|
| 873 | + | }, |
|
| 874 | + | }, |
|
| 875 | + | TreeOp::Move { |
|
| 876 | + | node: nid(2), |
|
| 877 | + | old: at(1, 1), |
|
| 878 | + | parent: NodeId::ROOT, |
|
| 879 | + | name: "b.rs".into(), |
|
| 880 | + | }, |
|
| 881 | + | // Moving an empty directory, then removing it. |
|
| 882 | + | TreeOp::Move { |
|
| 883 | + | node: nid(1), |
|
| 884 | + | old: at(1, 0), |
|
| 885 | + | parent: NodeId::ROOT, |
|
| 886 | + | name: "lib".into(), |
|
| 887 | + | }, |
|
| 888 | + | TreeOp::RemoveNode { |
|
| 889 | + | node: nid(1), |
|
| 890 | + | placement: at(1, 4), |
|
| 891 | + | }, |
|
| 892 | + | // A name taken twice: a conflict, so the view is computed again. |
|
| 893 | + | TreeOp::AddNode { |
|
| 894 | + | node: nid(3), |
|
| 895 | + | kind: NodeKind::File, |
|
| 896 | + | mode: Mode::TEXT, |
|
| 897 | + | parent: NodeId::ROOT, |
|
| 898 | + | name: "b.rs".into(), |
|
| 899 | + | }, |
|
| 900 | + | ]; |
|
| 901 | + | for (i, op) in ops.iter().enumerate() { |
|
| 902 | + | let _ = g.view(); |
|
| 903 | + | g.apply(h(1), i as u32, op); |
|
| 904 | + | assert_eq!(g.view(), g.compute_view()); |
|
| 905 | + | } |
|
| 906 | + | let v = g.view(); |
|
| 907 | + | assert_eq!(paths(&v), ["b.rs.oxy-conflict-1", "b.rs.oxy-conflict-2"]); |
|
| 908 | + | assert!(v.entries.values().any(|e| e.mode.exec)); |
|
| 650 | 909 | } |
|
| 651 | 910 | ||
| 652 | 911 | #[test] |
|
| … | 292 unchanged lines |