The same file added on both sides, identically, is one file c2e1d09047db
Andy's decision on #101: the same path added on two bookmarks with identical content unifies silently: no conflict, nothing to resolve, no resolution change. A name conflict whose candidates are all one kind and mode (files, symlinks, subrepos) with identical content (the same rendered bytes; the same blob or pin) is shown as one entry at its name, the node with the smallest placement key, so every peer and the hub agree whatever order the changes came in. The stored and signed formats are unchanged: the raw tree keeps the conflict; State::view() unifies. It stays one: when the working copy touches the node shown (an edit, a pin, a mode, a move, a removal), the commit also retires the duplicates' placements, which status and show don't list, and partial commits carry them along. Different content, or a different mode, is still a name conflict. Directories are not unified. Tests: a merge of identical additions (files and a subrepo at the same pin), later edits, deletes, renames and partial commits, a peer in another order, and the cases that still conflict. Closes #101
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 6a9769ab155d
- When
- · 2026-10-18 19:18 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
c2e1d09047db8ed90b2f4ccf0bcdb26a2b1f5df68962ee04b5fa914604ac8112- Depends on
- 073a2115995f 0bdc3f91a39f 10b4fd430eab 119bbf50cb86 170bb436e1d5 19303b4a06fb 2306a4d24779 252e8cb23fbc 36e51aa806c6 3e5772ccf9da 47ce8b8e2d4b 5552e69c7325 57eaac9107df 6e723b81f5b9 75167672ebcd 83d61e803ccd 86e1824f2d0b 8ab265dc4a51 957fee30826e 982864068a59 a87c7b9d563a bc489b6c7f69 c5ce0367eddf c6aa5bd9d1c8 c863c7a7a2d1 cc7a00565161 d0d52107b074 d3cd7ef95da6 d616faf5d136 dd6147dcf9c2 e02bf0cd0533 e7ddd552b619 e8d38e47e9d7 e8e6e0cac26a f9466021286a f9575918477f ff0af1ee299d
- Depended on by
- nothing yet
- In bookmarks
- doctor-trust identical-sides keyless-tags main paths-for-resolutions-and-amend push-notes subrepo-pin-state
- In tags
- v0.10.0 v0.11.0 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 60 atoms, 0 tree operations, 10 files
Browse the tree at this change
modified crates/oxy-core/src/repo/checkout.rs +1 −1
| … | 60 unchanged lines |
||
| 61 | 61 | let mut target = self.load_state_indexed(heads)?; |
|
| 62 | 62 | let heads = target.maximal(heads); |
|
| 63 | 63 | let tv = crate::timing::phase("checkout: target view"); |
|
| 64 | - | let tview = target.tree().view(); |
|
| 64 | + | let tview = self.tree_view(&mut target)?; |
|
| 65 | 65 | drop(tv); |
|
| 66 | 66 | let mut report = CheckoutReport::default(); |
|
| 67 | 67 | // Paths this file system cannot hold side by side (spec §7.7). |
|
| … | 569 unchanged lines |
modified crates/oxy-core/src/repo/doctor.rs +4 −2
| … | 208 unchanged lines |
||
| 209 | 209 | let Ok(heads) = self.bookmark_heads() else { |
|
| 210 | 210 | return; |
|
| 211 | 211 | }; |
|
| 212 | - | let Ok(state) = self.load_state_history(&heads) else { |
|
| 212 | + | let Ok(mut state) = self.load_state_history(&heads) else { |
|
| 213 | 213 | return; |
|
| 214 | 214 | }; |
|
| 215 | - | let view = state.tree().view(); |
|
| 215 | + | let Ok(view) = self.tree_view(&mut state) else { |
|
| 216 | + | return; |
|
| 217 | + | }; |
|
| 216 | 218 | let paths: Vec<&str> = view.entries.keys().map(String::as_str).collect(); |
|
| 217 | 219 | let mut problems: Vec<String> = |
|
| 218 | 220 | crate::portable::Platform::windows().problems(paths.iter().copied()); |
|
| … | 125 unchanged lines |
modified crates/oxy-core/src/repo/inspect.rs +2 −2
| … | 61 unchanged lines |
||
| 62 | 62 | let (_, wc) = self.working_copy()?; |
|
| 63 | 63 | // The working copy's base is what the state index holds. |
|
| 64 | 64 | let mut state = self.load_state_indexed(&wc.deps)?; |
|
| 65 | - | let tree = state.tree().view(); |
|
| 65 | + | let tree = self.tree_view(&mut state)?; |
|
| 66 | 66 | if let Some(e) = tree.entries.get(path) { |
|
| 67 | 67 | self.load_graphs_of(&mut state, &[e.node])?; |
|
| 68 | 68 | } |
|
| … | 8 unchanged lines |
||
| 77 | 77 | /// Blame `path` in the state with these heads, building only its graph. |
|
| 78 | 78 | fn blame_heads(&self, path: &str, heads: &[ChangeHash], what: &str) -> Result<Vec<BlameLine>> { |
|
| 79 | 79 | let mut state = self.load_state_history(heads)?; |
|
| 80 | - | let tree = state.tree().view(); |
|
| 80 | + | let tree = self.tree_view(&mut state)?; |
|
| 81 | 81 | if let Some(e) = tree.entries.get(path) { |
|
| 82 | 82 | self.load_graphs_of(&mut state, &[e.node])?; |
|
| 83 | 83 | } |
|
| … | 397 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +19 −2
| … | 1107 unchanged lines |
||
| 1108 | 1108 | .map_err(|e| self.index_damage(e)) |
|
| 1109 | 1109 | } |
|
| 1110 | 1110 | ||
| 1111 | + | /// The tree of `state` as every reader sees it ([`State::view`]), with |
|
| 1112 | + | /// the graphs built that it compares: same-path additions with |
|
| 1113 | + | /// identical content are one entry, not a name conflict. |
|
| 1114 | + | pub fn tree_view(&self, state: &mut State) -> Result<crate::tree::TreeView> { |
|
| 1115 | + | let need = state.unify_candidates(); |
|
| 1116 | + | if !need.is_empty() { |
|
| 1117 | + | self.load_graphs_of(state, &need)?; |
|
| 1118 | + | } |
|
| 1119 | + | Ok(state.view()) |
|
| 1120 | + | } |
|
| 1121 | + | ||
| 1111 | 1122 | /// An error building graphs, and when it is a state index found |
|
| 1112 | 1123 | /// damaged (a file's history that does not match its hash), the |
|
| 1113 | 1124 | /// workspace's state indexes removed: they are rebuilt from history by |
|
| … | 970 unchanged lines |
||
| 2084 | 2095 | )); |
|
| 2085 | 2096 | } |
|
| 2086 | 2097 | let mut state = self.load_state_history(heads)?; |
|
| 2087 | - | let Some(node) = state.tree().view().entries.get(path).map(|e| e.node) else { |
|
| 2098 | + | let Some(node) = self |
|
| 2099 | + | .tree_view(&mut state)? |
|
| 2100 | + | .entries |
|
| 2101 | + | .get(path) |
|
| 2102 | + | .map(|e| e.node) |
|
| 2103 | + | else { |
|
| 2088 | 2104 | return fail(format!("{path} is not in that state")); |
|
| 2089 | 2105 | }; |
|
| 2090 | 2106 | self.load_graphs_of(&mut state, &[node])?; |
|
| … | 176 unchanged lines |
||
| 2267 | 2283 | let nodes: Vec<NodeId> = edited.into_iter().collect(); |
|
| 2268 | 2284 | self.load_graphs_of(&mut base, &nodes)?; |
|
| 2269 | 2285 | let snapshot = Snapshot { |
|
| 2270 | - | tree: base.tree().view(), |
|
| 2286 | + | tree: self.tree_view(&mut base)?, |
|
| 2271 | 2287 | files: BTreeMap::new(), |
|
| 2272 | 2288 | unrendered: BTreeSet::new(), |
|
| 2273 | 2289 | }; |
|
| … | 12 unchanged lines |
||
| 2286 | 2302 | change.edits.iter().map(|e| e.node()).collect(); |
|
| 2287 | 2303 | let nodes: Vec<NodeId> = edited.iter().copied().collect(); |
|
| 2288 | 2304 | self.load_graphs_of(&mut base, &nodes)?; |
|
| 2305 | + | self.tree_view(&mut base)?; |
|
| 2289 | 2306 | let snapshot = { |
|
| 2290 | 2307 | let _t = crate::timing::phase("diff: of which materialize"); |
|
| 2291 | 2308 | base.materialize_nodes(&edited) |
|
| … | 119 unchanged lines |
modified crates/oxy-core/src/repo/revset.rs +12 −6
| … | 267 unchanged lines |
||
| 268 | 268 | .build() |
|
| 269 | 269 | .map_err(|e| err(format!("touches({glob:?}): {e}")))? |
|
| 270 | 270 | .compile_matcher(); |
|
| 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() { |
|
| 271 | + | // The tree alone names the nodes: no file graph is built (but for |
|
| 272 | + | // same-path additions it compares). The working copy's base comes |
|
| 273 | + | // from the workspace's state index. |
|
| 274 | + | let mut state = match self.working_copy() { |
|
| 274 | 275 | Some(wc) => { |
|
| 275 | 276 | let deps = self |
|
| 276 | 277 | .graph |
|
| … | 5 unchanged lines |
||
| 282 | 283 | } |
|
| 283 | 284 | None => self.repo.load_state_history(&self.repo.bookmark_heads()?)?, |
|
| 284 | 285 | }; |
|
| 285 | - | let nodes: HashSet<NodeId> = state |
|
| 286 | - | .tree() |
|
| 287 | - | .view() |
|
| 286 | + | let view = self.repo.tree_view(&mut state)?; |
|
| 287 | + | let mut nodes: HashSet<NodeId> = view |
|
| 288 | 288 | .entries |
|
| 289 | 289 | .iter() |
|
| 290 | 290 | .filter(|(path, _)| matcher.is_match(path)) |
|
| 291 | 291 | .map(|(_, e)| e.node) |
|
| 292 | 292 | .collect(); |
|
| 293 | + | // A file shown as one of identical additions is all of them. |
|
| 294 | + | for (dup, shown) in &view.unified { |
|
| 295 | + | if nodes.contains(shown) { |
|
| 296 | + | nodes.insert(*dup); |
|
| 297 | + | } |
|
| 298 | + | } |
|
| 293 | 299 | if nodes.is_empty() { |
|
| 294 | 300 | return Ok(Set::new()); |
|
| 295 | 301 | } |
|
| … | 469 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +128 −21
| … | 349 unchanged lines |
||
| 350 | 350 | if seed.is_some() { |
|
| 351 | 351 | b.retire_stray_placements(); |
|
| 352 | 352 | } |
|
| 353 | + | b.retire_unified(); |
|
| 353 | 354 | laps.lap("build: stray placements"); |
|
| 354 | 355 | let mut statuses = b.statuses; |
|
| 355 | 356 | statuses.sort_by(|a, b| a.path.cmp(&b.path)); |
|
| … | 80 unchanged lines |
||
| 436 | 437 | }; |
|
| 437 | 438 | let view = { |
|
| 438 | 439 | let _t = crate::timing::phase("snapshot: tree view"); |
|
| 439 | - | base.tree().view() |
|
| 440 | + | self.tree_view(&mut base)? |
|
| 440 | 441 | }; |
|
| 441 | 442 | let manifest = { |
|
| 442 | 443 | let _t = crate::timing::phase("snapshot: read manifest"); |
|
| … | 383 unchanged lines |
||
| 826 | 827 | attrs: &crate::attributes::Attributes, |
|
| 827 | 828 | ) -> HashMap<String, [u8; 32]> { |
|
| 828 | 829 | let _t = crate::timing::phase("commit: graph keys"); |
|
| 829 | - | let view = st.tree().view(); |
|
| 830 | + | let view = st.view(); |
|
| 830 | 831 | paths |
|
| 831 | 832 | .iter() |
|
| 832 | 833 | .filter_map(|p| { |
|
| … | 102 unchanged lines |
||
| 935 | 936 | (o, e, partial) |
|
| 936 | 937 | } |
|
| 937 | 938 | Selection::Hunks(indices) => { |
|
| 938 | - | let (o, e) = select_hunks(&snap.wc, indices); |
|
| 939 | + | let (o, e) = select_hunks(&snap.wc, indices, &snap.base_snapshot.tree.unified); |
|
| 939 | 940 | let partial = o.len() != snap.wc.tree_ops.len() || e.len() != snap.wc.edits.len(); |
|
| 940 | 941 | (o, e, partial) |
|
| 941 | 942 | } |
|
| … | 243 unchanged lines |
||
| 1185 | 1186 | nodes.insert(n); |
|
| 1186 | 1187 | } |
|
| 1187 | 1188 | } |
|
| 1189 | + | // The nodes shown as one with a selected node are retired with it. |
|
| 1190 | + | for (dup, shown) in &base_snapshot.tree.unified { |
|
| 1191 | + | if nodes.contains(shown) { |
|
| 1192 | + | nodes.insert(*dup); |
|
| 1193 | + | } |
|
| 1194 | + | } |
|
| 1188 | 1195 | // New parents of selected new nodes come along. |
|
| 1189 | 1196 | let mut changed = true; |
|
| 1190 | 1197 | while changed { |
|
| … | 22 unchanged lines |
||
| 1213 | 1220 | /// The hunks `indices` name plus whatever they refer to within the working |
|
| 1214 | 1221 | /// copy (the parent directory of an added node, the insert that introduced |
|
| 1215 | 1222 | /// an anchor line), renumbered so the result is a well-formed change. |
|
| 1216 | - | fn select_hunks(wc: &Change, indices: &[usize]) -> (Vec<TreeOp>, Vec<Edit>) { |
|
| 1223 | + | /// The nodes `unified` shows as one with another (a base's |
|
| 1224 | + | /// [`TreeView::unified`](crate::tree::TreeView::unified)) are retired with |
|
| 1225 | + | /// any hunk of the node shown. |
|
| 1226 | + | fn select_hunks( |
|
| 1227 | + | wc: &Change, |
|
| 1228 | + | indices: &[usize], |
|
| 1229 | + | unified: &BTreeMap<NodeId, NodeId>, |
|
| 1230 | + | ) -> (Vec<TreeOp>, Vec<Edit>) { |
|
| 1217 | 1231 | let n_ops = wc.tree_ops.len(); |
|
| 1218 | 1232 | let mut ops_sel = vec![false; n_ops]; |
|
| 1219 | 1233 | let mut edits_sel = vec![false; wc.edits.len()]; |
|
| … | 89 unchanged lines |
||
| 1309 | 1323 | } |
|
| 1310 | 1324 | } |
|
| 1311 | 1325 | } |
|
| 1326 | + | } |
|
| 1327 | + | } |
|
| 1328 | + | } |
|
| 1329 | + | } |
|
| 1330 | + | // The retirement of a node shown as one with a selected node. |
|
| 1331 | + | if !unified.is_empty() { |
|
| 1332 | + | let chosen: HashSet<NodeId> = (0..n_ops) |
|
| 1333 | + | .filter(|i| ops_sel[*i]) |
|
| 1334 | + | .map(|i| wc.tree_ops[i].node()) |
|
| 1335 | + | .chain( |
|
| 1336 | + | (0..wc.edits.len()) |
|
| 1337 | + | .filter(|j| edits_sel[*j]) |
|
| 1338 | + | .map(|j| wc.edits[j].node()), |
|
| 1339 | + | ) |
|
| 1340 | + | .collect(); |
|
| 1341 | + | for (i, o) in wc.tree_ops.iter().enumerate() { |
|
| 1342 | + | if let TreeOp::RemoveNode { node, .. } = o { |
|
| 1343 | + | if unified.get(node).is_some_and(|s| chosen.contains(s)) { |
|
| 1344 | + | ops_sel[i] = true; |
|
| 1312 | 1345 | } |
|
| 1313 | 1346 | } |
|
| 1314 | 1347 | } |
|
| … | 67 unchanged lines |
||
| 1382 | 1415 | } |
|
| 1383 | 1416 | let mut out = Vec::new(); |
|
| 1384 | 1417 | for (i, op) in change.tree_ops.iter().enumerate() { |
|
| 1418 | + | // A retired duplicate goes with the node shown: no hunk of its own. |
|
| 1419 | + | if is_unified_retirement(op, base_snapshot) { |
|
| 1420 | + | continue; |
|
| 1421 | + | } |
|
| 1385 | 1422 | let one = Change { |
|
| 1386 | 1423 | tree_ops: vec![op.clone()], |
|
| 1387 | 1424 | edits: vec![], |
|
| … | 66 unchanged lines |
||
| 1454 | 1491 | (None, Some(b)) => self.load_state_indexed(&b)?, |
|
| 1455 | 1492 | (None, None) => return fail("this is a bare repository: it has no working copy"), |
|
| 1456 | 1493 | }; |
|
| 1457 | - | let view = state.tree().view(); |
|
| 1494 | + | let view = self.tree_view(&mut state)?; |
|
| 1458 | 1495 | let Some(node) = view.entries.get(path).map(|e| e.node) else { |
|
| 1459 | 1496 | return fail(format!("{path} is not in that state")); |
|
| 1460 | 1497 | }; |
|
| … | 109 unchanged lines |
||
| 1570 | 1607 | .map(|h| h.index) |
|
| 1571 | 1608 | .filter(|i| !indices.contains(i)) |
|
| 1572 | 1609 | .collect(); |
|
| 1573 | - | let (ops, edits) = select_hunks(&snap.wc, &keep); |
|
| 1610 | + | let (ops, edits) = select_hunks(&snap.wc, &keep, &snap.base_snapshot.tree.unified); |
|
| 1574 | 1611 | let dropped_nodes: BTreeSet<NodeId> = hunks |
|
| 1575 | 1612 | .iter() |
|
| 1576 | 1613 | .filter(|h| indices.contains(&h.index)) |
|
| … | 173 unchanged lines |
||
| 1750 | 1787 | // The path's node from the trees alone: the base's (from the |
|
| 1751 | 1788 | // workspace's state index), else the bookmark's. Nothing is |
|
| 1752 | 1789 | // rendered. |
|
| 1753 | - | let node_in = |state: State| state.tree().view().entries.get(path).map(|e| e.node); |
|
| 1754 | - | let node = match node_in(self.load_state_indexed(&wc.deps)?) { |
|
| 1790 | + | let node_in = |mut state: State| -> Result<Option<HashSet<NodeId>>> { |
|
| 1791 | + | Ok(nodes_at(&self.tree_view(&mut state)?, path)) |
|
| 1792 | + | }; |
|
| 1793 | + | let nodes = match node_in(self.load_state_indexed(&wc.deps)?)? { |
|
| 1755 | 1794 | Some(n) => Some(n), |
|
| 1756 | - | None => node_in(self.load_state_history(&heads)?), |
|
| 1795 | + | None => node_in(self.load_state_history(&heads)?)?, |
|
| 1757 | 1796 | }; |
|
| 1758 | - | let Some(node) = node else { |
|
| 1797 | + | let Some(nodes) = nodes else { |
|
| 1759 | 1798 | return fail(format!("{path} is not tracked")); |
|
| 1760 | 1799 | }; |
|
| 1761 | - | Ok(self.entries_touching(entries, node)) |
|
| 1800 | + | Ok(self.entries_touching_up_to(entries, nodes, None)) |
|
| 1762 | 1801 | } |
|
| 1763 | 1802 | ||
| 1764 | 1803 | /// The log of the state with these heads restricted to changes touching |
|
| … | 11 unchanged lines |
||
| 1776 | 1815 | path: &str, |
|
| 1777 | 1816 | limit: Option<usize>, |
|
| 1778 | 1817 | ) -> Result<Vec<LogEntry>> { |
|
| 1779 | - | let Some(node) = state.tree().view().entries.get(path).map(|e| e.node) else { |
|
| 1818 | + | let Some(nodes) = nodes_at(&state.view(), path) else { |
|
| 1780 | 1819 | return fail(format!("{path} is not in that state")); |
|
| 1781 | 1820 | }; |
|
| 1782 | - | Ok(self.entries_touching_up_to(self.log_of(state), node, limit)) |
|
| 1783 | - | } |
|
| 1784 | - | ||
| 1785 | - | fn entries_touching(&self, entries: Vec<LogEntry>, node: NodeId) -> Vec<LogEntry> { |
|
| 1786 | - | self.entries_touching_up_to(entries, node, None) |
|
| 1821 | + | Ok(self.entries_touching_up_to(self.log_of(state), nodes, limit)) |
|
| 1787 | 1822 | } |
|
| 1788 | 1823 | ||
| 1789 | 1824 | fn entries_touching_up_to( |
|
| 1790 | 1825 | &self, |
|
| 1791 | 1826 | entries: Vec<LogEntry>, |
|
| 1792 | - | node: NodeId, |
|
| 1827 | + | wanted: HashSet<NodeId>, |
|
| 1793 | 1828 | limit: Option<usize>, |
|
| 1794 | 1829 | ) -> Vec<LogEntry> { |
|
| 1795 | 1830 | let mut touched = self.touched(); |
|
| … | 1 unchanged lines |
||
| 1797 | 1832 | let hashes: Vec<ChangeHash> = entries.iter().map(|e| e.hash).collect(); |
|
| 1798 | 1833 | touched.fill(self, &hashes); |
|
| 1799 | 1834 | } |
|
| 1800 | - | let wanted = HashSet::from([node]); |
|
| 1801 | 1835 | let mut out = Vec::new(); |
|
| 1802 | 1836 | for e in entries { |
|
| 1803 | 1837 | if limit.is_some_and(|n| out.len() >= n) { |
|
| … | 6 unchanged lines |
||
| 1810 | 1844 | touched.save(); |
|
| 1811 | 1845 | out |
|
| 1812 | 1846 | } |
|
| 1847 | + | } |
|
| 1848 | + | ||
| 1849 | + | /// The node at `path` in `view`, with the nodes it shows as one with it |
|
| 1850 | + | /// (identical additions, spec §9.2): the file's history is all of theirs. |
|
| 1851 | + | fn nodes_at(view: &crate::tree::TreeView, path: &str) -> Option<HashSet<NodeId>> { |
|
| 1852 | + | let node = view.entries.get(path)?.node; |
|
| 1853 | + | let mut out = HashSet::from([node]); |
|
| 1854 | + | out.extend( |
|
| 1855 | + | view.unified |
|
| 1856 | + | .iter() |
|
| 1857 | + | .filter(|(_, shown)| **shown == node) |
|
| 1858 | + | .map(|(dup, _)| *dup), |
|
| 1859 | + | ); |
|
| 1860 | + | Some(out) |
|
| 1813 | 1861 | } |
|
| 1814 | 1862 | ||
| 1815 | 1863 | /// Per-file hunks of `change` against `base`. |
|
| … | 28 unchanged lines |
||
| 1844 | 1892 | }); |
|
| 1845 | 1893 | } |
|
| 1846 | 1894 | }; |
|
| 1847 | - | for op in &change.tree_ops { |
|
| 1895 | + | for op in change |
|
| 1896 | + | .tree_ops |
|
| 1897 | + | .iter() |
|
| 1898 | + | .filter(|o| !is_unified_retirement(o, base_snapshot)) |
|
| 1899 | + | { |
|
| 1848 | 1900 | match op { |
|
| 1849 | 1901 | TreeOp::AddNode { node, kind, .. } => { |
|
| 1850 | 1902 | if *kind != NodeKind::Dir { |
|
| … | 517 unchanged lines |
||
| 2368 | 2420 | } |
|
| 2369 | 2421 | } |
|
| 2370 | 2422 | ||
| 2423 | + | /// Nodes the base shows as one with another (identical additions at one |
|
| 2424 | + | /// path, spec §9.2) are retired by the change that first touches the |
|
| 2425 | + | /// node shown: edited, moved, removed or given a mode alone, it would |
|
| 2426 | + | /// differ from them and the name conflict would come back. Nothing |
|
| 2427 | + | /// else changes for the user, so no status reports it. |
|
| 2428 | + | fn retire_unified(&mut self) { |
|
| 2429 | + | let unified = &self.base_snapshot.tree.unified; |
|
| 2430 | + | if unified.is_empty() { |
|
| 2431 | + | return; |
|
| 2432 | + | } |
|
| 2433 | + | let touched: HashSet<NodeId> = self |
|
| 2434 | + | .tree_ops |
|
| 2435 | + | .iter() |
|
| 2436 | + | .map(|o| o.node()) |
|
| 2437 | + | .chain(self.edits.iter().map(|e| e.node())) |
|
| 2438 | + | .collect(); |
|
| 2439 | + | let mut retired: HashSet<(ChangeHash, u32)> = HashSet::new(); |
|
| 2440 | + | for op in &self.tree_ops { |
|
| 2441 | + | match op { |
|
| 2442 | + | TreeOp::Move { old, .. } => { |
|
| 2443 | + | retired.insert((old.change, old.index)); |
|
| 2444 | + | } |
|
| 2445 | + | TreeOp::RemoveNode { placement, .. } => { |
|
| 2446 | + | retired.insert((placement.change, placement.index)); |
|
| 2447 | + | } |
|
| 2448 | + | TreeOp::AddNode { .. } | TreeOp::SetMode { .. } => {} |
|
| 2449 | + | } |
|
| 2450 | + | } |
|
| 2451 | + | let mut extra = Vec::new(); |
|
| 2452 | + | for (dup, shown) in unified { |
|
| 2453 | + | if !touched.contains(shown) { |
|
| 2454 | + | continue; |
|
| 2455 | + | } |
|
| 2456 | + | for (k, _) in self.base.tree().live_placements(dup) { |
|
| 2457 | + | if retired.insert(k) { |
|
| 2458 | + | extra.push(TreeOp::RemoveNode { |
|
| 2459 | + | node: *dup, |
|
| 2460 | + | placement: op_ref(k), |
|
| 2461 | + | }); |
|
| 2462 | + | } |
|
| 2463 | + | } |
|
| 2464 | + | } |
|
| 2465 | + | self.tree_ops.extend(extra); |
|
| 2466 | + | } |
|
| 2467 | + | ||
| 2371 | 2468 | /// The node's other live placements at the same place as `k`, the one |
|
| 2372 | 2469 | /// the tree shows: two lines of history that put a node back where it |
|
| 2373 | 2470 | /// was each leave a placement, and the tree shows them as one file. A |
|
| … | 412 unchanged lines |
||
| 2786 | 2883 | out |
|
| 2787 | 2884 | } |
|
| 2788 | 2885 | ||
| 2886 | + | /// Whether `op` retires a node `base_snapshot` shows as one with another |
|
| 2887 | + | /// (spec §9.2): part of touching that node, never a removal the user made. |
|
| 2888 | + | fn is_unified_retirement(op: &TreeOp, base_snapshot: &Snapshot) -> bool { |
|
| 2889 | + | matches!(op, TreeOp::RemoveNode { node, .. } if base_snapshot.tree.unified.contains_key(node)) |
|
| 2890 | + | } |
|
| 2891 | + | ||
| 2789 | 2892 | fn path_of_node( |
|
| 2790 | 2893 | n: &NodeId, |
|
| 2791 | 2894 | by_node: &HashMap<NodeId, &crate::tree::TreeEntry>, |
|
| … | 43 unchanged lines |
||
| 2835 | 2938 | }; |
|
| 2836 | 2939 | ||
| 2837 | 2940 | let mut out = String::new(); |
|
| 2838 | - | for op in &change.tree_ops { |
|
| 2941 | + | for op in change |
|
| 2942 | + | .tree_ops |
|
| 2943 | + | .iter() |
|
| 2944 | + | .filter(|o| !is_unified_retirement(o, base_snapshot)) |
|
| 2945 | + | { |
|
| 2839 | 2946 | match op { |
|
| 2840 | 2947 | TreeOp::AddNode { |
|
| 2841 | 2948 | node, kind, mode, .. |
|
| … | 236 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +291 −0
| … | 326 unchanged lines |
||
| 327 | 327 | ); |
|
| 328 | 328 | } |
|
| 329 | 329 | ||
| 330 | + | /// Each of two bookmarks adds `path` (a file written by `side` on each) |
|
| 331 | + | /// from the same base, and the second is merged into the first. Returns |
|
| 332 | + | /// the repository on `main`, the merge's heads, and the three changes. |
|
| 333 | + | fn merged_additions( |
|
| 334 | + | side: impl Fn(&Path, bool), |
|
| 335 | + | ) -> (tempfile::TempDir, Repo, Vec<ChangeHash>, [ChangeHash; 3]) { |
|
| 336 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 337 | + | let root = dir.path().to_path_buf(); |
|
| 338 | + | let mut repo = Repo::init(&root).unwrap(); |
|
| 339 | + | write(&root, "keep.txt", b"k\n"); |
|
| 340 | + | let h1 = repo.commit("base", &[]).unwrap(); |
|
| 341 | + | repo.bookmark_create("side", None).unwrap(); |
|
| 342 | + | side(&root, false); |
|
| 343 | + | let ha = repo.commit("add on main", &[]).unwrap(); |
|
| 344 | + | repo.bookmark_switch("side", false).unwrap(); |
|
| 345 | + | side(&root, true); |
|
| 346 | + | let hb = repo.commit("add on side", &[]).unwrap(); |
|
| 347 | + | repo.bookmark_switch("main", false).unwrap(); |
|
| 348 | + | let m = repo.merge("side").unwrap(); |
|
| 349 | + | assert!(m.changed && m.checked_out); |
|
| 350 | + | (dir, repo, m.heads, [h1, ha, hb]) |
|
| 351 | + | } |
|
| 352 | + | ||
| 353 | + | fn conflict_copies(root: &Path) -> Vec<String> { |
|
| 354 | + | let mut out: Vec<String> = fs::read_dir(root) |
|
| 355 | + | .unwrap() |
|
| 356 | + | .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) |
|
| 357 | + | .filter(|n| n.contains(".oxy-conflict-")) |
|
| 358 | + | .collect(); |
|
| 359 | + | out.sort(); |
|
| 360 | + | out |
|
| 361 | + | } |
|
| 362 | + | ||
| 363 | + | /// The same path added on two bookmarks with identical content is one |
|
| 364 | + | /// file after the merge (oxygen#101): no conflict, nothing to resolve, |
|
| 365 | + | /// nothing recorded; and editing or removing it later keeps it one. |
|
| 366 | + | #[test] |
|
| 367 | + | fn identical_additions_unify_on_merge() { |
|
| 368 | + | hermetic(); |
|
| 369 | + | let (dir, repo, heads, [h1, ha, hb]) = |
|
| 370 | + | merged_additions(|root, _| write(root, "x.txt", b"same\nlines\n")); |
|
| 371 | + | let root = dir.path(); |
|
| 372 | + | // The tree graph has the name conflict; every reader sees one file. |
|
| 373 | + | let st = repo.load_state(&heads).unwrap(); |
|
| 374 | + | assert!(st |
|
| 375 | + | .tree() |
|
| 376 | + | .view() |
|
| 377 | + | .conflicts |
|
| 378 | + | .iter() |
|
| 379 | + | .any(|c| matches!(c, crate::tree::TreeConflict::Name { .. }))); |
|
| 380 | + | let snap = st.materialize(); |
|
| 381 | + | assert!(!snap.is_conflicted(), "{:?}", snap.tree.conflicts); |
|
| 382 | + | assert_eq!( |
|
| 383 | + | snap.files.keys().cloned().collect::<Vec<_>>(), |
|
| 384 | + | ["keep.txt", "x.txt"] |
|
| 385 | + | ); |
|
| 386 | + | assert!( |
|
| 387 | + | matches!(&snap.files["x.txt"].content, FileContent::Text { bytes, .. } if bytes == b"same\nlines\n") |
|
| 388 | + | ); |
|
| 389 | + | assert_eq!(snap.tree.unified.len(), 1); |
|
| 390 | + | // On disk once, the working copy clean, nothing to commit. |
|
| 391 | + | assert_eq!(fs::read(root.join("x.txt")).unwrap(), b"same\nlines\n"); |
|
| 392 | + | assert!( |
|
| 393 | + | conflict_copies(root).is_empty(), |
|
| 394 | + | "{:?}", |
|
| 395 | + | conflict_copies(root) |
|
| 396 | + | ); |
|
| 397 | + | let s = repo.snapshot("status").unwrap(); |
|
| 398 | + | assert!(s.statuses.is_empty(), "{:?}", statuses(&s)); |
|
| 399 | + | assert!(!s.changed && !s.resolves); |
|
| 400 | + | assert!(!s.base_snapshot.is_conflicted()); |
|
| 401 | + | assert!(s.wc.tree_ops.is_empty() && s.wc.edits.is_empty()); |
|
| 402 | + | let json = repo.status_json(&s).unwrap(); |
|
| 403 | + | assert!(json.contains("\"conflicted\":false"), "{json}"); |
|
| 404 | + | assert!(repo.commit("nothing", &[]).is_err()); |
|
| 405 | + | assert_eq!(repo.file_at("x.txt", None).unwrap(), b"same\nlines\n"); |
|
| 406 | + | // A peer holding the same changes, added in another order, agrees. |
|
| 407 | + | let mut peer = State::new(); |
|
| 408 | + | for h in [hb, ha, h1] { |
|
| 409 | + | peer.add_unchecked(repo.store().get_change(&h).unwrap()); |
|
| 410 | + | } |
|
| 411 | + | peer.validate().unwrap(); |
|
| 412 | + | let ps = peer.materialize(); |
|
| 413 | + | assert!(!ps.is_conflicted()); |
|
| 414 | + | assert_eq!(ps.tree.entries["x.txt"], snap.tree.entries["x.txt"]); |
|
| 415 | + | assert_eq!(ps.tree.unified, snap.tree.unified); |
|
| 416 | + | // The file's history is both additions. |
|
| 417 | + | let log: Vec<ChangeHash> = repo |
|
| 418 | + | .log_for_path("x.txt") |
|
| 419 | + | .unwrap() |
|
| 420 | + | .iter() |
|
| 421 | + | .map(|e| e.hash) |
|
| 422 | + | .collect(); |
|
| 423 | + | assert!(log.contains(&ha) && log.contains(&hb), "{log:?}"); |
|
| 424 | + | ||
| 425 | + | // Edited and committed: one file still, the duplicate retired with it. |
|
| 426 | + | write(root, "x.txt", b"same\nedited\n"); |
|
| 427 | + | let s = repo.snapshot("status").unwrap(); |
|
| 428 | + | assert_eq!(statuses(&s), vec![("x.txt".into(), ChangeStatus::Modified)]); |
|
| 429 | + | let he = repo.commit("edit x", &[]).unwrap(); |
|
| 430 | + | let c = repo.store().get_change(&he).unwrap(); |
|
| 431 | + | assert_eq!(c.kind, ChangeKind::Normal); |
|
| 432 | + | let (dup, _) = snap.tree.unified.iter().next().unwrap(); |
|
| 433 | + | assert!( |
|
| 434 | + | c.tree_ops |
|
| 435 | + | .iter() |
|
| 436 | + | .any(|o| matches!(o, TreeOp::RemoveNode { node, .. } if node == dup)), |
|
| 437 | + | "{:?}", |
|
| 438 | + | c.tree_ops |
|
| 439 | + | ); |
|
| 440 | + | let d = repo.describe_change(&he).unwrap(); |
|
| 441 | + | assert!(!d.contains("D x.txt"), "{d}"); |
|
| 442 | + | let st = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap(); |
|
| 443 | + | assert!( |
|
| 444 | + | st.tree().view().conflicts.is_empty(), |
|
| 445 | + | "nothing left to unify" |
|
| 446 | + | ); |
|
| 447 | + | let snap = st.materialize(); |
|
| 448 | + | assert!(!snap.is_conflicted()); |
|
| 449 | + | assert!( |
|
| 450 | + | matches!(&snap.files["x.txt"].content, FileContent::Text { bytes, .. } if bytes == b"same\nedited\n") |
|
| 451 | + | ); |
|
| 452 | + | assert!(!repo.snapshot("status").unwrap().changed); |
|
| 453 | + | ||
| 454 | + | // Removed and committed: gone. |
|
| 455 | + | fs::remove_file(root.join("x.txt")).unwrap(); |
|
| 456 | + | repo.commit("drop x", &[]).unwrap(); |
|
| 457 | + | let snap = repo |
|
| 458 | + | .load_state(&repo.bookmark_heads().unwrap()) |
|
| 459 | + | .unwrap() |
|
| 460 | + | .materialize(); |
|
| 461 | + | assert!(!snap.is_conflicted(), "{:?}", snap.tree.conflicts); |
|
| 462 | + | assert_eq!(snap.files.keys().cloned().collect::<Vec<_>>(), ["keep.txt"]); |
|
| 463 | + | assert!(!root.join("x.txt").exists()); |
|
| 464 | + | } |
|
| 465 | + | ||
| 466 | + | /// Removing, renaming, or committing just the path of a unified file |
|
| 467 | + | /// retires its duplicate with it, so the conflict never comes back. |
|
| 468 | + | #[test] |
|
| 469 | + | fn unified_additions_stay_one_when_removed_renamed_or_committed_alone() { |
|
| 470 | + | hermetic(); |
|
| 471 | + | let add = |root: &Path, _: bool| write(root, "x.txt", b"same\n"); |
|
| 472 | + | for how in ["remove", "rename", "path", "hunk"] { |
|
| 473 | + | let (dir, repo, _, _) = merged_additions(add); |
|
| 474 | + | let root = dir.path(); |
|
| 475 | + | match how { |
|
| 476 | + | "remove" => fs::remove_file(root.join("x.txt")).unwrap(), |
|
| 477 | + | "rename" => fs::rename(root.join("x.txt"), root.join("y.txt")).unwrap(), |
|
| 478 | + | _ => { |
|
| 479 | + | write(root, "x.txt", b"same\nmore\n"); |
|
| 480 | + | write(root, "other.txt", b"o\n"); |
|
| 481 | + | } |
|
| 482 | + | } |
|
| 483 | + | let s = repo.snapshot("status").unwrap(); |
|
| 484 | + | let h = match how { |
|
| 485 | + | "path" => repo |
|
| 486 | + | .commit_selection("x alone", &[], &Selection::Paths(vec!["x.txt".into()])) |
|
| 487 | + | .unwrap(), |
|
| 488 | + | "hunk" => { |
|
| 489 | + | let (_, hunks) = repo.hunks().unwrap(); |
|
| 490 | + | // The duplicate's retirement is no hunk of its own. |
|
| 491 | + | assert!(hunks.iter().all(|h| !h.text.starts_with("D ")), "{hunks:?}"); |
|
| 492 | + | let x: Vec<usize> = hunks |
|
| 493 | + | .iter() |
|
| 494 | + | .filter(|h| h.path == "x.txt") |
|
| 495 | + | .map(|h| h.index) |
|
| 496 | + | .collect(); |
|
| 497 | + | assert!(!x.is_empty()); |
|
| 498 | + | repo.commit_selection("x hunks", &[], &Selection::Hunks(x)) |
|
| 499 | + | .unwrap() |
|
| 500 | + | } |
|
| 501 | + | _ => repo.commit(how, &[]).unwrap(), |
|
| 502 | + | }; |
|
| 503 | + | assert!(!s.base_snapshot.is_conflicted()); |
|
| 504 | + | let heads = repo.bookmark_heads().unwrap(); |
|
| 505 | + | assert!(heads.contains(&h)); |
|
| 506 | + | let snap = repo.load_state(&heads).unwrap().materialize(); |
|
| 507 | + | assert!(!snap.is_conflicted(), "{how}: {:?}", snap.tree.conflicts); |
|
| 508 | + | let want: &[&str] = match how { |
|
| 509 | + | "remove" => &["keep.txt"], |
|
| 510 | + | "rename" => &["keep.txt", "y.txt"], |
|
| 511 | + | _ => &["keep.txt", "x.txt"], |
|
| 512 | + | }; |
|
| 513 | + | assert_eq!( |
|
| 514 | + | snap.files.keys().cloned().collect::<Vec<_>>(), |
|
| 515 | + | want, |
|
| 516 | + | "{how}" |
|
| 517 | + | ); |
|
| 518 | + | assert!(conflict_copies(root).is_empty(), "{how}"); |
|
| 519 | + | } |
|
| 520 | + | } |
|
| 521 | + | ||
| 522 | + | /// The same path added with different content, or a different mode, is a |
|
| 523 | + | /// name conflict as before. |
|
| 524 | + | #[test] |
|
| 525 | + | fn additions_that_differ_still_conflict() { |
|
| 526 | + | hermetic(); |
|
| 527 | + | #[cfg(unix)] |
|
| 528 | + | use std::os::unix::fs::PermissionsExt; |
|
| 529 | + | let content = |
|
| 530 | + | |root: &Path, side: bool| write(root, "x.txt", if side { b"two\n" } else { b"one\n" }); |
|
| 531 | + | let mode = |root: &Path, side: bool| { |
|
| 532 | + | write(root, "x.txt", b"same\n"); |
|
| 533 | + | #[cfg(unix)] |
|
| 534 | + | if side { |
|
| 535 | + | fs::set_permissions(root.join("x.txt"), fs::Permissions::from_mode(0o755)).unwrap(); |
|
| 536 | + | } |
|
| 537 | + | }; |
|
| 538 | + | let check = |what: &str, side: &dyn Fn(&Path, bool)| { |
|
| 539 | + | let (dir, repo, heads, _) = merged_additions(side); |
|
| 540 | + | let snap = repo.load_state(&heads).unwrap().materialize(); |
|
| 541 | + | assert!(snap.tree.unified.is_empty(), "{what}"); |
|
| 542 | + | assert!( |
|
| 543 | + | snap.tree.conflicts.iter().any( |
|
| 544 | + | |c| matches!(c, crate::tree::TreeConflict::Name { name, .. } if name == "x.txt") |
|
| 545 | + | ), |
|
| 546 | + | "{what}: {:?}", |
|
| 547 | + | snap.tree.conflicts |
|
| 548 | + | ); |
|
| 549 | + | assert_eq!( |
|
| 550 | + | conflict_copies(dir.path()), |
|
| 551 | + | ["x.txt.oxy-conflict-1", "x.txt.oxy-conflict-2"], |
|
| 552 | + | "{what}" |
|
| 553 | + | ); |
|
| 554 | + | assert!(!dir.path().join("x.txt").exists(), "{what}"); |
|
| 555 | + | }; |
|
| 556 | + | check("content", &content); |
|
| 557 | + | #[cfg(unix)] |
|
| 558 | + | check("mode", &mode); |
|
| 559 | + | #[cfg(not(unix))] |
|
| 560 | + | let _ = mode; |
|
| 561 | + | } |
|
| 562 | + | ||
| 563 | + | /// The same subrepo at the same pin, added on two bookmarks, is one |
|
| 564 | + | /// subrepo after the merge (oxygen#101), as is the identical |
|
| 565 | + | /// `.oxymodules` beside it. |
|
| 566 | + | #[test] |
|
| 567 | + | fn the_same_subrepo_added_on_two_bookmarks_unifies() { |
|
| 568 | + | hermetic(); |
|
| 569 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 570 | + | let root = dir.path(); |
|
| 571 | + | let lib_origin = Repo::init(&root.join("lib-origin")).unwrap(); |
|
| 572 | + | let lib_url = root.join("lib-origin").to_string_lossy().to_string(); |
|
| 573 | + | let lib = Repo::clone(&lib_url, &root.join("lib")) |
|
| 574 | + | .unwrap() |
|
| 575 | + | .with_home(root.join("home")); |
|
| 576 | + | write(&root.join("lib"), "lib.txt", b"v1\n"); |
|
| 577 | + | lib.commit("lib v1", &[]).unwrap(); |
|
| 578 | + | assert!(lib.push("origin", false).unwrap()[0].ok); |
|
| 579 | + | let _ = lib_origin; |
|
| 580 | + | ||
| 581 | + | let app_dir = root.join("app"); |
|
| 582 | + | let mut app = Repo::init(&app_dir).unwrap().with_home(root.join("home")); |
|
| 583 | + | write(&app_dir, "main.txt", b"app\n"); |
|
| 584 | + | app.commit("base", &[]).unwrap(); |
|
| 585 | + | app.bookmark_create("side", None).unwrap(); |
|
| 586 | + | app.submodule_add(&lib_url, "rstar-api", None).unwrap(); |
|
| 587 | + | let modules = fs::read(app_dir.join(".oxymodules")).unwrap(); |
|
| 588 | + | app.commit("rstar-api on main", &[]).unwrap(); |
|
| 589 | + | // On the other bookmark the nested repository is still there (it is |
|
| 590 | + | // never deleted); registering it again adds it at the same pin. |
|
| 591 | + | app.bookmark_switch("side", false).unwrap(); |
|
| 592 | + | assert!(app_dir.join("rstar-api/.oxy").is_dir()); |
|
| 593 | + | write(&app_dir, ".oxymodules", &modules); |
|
| 594 | + | let s = app.snapshot("status").unwrap(); |
|
| 595 | + | assert_eq!( |
|
| 596 | + | statuses(&s), |
|
| 597 | + | vec![ |
|
| 598 | + | (".oxymodules".into(), ChangeStatus::Added), |
|
| 599 | + | ("rstar-api".into(), ChangeStatus::Added) |
|
| 600 | + | ] |
|
| 601 | + | ); |
|
| 602 | + | app.commit("rstar-api on side", &[]).unwrap(); |
|
| 603 | + | app.bookmark_switch("main", false).unwrap(); |
|
| 604 | + | ||
| 605 | + | let m = app.merge("side").unwrap(); |
|
| 606 | + | assert!(m.changed && m.checked_out); |
|
| 607 | + | let snap = app.load_state(&m.heads).unwrap().materialize(); |
|
| 608 | + | assert!(!snap.is_conflicted(), "{:?}", snap.tree.conflicts); |
|
| 609 | + | assert_eq!(snap.tree.unified.len(), 2); |
|
| 610 | + | assert_eq!(snap.files["rstar-api"].kind, NodeKind::Subrepo); |
|
| 611 | + | assert!(conflict_copies(&app_dir).is_empty()); |
|
| 612 | + | let s = app.snapshot("status").unwrap(); |
|
| 613 | + | assert!(s.statuses.is_empty(), "{:?}", statuses(&s)); |
|
| 614 | + | assert!(!s.base_snapshot.is_conflicted()); |
|
| 615 | + | assert_eq!( |
|
| 616 | + | fs::read_to_string(app_dir.join("rstar-api/lib.txt")).unwrap(), |
|
| 617 | + | "v1\n" |
|
| 618 | + | ); |
|
| 619 | + | } |
|
| 620 | + | ||
| 330 | 621 | #[test] |
|
| 331 | 622 | fn identities_and_signed_commits() { |
|
| 332 | 623 | hermetic(); |
|
| … | 7785 unchanged lines |
modified crates/oxy-core/src/state.rs +100 −8
| … | 110 unchanged lines |
||
| 111 | 111 | /// changes and tree), the file graphs not built yet, by node: `files` |
|
| 112 | 112 | /// holds only those loaded with [`State::load_graphs`]. |
|
| 113 | 113 | deferred: Option<crate::fasthash::FastMap<NodeId, Arc<Deferred>>>, |
|
| 114 | + | /// The tree as readers see it ([`State::view`]), once worked out for |
|
| 115 | + | /// a tree with name conflicts; cleared by every change added. |
|
| 116 | + | unified_view: std::sync::OnceLock<TreeView>, |
|
| 114 | 117 | } |
|
| 115 | 118 | ||
| 116 | 119 | use crate::objects::TextRef; |
|
| … | 574 unchanged lines |
||
| 691 | 694 | closure: Mutex::new(ClosureMemo::default()), |
|
| 692 | 695 | levels: Mutex::new(HashMap::new()), |
|
| 693 | 696 | deferred: self.deferred.clone(), |
|
| 697 | + | unified_view: self.unified_view.clone(), |
|
| 694 | 698 | } |
|
| 695 | 699 | } |
|
| 696 | 700 | } |
|
| … | 578 unchanged lines |
||
| 1275 | 1279 | pub fn tree(&self) -> &TreeGraph { |
|
| 1276 | 1280 | &self.tree |
|
| 1277 | 1281 | } |
|
| 1282 | + | ||
| 1283 | + | /// The tree as every reader sees it: the tree graph's view, with the |
|
| 1284 | + | /// same path added on several lines of history with identical content |
|
| 1285 | + | /// shown as one entry rather than a name conflict (spec §9.2). The |
|
| 1286 | + | /// text files among such candidates must be built in a history-only |
|
| 1287 | + | /// state ([`State::unify_candidates`]); until they are, they are shown |
|
| 1288 | + | /// as the conflict. |
|
| 1289 | + | pub fn view(&self) -> TreeView { |
|
| 1290 | + | if let Some(v) = self.unified_view.get() { |
|
| 1291 | + | return v.clone(); |
|
| 1292 | + | } |
|
| 1293 | + | let raw = self.tree.view(); |
|
| 1294 | + | if !raw |
|
| 1295 | + | .conflicts |
|
| 1296 | + | .iter() |
|
| 1297 | + | .any(|c| matches!(c, crate::tree::TreeConflict::Name { .. })) |
|
| 1298 | + | { |
|
| 1299 | + | return raw; |
|
| 1300 | + | } |
|
| 1301 | + | let (view, decided) = self.tree.unify(raw, |es| self.same_content(es)); |
|
| 1302 | + | if decided { |
|
| 1303 | + | let _ = self.unified_view.set(view.clone()); |
|
| 1304 | + | } |
|
| 1305 | + | view |
|
| 1306 | + | } |
|
| 1307 | + | ||
| 1308 | + | /// The text files [`State::view`] must compare and a history-only |
|
| 1309 | + | /// state has not built: candidates of the tree's name conflicts. |
|
| 1310 | + | pub fn unify_candidates(&self) -> Vec<NodeId> { |
|
| 1311 | + | let Some(deferred) = &self.deferred else { |
|
| 1312 | + | return Vec::new(); |
|
| 1313 | + | }; |
|
| 1314 | + | if self.unified_view.get().is_some() { |
|
| 1315 | + | return Vec::new(); |
|
| 1316 | + | } |
|
| 1317 | + | let raw = self.tree.view(); |
|
| 1318 | + | let mut out = Vec::new(); |
|
| 1319 | + | for c in &raw.conflicts { |
|
| 1320 | + | if let crate::tree::TreeConflict::Name { nodes, .. } = c { |
|
| 1321 | + | out.extend(nodes.iter().filter(|n| deferred.contains_key(*n))); |
|
| 1322 | + | } |
|
| 1323 | + | } |
|
| 1324 | + | out |
|
| 1325 | + | } |
|
| 1326 | + | ||
| 1327 | + | /// Whether the entries' contents are identical, unconflicted: the same |
|
| 1328 | + | /// rendering for text files, the same single blob for opaque ones, |
|
| 1329 | + | /// symlinks and subrepos. `None` for a text file not built yet. |
|
| 1330 | + | fn same_content(&self, entries: &[&crate::tree::TreeEntry]) -> Option<bool> { |
|
| 1331 | + | let text = |e: &crate::tree::TreeEntry| e.kind == NodeKind::File && !e.mode.opaque; |
|
| 1332 | + | let mut first: Option<Vec<u8>> = None; |
|
| 1333 | + | let mut first_blob: Option<ObjectRef> = None; |
|
| 1334 | + | for e in entries { |
|
| 1335 | + | if text(e) { |
|
| 1336 | + | if self |
|
| 1337 | + | .deferred |
|
| 1338 | + | .as_ref() |
|
| 1339 | + | .is_some_and(|d| d.contains_key(&e.node)) |
|
| 1340 | + | { |
|
| 1341 | + | return None; |
|
| 1342 | + | } |
|
| 1343 | + | let (bytes, m) = self.render_file(&e.node); |
|
| 1344 | + | if m.is_conflicted() { |
|
| 1345 | + | return Some(false); |
|
| 1346 | + | } |
|
| 1347 | + | match &first { |
|
| 1348 | + | None => first = Some(bytes), |
|
| 1349 | + | Some(b) if *b == bytes => {} |
|
| 1350 | + | Some(_) => return Some(false), |
|
| 1351 | + | } |
|
| 1352 | + | } else { |
|
| 1353 | + | let [blob] = self.live_blobs(&e.node)[..] else { |
|
| 1354 | + | return Some(false); |
|
| 1355 | + | }; |
|
| 1356 | + | match first_blob { |
|
| 1357 | + | None => first_blob = Some(blob), |
|
| 1358 | + | Some(b) if b == blob => {} |
|
| 1359 | + | Some(_) => return Some(false), |
|
| 1360 | + | } |
|
| 1361 | + | } |
|
| 1362 | + | } |
|
| 1363 | + | Some(true) |
|
| 1364 | + | } |
|
| 1278 | 1365 | pub fn file(&self, node: &NodeId) -> Option<&FileGraph> { |
|
| 1279 | 1366 | self.graph(node) |
|
| 1280 | 1367 | } |
|
| … | 93 unchanged lines |
||
| 1374 | 1461 | return; |
|
| 1375 | 1462 | } |
|
| 1376 | 1463 | let meta = ChangeMeta::of(&change); |
|
| 1464 | + | self.unified_view = Default::default(); |
|
| 1377 | 1465 | self.apply_with(h, change, texts); |
|
| 1378 | 1466 | self.changes.insert(h, Arc::new(meta)); |
|
| 1379 | 1467 | { |
|
| … | 627 unchanged lines |
||
| 2007 | 2095 | pub fn materialize(&self) -> Snapshot { |
|
| 2008 | 2096 | use rayon::prelude::*; |
|
| 2009 | 2097 | self.require_full("materializing a state"); |
|
| 2010 | - | let tree = self.tree.view(); |
|
| 2098 | + | let tree = self.view(); |
|
| 2011 | 2099 | let entries: Vec<(&String, &crate::tree::TreeEntry)> = tree.entries.iter().collect(); |
|
| 2012 | 2100 | let rendered: Vec<(String, FileState)> = entries |
|
| 2013 | 2101 | .par_iter() |
|
| … | 10 unchanged lines |
||
| 2024 | 2112 | /// Like [`State::materialize`], leaving the text files of nodes in |
|
| 2025 | 2113 | /// `skip` unrendered (listed in [`Snapshot::unrendered`]); every other |
|
| 2026 | 2114 | /// file's graph must be loaded. For a caller that already knows those |
|
| 2027 | - | /// files are unchanged and not conflicted. |
|
| 2115 | + | /// files are unchanged and not conflicted. Its tree is the tree |
|
| 2116 | + | /// graph's own view, as a Git import builds against it: same-path |
|
| 2117 | + | /// additions are not unified ([`State::view`]), since the import's |
|
| 2118 | + | /// merge records Git's tree for them itself. |
|
| 2028 | 2119 | pub fn materialize_except(&self, skip: &HashSet<NodeId>) -> Snapshot { |
|
| 2029 | - | self.materialize_leaving(|_, e| skip.contains(&e.node)) |
|
| 2120 | + | self.materialize_leaving(self.tree.view(), |_, e| skip.contains(&e.node)) |
|
| 2030 | 2121 | } |
|
| 2031 | 2122 | ||
| 2032 | 2123 | /// As [`State::materialize_except`], rendering only the text files at |
|
| 2033 | - | /// `render` (a few, where the caller knows the rest unchanged). |
|
| 2124 | + | /// `render` (a few, where the caller knows the rest unchanged), with |
|
| 2125 | + | /// the tree as [`State::view`] has it. |
|
| 2034 | 2126 | pub fn materialize_only(&self, render: &std::collections::HashSet<&str>) -> Snapshot { |
|
| 2035 | - | self.materialize_leaving(|p, _| !render.contains(p)) |
|
| 2127 | + | self.materialize_leaving(self.view(), |p, _| !render.contains(p)) |
|
| 2036 | 2128 | } |
|
| 2037 | 2129 | ||
| 2038 | - | /// As [`State::materialize_except`], rendering only the text files of |
|
| 2130 | + | /// As [`State::materialize_only`], rendering only the text files of |
|
| 2039 | 2131 | /// the nodes in `render` (a change's edited files), without a set of |
|
| 2040 | 2132 | /// every other file to leave out. |
|
| 2041 | 2133 | pub fn materialize_nodes(&self, render: &HashSet<NodeId>) -> Snapshot { |
|
| 2042 | - | self.materialize_leaving(|_, e| !render.contains(&e.node)) |
|
| 2134 | + | self.materialize_leaving(self.view(), |_, e| !render.contains(&e.node)) |
|
| 2043 | 2135 | } |
|
| 2044 | 2136 | ||
| 2045 | 2137 | fn materialize_leaving( |
|
| 2046 | 2138 | &self, |
|
| 2139 | + | tree: TreeView, |
|
| 2047 | 2140 | leave: impl Fn(&str, &crate::tree::TreeEntry) -> bool + Sync, |
|
| 2048 | 2141 | ) -> Snapshot { |
|
| 2049 | 2142 | use rayon::prelude::*; |
|
| 2050 | - | let tree = self.tree.view(); |
|
| 2051 | 2143 | let entries: Vec<(&String, &crate::tree::TreeEntry)> = tree.entries.iter().collect(); |
|
| 2052 | 2144 | let rendered: Vec<(String, FileState, bool)> = entries |
|
| 2053 | 2145 | .par_iter() |
|
| … | 875 unchanged lines |
modified crates/oxy-core/src/tree.rs +176 −0
| … | 179 unchanged lines |
||
| 180 | 180 | /// cheap to clone. |
|
| 181 | 181 | pub entries: std::sync::Arc<BTreeMap<String, TreeEntry>>, |
|
| 182 | 182 | pub conflicts: Vec<TreeConflict>, |
|
| 183 | + | /// Nodes added at a place another node holds with identical content |
|
| 184 | + | /// (spec §9.2), each with the node the view shows there in its stead: |
|
| 185 | + | /// one file to every reader, and retired by the first change that |
|
| 186 | + | /// touches the node shown ([`TreeGraph::unify`]). |
|
| 187 | + | pub unified: BTreeMap<NodeId, NodeId>, |
|
| 183 | 188 | } |
|
| 184 | 189 | ||
| 185 | 190 | impl TreeView { |
|
| … | 577 unchanged lines |
||
| 763 | 768 | } |
|
| 764 | 769 | ||
| 765 | 770 | impl TreeGraph { |
|
| 771 | + | /// `view` (this graph's) with each name conflict between nodes added |
|
| 772 | + | /// with identical content made one entry (spec §9.2): the node whose |
|
| 773 | + | /// placement there has the least key is shown at the plain name, and |
|
| 774 | + | /// the others are listed in [`TreeView::unified`]. Only a group of |
|
| 775 | + | /// files, symlinks or subrepos of one kind and mode, none in another |
|
| 776 | + | /// conflict, is offered to `same`, one entry per node, which says |
|
| 777 | + | /// whether their contents are identical (`None`: it cannot tell yet). |
|
| 778 | + | /// The key order is the same for every peer, so all show one node. |
|
| 779 | + | /// Also returns whether `same` answered for every group. |
|
| 780 | + | pub fn unify( |
|
| 781 | + | &self, |
|
| 782 | + | mut view: TreeView, |
|
| 783 | + | same: impl Fn(&[&TreeEntry]) -> Option<bool>, |
|
| 784 | + | ) -> (TreeView, bool) { |
|
| 785 | + | let mut decided = true; |
|
| 786 | + | if !view |
|
| 787 | + | .conflicts |
|
| 788 | + | .iter() |
|
| 789 | + | .any(|c| matches!(c, TreeConflict::Name { .. })) |
|
| 790 | + | { |
|
| 791 | + | return (view, decided); |
|
| 792 | + | } |
|
| 793 | + | // Nodes in any other conflict stay as they are. |
|
| 794 | + | let mut troubled: HashSet<NodeId> = HashSet::new(); |
|
| 795 | + | for c in &view.conflicts { |
|
| 796 | + | match c { |
|
| 797 | + | TreeConflict::Name { .. } => {} |
|
| 798 | + | TreeConflict::MultiPlacement { node, .. } |
|
| 799 | + | | TreeConflict::Orphan { node, .. } |
|
| 800 | + | | TreeConflict::Cycle { node } |
|
| 801 | + | | TreeConflict::Mode { node, .. } => { |
|
| 802 | + | troubled.insert(*node); |
|
| 803 | + | } |
|
| 804 | + | } |
|
| 805 | + | } |
|
| 806 | + | let mut of_node: HashMap<NodeId, Vec<&TreeEntry>> = HashMap::new(); |
|
| 807 | + | for e in view.entries.values() { |
|
| 808 | + | of_node.entry(e.node).or_default().push(e); |
|
| 809 | + | } |
|
| 810 | + | // (paths to drop, entry to add, duplicates) per unified group. |
|
| 811 | + | let mut merged: Vec<(Vec<String>, TreeEntry, Vec<NodeId>)> = Vec::new(); |
|
| 812 | + | let mut gone: HashSet<usize> = HashSet::new(); |
|
| 813 | + | for (i, c) in view.conflicts.iter().enumerate() { |
|
| 814 | + | let TreeConflict::Name { |
|
| 815 | + | parent, |
|
| 816 | + | name, |
|
| 817 | + | nodes, |
|
| 818 | + | } = c |
|
| 819 | + | else { |
|
| 820 | + | continue; |
|
| 821 | + | }; |
|
| 822 | + | if nodes.iter().any(|n| troubled.contains(n)) { |
|
| 823 | + | continue; |
|
| 824 | + | } |
|
| 825 | + | // The group's entries: each node's placements at this place. |
|
| 826 | + | let mut group: Vec<&TreeEntry> = Vec::new(); |
|
| 827 | + | for n in nodes { |
|
| 828 | + | for e in of_node.get(n).into_iter().flatten() { |
|
| 829 | + | if self |
|
| 830 | + | .placement(&e.placement) |
|
| 831 | + | .is_some_and(|p| p.parent == *parent && p.name == *name) |
|
| 832 | + | { |
|
| 833 | + | group.push(e); |
|
| 834 | + | } |
|
| 835 | + | } |
|
| 836 | + | } |
|
| 837 | + | let Some(first) = group.first() else { |
|
| 838 | + | continue; |
|
| 839 | + | }; |
|
| 840 | + | if first.kind == NodeKind::Dir |
|
| 841 | + | || group |
|
| 842 | + | .iter() |
|
| 843 | + | .any(|e| e.kind != first.kind || e.mode != first.mode) |
|
| 844 | + | { |
|
| 845 | + | continue; |
|
| 846 | + | } |
|
| 847 | + | group.sort_by_key(|e| e.placement); |
|
| 848 | + | let mut reps: Vec<&TreeEntry> = Vec::new(); |
|
| 849 | + | for e in &group { |
|
| 850 | + | if !reps.iter().any(|r| r.node == e.node) { |
|
| 851 | + | reps.push(e); |
|
| 852 | + | } |
|
| 853 | + | } |
|
| 854 | + | if reps.len() < 2 { |
|
| 855 | + | continue; |
|
| 856 | + | } |
|
| 857 | + | match same(&reps) { |
|
| 858 | + | Some(true) => {} |
|
| 859 | + | Some(false) => continue, |
|
| 860 | + | None => { |
|
| 861 | + | decided = false; |
|
| 862 | + | continue; |
|
| 863 | + | } |
|
| 864 | + | } |
|
| 865 | + | let shown = reps[0]; |
|
| 866 | + | let path = match shown.path.rsplit_once('/') { |
|
| 867 | + | Some((dir, _)) => format!("{dir}/{name}"), |
|
| 868 | + | None => name.clone(), |
|
| 869 | + | }; |
|
| 870 | + | merged.push(( |
|
| 871 | + | group.iter().map(|e| e.path.clone()).collect(), |
|
| 872 | + | TreeEntry { |
|
| 873 | + | path, |
|
| 874 | + | ..shown.clone() |
|
| 875 | + | }, |
|
| 876 | + | reps[1..].iter().map(|e| e.node).collect(), |
|
| 877 | + | )); |
|
| 878 | + | gone.insert(i); |
|
| 879 | + | } |
|
| 880 | + | drop(of_node); |
|
| 881 | + | if merged.is_empty() { |
|
| 882 | + | return (view, decided); |
|
| 883 | + | } |
|
| 884 | + | let entries = std::sync::Arc::make_mut(&mut view.entries); |
|
| 885 | + | for (paths, shown, dups) in merged { |
|
| 886 | + | for p in paths { |
|
| 887 | + | entries.remove(&p); |
|
| 888 | + | } |
|
| 889 | + | for d in dups { |
|
| 890 | + | view.unified.insert(d, shown.node); |
|
| 891 | + | } |
|
| 892 | + | entries.insert(shown.path.clone(), shown); |
|
| 893 | + | } |
|
| 894 | + | let mut i = 0; |
|
| 895 | + | view.conflicts.retain(|_| { |
|
| 896 | + | i += 1; |
|
| 897 | + | !gone.contains(&(i - 1)) |
|
| 898 | + | }); |
|
| 899 | + | (view, decided) |
|
| 900 | + | } |
|
| 901 | + | } |
|
| 902 | + | ||
| 903 | + | impl TreeGraph { |
|
| 766 | 904 | /// Serialize for a checkpoint. |
|
| 767 | 905 | pub fn to_value(&self) -> crate::cbor::Value { |
|
| 768 | 906 | use crate::cbor::Value; |
|
| … | 368 unchanged lines |
||
| 1137 | 1275 | Ok(Some(TreeView { |
|
| 1138 | 1276 | entries: std::sync::Arc::new(entries.into_iter().collect()), |
|
| 1139 | 1277 | conflicts: Vec::new(), |
|
| 1278 | + | unified: BTreeMap::new(), |
|
| 1140 | 1279 | })) |
|
| 1141 | 1280 | } |
|
| 1142 | 1281 | } |
|
| … | 176 unchanged lines |
||
| 1319 | 1458 | nodes: vec![nid(1), nid(2)] |
|
| 1320 | 1459 | }] |
|
| 1321 | 1460 | ); |
|
| 1461 | + | } |
|
| 1462 | + | ||
| 1463 | + | /// Identical additions at one place are one entry, the node with the |
|
| 1464 | + | /// least placement key shown; anything else stays a name conflict. |
|
| 1465 | + | #[test] |
|
| 1466 | + | fn identical_additions_unify() { |
|
| 1467 | + | let mut g = TreeGraph::new(); |
|
| 1468 | + | add(&mut g, 1, 0, 1, NodeKind::Dir, NodeId::ROOT, "d"); |
|
| 1469 | + | add(&mut g, 3, 0, 3, NodeKind::File, nid(1), "a.txt"); |
|
| 1470 | + | add(&mut g, 2, 0, 2, NodeKind::File, nid(1), "a.txt"); |
|
| 1471 | + | let raw = g.view(); |
|
| 1472 | + | let (v, decided) = g.unify(raw.clone(), |es| { |
|
| 1473 | + | assert_eq!(es.len(), 2, "one entry per node"); |
|
| 1474 | + | Some(true) |
|
| 1475 | + | }); |
|
| 1476 | + | assert!(decided); |
|
| 1477 | + | assert_eq!(paths(&v), ["d", "d/a.txt"]); |
|
| 1478 | + | assert!(v.conflicts.is_empty()); |
|
| 1479 | + | assert_eq!(v.entries["d/a.txt"].node, nid(2), "the least placement key"); |
|
| 1480 | + | assert_eq!(v.entries["d/a.txt"].placement, (h(2), 0)); |
|
| 1481 | + | assert_eq!(v.unified, [(nid(3), nid(2))].into_iter().collect()); |
|
| 1482 | + | // Different contents, or not known yet: the conflict as it was. |
|
| 1483 | + | let (v, decided) = g.unify(raw.clone(), |_| Some(false)); |
|
| 1484 | + | assert!(decided); |
|
| 1485 | + | assert_eq!(v, raw); |
|
| 1486 | + | let (v, decided) = g.unify(raw.clone(), |_| None); |
|
| 1487 | + | assert!(!decided); |
|
| 1488 | + | assert_eq!(v, raw); |
|
| 1489 | + | // Directories, and kinds that differ, are never offered. |
|
| 1490 | + | let mut g = TreeGraph::new(); |
|
| 1491 | + | add(&mut g, 1, 0, 1, NodeKind::Dir, NodeId::ROOT, "d"); |
|
| 1492 | + | add(&mut g, 2, 0, 2, NodeKind::Dir, NodeId::ROOT, "d"); |
|
| 1493 | + | add(&mut g, 3, 0, 3, NodeKind::File, NodeId::ROOT, "f"); |
|
| 1494 | + | add(&mut g, 4, 0, 4, NodeKind::Symlink, NodeId::ROOT, "f"); |
|
| 1495 | + | let raw = g.view(); |
|
| 1496 | + | let (v, _) = g.unify(raw.clone(), |_| panic!("not offered")); |
|
| 1497 | + | assert_eq!(v, raw); |
|
| 1322 | 1498 | } |
|
| 1323 | 1499 | ||
| 1324 | 1500 | #[test] |
|
| … | 220 unchanged lines |
modified docs/spec.md +1 −1
| … | 355 unchanged lines |
||
| 356 | 356 | ||
| 357 | 357 | ### 9.2 Tree conflicts |
|
| 358 | 358 | ||
| 359 | - | - **Name conflict.** Two live nodes share a placement. |
|
| 359 | + | - **Name conflict.** Two live nodes share a placement. The same path added with identical content on several lines of history is not one, as Git unifies an addition both sides of a merge made: when every node at the placement is a file, symlink, or subrepo of one kind and one mode, none is in another tree conflict, and their contents are the same and unconflicted (one rendering for text files; one live blob for opaque files, symlink targets, and subrepo pins), the state shows a single entry, the node whose placement has the least `PlacementRef`, so every peer shows the same one. Nothing is resolved and no change is recorded. The first change that touches the node shown (an edit, `Move`, `RemoveNode`, or `SetMode`) also deletes the others' placements, or they would differ from it and the conflict would return; a working-copy snapshot adds those deletions with the touch and reports no change of its own for them. Directories are not unified. A Git import builds against the tree without this unification, so an import of a merge records Git's tree for such a path itself (§23). |
|
| 360 | 360 | - **Multi-placement.** One node has two live placements (concurrent renames). |
|
| 361 | 361 | - **Orphan.** A live node's parent directory is not live (concurrent delete of a directory and modification within it). |
|
| 362 | 362 | - **Cycle.** A directory is placed inside its own descendant, which arises when two commuting changes each move a directory under the other. Both placements are live and the tree is conflicted. For materialization the placement whose introducing change has the greater `(hlc, ChangeHash)` is treated as absent; resolution deletes one of the two placements. |
|
| … | 690 unchanged lines |