OXYGENHUB

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