OXYGENHUB

Identical conflict sides settle by themselves, keeping one, in any file (closes #146) c0d4075cadc0

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 3bdb831214ee
When
· 2026-10-26 17:47 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
c0d4075cadc08ad8f82059238de1df8c82df07c5431aa1d99335d80fb1357e65
Depends on
157d02e95586 20247a03a596 4afe288b7946 80b069facc69 8fd78eb52ad2 982864068a59 9bd98cfb4f7e ac4ec124b9f2 e4595038502a fce44ae9ad29 ff0af1ee299d
Depended on by
78b49d4d01f0
In bookmarks
identical-sides main subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.8.0 v0.9.0
Edits
24 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Identical sides settle by themselves (#146).** Two bookmarks that each resolved the same conflict to the same text conflicted again when merged, both sides the identical lines. A conflict whose sides are byte-identical now settles as `merge=union` adjacent insertions do (#145), in any file and keeping one side: a checkout writes it once ("kept one of its identical sides") and the next commit records the resolution, and a host landing a proposal settles it too.
6 +
5 7
- **A shallow clone fetches its checkpoint in parts (#137).** The checkpoint of a big state came as one answer, the whole state's graph blobs in one body (about 2 GB for the VS Code history), built and held whole on both sides. A server now answers `checkpoint-index` (the checkpoint and its history, no graphs), and the client fetches the graph blobs it lacks with `objects`, 2,048 at a time; `objects` reads them in pack order. An older server answers 404 and gets the one-piece request as before.
6 8
7 9
- **A keyless agent follows its owner's newest identity (#82).** Every delegation rotates its owner's identity, so the `author.key` an agent's home was given goes stale, and its changes named an identity two rotations old (verifying only because a grant from before a rotation still counts). With `author.for = ACCOUNT` in the home, the hub sends the account's newest linked identity with each certificate, with its chain back to the grant's key; oxy keeps it in the home (`keys/author.for`), stores the chain beside the change so the grant checks, and authors as it from then on. A repository's own `author.key` pin still wins.
…
283 unchanged lines
modified crates/oxy-core/src/repo/checkout.rs +1 −1
…
455 unchanged lines
456 456
        }
457 457
        self.write_manifest_entries(report.conflicted, false, &entries, &HashMap::new())?;
458 458
        if report.conflicted && !self.bare {
459 -
            report.kept_both = self.settle_unions(command)?;
459 +
            report.kept_both = self.settle_conflicts(command)?;
460 460
        }
461 461
        let _ = self.run_hook(
462 462
            "post-checkout",
…
188 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +80 −37
…
116 unchanged lines
117 117
    pub path: String,
118 118
    /// The conflicts settled by keeping every side, by number.
119 119
    pub settled: Vec<usize>,
120 +
    /// Of those, the ones whose sides were identical, settled by keeping
121 +
    /// one (oxygen#146).
122 +
    pub identical: Vec<usize>,
120 123
    /// The conflicts that keep their markers, by number, and why.
121 124
    pub left: Vec<(usize, &'static str)>,
122 125
}
…
6 unchanged lines
129 132
    sides: &[Option<Vec<Vec<u8>>>],
130 133
    reverse: bool,
131 134
) -> (Vec<u8>, Vec<usize>, Vec<(usize, &'static str)>) {
135 +
    let plans: Vec<Option<crate::state::Settle>> = sides
136 +
        .iter()
137 +
        .map(|s| {
138 +
            s.as_ref().map(|w| crate::state::Settle {
139 +
                sides: w.clone(),
140 +
                kept: w.clone(),
141 +
                identical: false,
142 +
            })
143 +
        })
144 +
        .collect();
145 +
    settle_text(text, &plans, reverse)
146 +
}
147 +
148 +
/// `text` (a working-copy file) with the marker block of each conflict
149 +
/// that `plans` (from [`State::settle_plans`]) settle replaced by its
150 +
/// kept text, the sides in order (`reverse`d on request); the conflicts
151 +
/// settled, and those left and why. A block whose text between the
152 +
/// markers is not its sides' keeps its markers.
153 +
pub(crate) fn settle_text(
154 +
    text: &[u8],
155 +
    plans: &[Option<crate::state::Settle>],
156 +
    reverse: bool,
157 +
) -> (Vec<u8>, Vec<usize>, Vec<(usize, &'static str)>) {
132 158
    // A side as the markers hold it: the renderer ends each with a newline.
133 159
    let with_newline = |s: &[u8]| {
134 160
        let mut s = s.to_vec();
…
29 unchanged lines
164 190
                found.last_mut().unwrap().extend_from_slice(line);
165 191
            }
166 192
        }
167 -
        let wanted = match sides.get(n.wrapping_sub(1)) {
168 -
            _ if total != sides.len() => {
193 +
        let wanted = match plans.get(n.wrapping_sub(1)) {
194 +
            _ if total != plans.len() => {
169 195
                Err("the markers are not the file's conflicts now; see `oxy status`")
170 196
            }
171 197
            Some(Some(w)) => Ok(w),
172 198
            _ => Err("both sides changed these lines; edit the markers"),
173 199
        };
174 200
        let keep = wanted.and_then(|w| {
175 -
            let mut a: Vec<Vec<u8>> = w.iter().map(|s| with_newline(s)).collect();
201 +
            let mut a: Vec<Vec<u8>> = w.sides.iter().map(|s| with_newline(s)).collect();
176 202
            let mut b = found.clone();
177 203
            a.sort();
178 204
            b.sort();
179 205
            if a == b {
180 -
                Ok(w)
206 +
                Ok(&w.kept)
181 207
            } else {
182 208
                Err("the lines between its markers were edited; finish it by hand")
183 209
            }
…
426 unchanged lines
610 636
            out.push(KeptBoth {
611 637
                path: p,
612 638
                settled,
639 +
                identical: Vec::new(),
613 640
                left,
614 641
            });
615 642
        }
616 643
        Ok(out)
617 644
    }
618 645
619 -
    /// Settle the adjacent insertions of each conflicted text file whose
620 -
    /// `.oxyattributes` say `merge=union` (oxygen#145) by keeping both
621 -
    /// sides, earliest change first, as [`Repo::keep_both`] does: a
622 -
    /// checkout calls it, and the next commit records the resolution. The
623 -
    /// files it settled something in.
624 -
    pub(crate) fn settle_unions(&self, command: &str) -> Result<Vec<KeptBoth>> {
646 +
    /// Settle what a checkout may settle by itself in each conflicted text
647 +
    /// file ([`State::settle_plans`]): conflicts whose sides are identical,
648 +
    /// keeping one (oxygen#146), and in files whose `.oxyattributes` say
649 +
    /// `merge=union`, adjacent insertions, keeping both sides earliest
650 +
    /// change first (oxygen#145). The next commit records the resolution.
651 +
    /// The files it settled something in.
652 +
    pub(crate) fn settle_conflicts(&self, command: &str) -> Result<Vec<KeptBoth>> {
625 653
        let snap = self.snapshot(command)?;
626 -
        let paths: Vec<String> = snap
627 -
            .base_snapshot
628 -
            .files
629 -
            .iter()
630 -
            .filter(|(p, f)| {
631 -
                matches!(&f.content, FileContent::Text { materialized, .. } if materialized.is_conflicted())
632 -
                    && snap.attributes.lookup(p).union
633 -
            })
634 -
            .map(|(p, _)| p.clone())
635 -
            .collect();
636 -
        if paths.is_empty() {
637 -
            return Ok(Vec::new());
654 +
        let mut out = Vec::new();
655 +
        for (p, f) in &snap.base_snapshot.files {
656 +
            if !matches!(&f.content, FileContent::Text { materialized, .. } if materialized.is_conflicted())
657 +
            {
658 +
                continue;
659 +
            }
660 +
            let plans = snap
661 +
                .base
662 +
                .settle_plans(&f.node, snap.attributes.lookup(p).union);
663 +
            if plans.iter().all(Option::is_none) {
664 +
                continue;
665 +
            }
666 +
            let disk = self.root().join(p);
667 +
            let Ok(text) = fs::read(&disk) else { continue };
668 +
            let (bytes, settled, left) = settle_text(&text, &plans, false);
669 +
            if settled.is_empty() {
670 +
                continue;
671 +
            }
672 +
            fs::write(&disk, bytes)?;
673 +
            let identical = settled
674 +
                .iter()
675 +
                .copied()
676 +
                .filter(|n| plans[n - 1].as_ref().is_some_and(|w| w.identical))
677 +
                .collect();
678 +
            out.push(KeptBoth {
679 +
                path: p.clone(),
680 +
                settled,
681 +
                identical,
682 +
                left,
683 +
            });
638 684
        }
639 -
        let kept = self.keep_both(&snap, &paths, false)?;
640 -
        Ok(kept.into_iter().filter(|k| !k.settled.is_empty()).collect())
685 +
        Ok(out)
641 686
    }
642 687
643 -
    /// Every conflict of `snap`, `state`'s materialization, settled by
644 -
    /// keeping both sides (oxygen#145): the new text of each conflicted
645 -
    /// file, when each is a text file its `.oxyattributes` mark
646 -
    /// `merge=union` and each of its conflicts is an adjacent insertion;
647 -
    /// `None` when any conflict needs a person, or there is none. A host
648 -
    /// lands a proposal with these as a resolution, as a checkout settles
649 -
    /// them in a working copy.
688 +
    /// Every conflict of `snap`, `state`'s materialization, settled as a
689 +
    /// checkout settles them ([`State::settle_plans`]): the new text of
690 +
    /// each conflicted file, when each is a text file whose every conflict
691 +
    /// has identical sides (one kept, oxygen#146) or, in a file its
692 +
    /// `.oxyattributes` mark `merge=union`, is an adjacent insertion (both
693 +
    /// kept, oxygen#145); `None` when any conflict needs a person, or there
694 +
    /// is none. A host lands a proposal with these as a resolution, as a
695 +
    /// checkout settles them in a working copy.
650 696
    pub fn union_resolution(state: &State, snap: &Snapshot) -> Option<Vec<(String, Vec<u8>)>> {
651 697
        if snap.tree.is_conflicted() {
652 698
            return None;
…
7 unchanged lines
660 706
            let FileContent::Text { bytes, .. } = &f.content else {
661 707
                return None;
662 708
            };
663 -
            if !attrs.lookup(p).union {
664 -
                return None;
665 -
            }
666 -
            let sides = state.both_sides(&f.node);
667 -
            let (text, _, left) = keep_both_text(bytes, &sides, false);
668 -
            if !left.is_empty() || sides.iter().any(Option::is_none) {
709 +
            let plans = state.settle_plans(&f.node, attrs.lookup(p).union);
710 +
            let (text, _, left) = settle_text(bytes, &plans, false);
711 +
            if !left.is_empty() || plans.iter().any(Option::is_none) {
669 712
                return None;
670 713
            }
671 714
            out.push((p.clone(), text));
…
2775 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +53 −0
…
264 unchanged lines
265 265
            vec![KeptBoth {
266 266
                path: "f.txt".into(),
267 267
                settled: vec![1],
268 +
                identical: vec![],
268 269
                left: vec![]
269 270
            }]
270 271
        );
…
64 unchanged lines
335 336
    );
336 337
}
337 338
339 +
/// oxygen#146: two bookmarks that resolved the same conflict to the same
340 +
/// text agree. Merged, their resolutions are a conflict whose sides are
341 +
/// identical: a checkout keeps one, in any file, and a host settles it so
342 +
/// too; the next commit records it.
343 +
#[test]
344 +
fn identical_resolutions_on_two_bookmarks_settle_by_themselves() {
345 +
    hermetic();
346 +
    let dir = tempfile::tempdir().unwrap();
347 +
    let root = dir.path();
348 +
    let repo = Repo::init(root).unwrap();
349 +
    write(root, "f.txt", b"a\nx\nb\n");
350 +
    let base = repo.commit("base", &[]).unwrap();
351 +
    write(root, "f.txt", b"a\nX1\nb\n");
352 +
    let c1 = repo.commit("X1", &[]).unwrap();
353 +
    repo.checkout(&[base], false, "checkout").unwrap();
354 +
    write(root, "f.txt", b"a\nX2\nb\n");
355 +
    let c2 = repo.commit("X2", &[]).unwrap();
356 +
    // Each bookmark merges both and resolves the conflict the same way.
357 +
    repo.checkout(&[c1, c2], false, "checkout").unwrap();
358 +
    assert!(has_conflict_markers(&fs::read(root.join("f.txt")).unwrap()));
359 +
    write(root, "f.txt", b"a\nX\nb\n");
360 +
    let r1 = repo.commit("resolve on one", &[]).unwrap();
361 +
    repo.checkout(&[c1, c2], false, "checkout").unwrap();
362 +
    write(root, "f.txt", b"a\nX\nb\n");
363 +
    let r2 = repo.commit("resolve on two", &[]).unwrap();
364 +
    assert_ne!(r1, r2);
365 +
366 +
    let st = repo.load_state(&[r1, r2]).unwrap();
367 +
    assert!(
368 +
        st.materialize().is_conflicted(),
369 +
        "two resolutions, one place"
370 +
    );
371 +
    assert_eq!(
372 +
        Repo::union_resolution(&st, &st.materialize()),
373 +
        Some(vec![("f.txt".into(), b"a\nX\nb\n".to_vec())]),
374 +
        "a host settles it"
375 +
    );
376 +
    let r = repo.checkout(&[r1, r2], false, "checkout").unwrap();
377 +
    assert!(r.conflicted);
378 +
    assert_eq!(r.kept_both.len(), 1, "{:?}", r.kept_both);
379 +
    assert_eq!(
380 +
        (r.kept_both[0].path.as_str(), &r.kept_both[0].identical),
381 +
        ("f.txt", &vec![1])
382 +
    );
383 +
    assert_eq!(fs::read_to_string(root.join("f.txt")).unwrap(), "a\nX\nb\n");
384 +
    let s = repo.snapshot("status").unwrap();
385 +
    assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::Resolved)]);
386 +
    let merged = repo.commit("merge", &[]).unwrap();
387 +
    let st = repo.load_state(&[merged]).unwrap();
388 +
    assert!(!st.materialize().is_conflicted());
389 +
}
390 +
338 391
/// oxygen#145: a host (OxygenHub landing a proposal) settles a state whose
339 392
/// only conflicts are adjacent insertions in union files; any other
340 393
/// conflict needs a person.
…
10329 unchanged lines
modified crates/oxy-core/src/state.rs +55 −0
…
725 unchanged lines
726 726
    }
727 727
}
728 728
729 +
/// How one conflict settles without a person ([`State::settle_plans`]).
730 +
#[derive(Clone, Debug, PartialEq, Eq)]
731 +
pub struct Settle {
732 +
    /// Its sides' text, as its markers show them.
733 +
    pub sides: Vec<Vec<u8>>,
734 +
    /// The text that takes the markers' place, side by side, in order.
735 +
    pub kept: Vec<Vec<u8>>,
736 +
    /// Its sides are identical, and one is kept.
737 +
    pub identical: bool,
738 +
}
739 +
729 740
/// See `State::conflict_regions`.
730 741
#[derive(Clone, Debug, Default, PartialEq, Eq)]
731 742
pub struct ConflictRegions {
…
1317 unchanged lines
2049 2060
                            })
2050 2061
                            .collect(),
2051 2062
                    ));
2063 +
                }
2064 +
                _ => out.push(None),
2065 +
            }
2066 +
        }
2067 +
        out
2068 +
    }
2069 +
2070 +
    /// How each conflict of a text node settles without a person, in
2071 +
    /// marker order (spec §9.5); `None` for one that needs a person. A
2072 +
    /// conflict whose sides are identical keeps one of them, in any file
2073 +
    /// (oxygen#146): two bookmarks that resolved the same conflict the same
2074 +
    /// way agree. With `union` (the file is `merge=union`, oxygen#145), an
2075 +
    /// adjacent insertion keeps every side, earliest change first.
2076 +
    pub fn settle_plans(&self, node: &NodeId, union: bool) -> Vec<Option<Settle>> {
2077 +
        let Some(g) = self.graph(node) else {
2078 +
            return Vec::new();
2079 +
        };
2080 +
        let m = self.materialize_ordered(g);
2081 +
        let text = |s: &[AtomKey]| -> Vec<u8> {
2082 +
            s.iter()
2083 +
                .flat_map(|a| g.content(a).unwrap_or_default().to_vec())
2084 +
                .collect()
2085 +
        };
2086 +
        let mut out = Vec::new();
2087 +
        for (i, seg) in m.segments.iter().enumerate() {
2088 +
            match seg {
2089 +
                crate::graph::Segment::Lines(_) => {}
2090 +
                crate::graph::Segment::Conflict(sides) => {
2091 +
                    let texts: Vec<Vec<u8>> = sides.iter().map(|s| text(s)).collect();
2092 +
                    out.push(if texts.len() > 1 && texts.iter().all(|t| *t == texts[0]) {
2093 +
                        Some(Settle {
2094 +
                            kept: vec![texts[0].clone()],
2095 +
                            sides: texts,
2096 +
                            identical: true,
2097 +
                        })
2098 +
                    } else if union && g.adjacent_insertion(&m, i, self) {
2099 +
                        Some(Settle {
2100 +
                            kept: texts.clone(),
2101 +
                            sides: texts,
2102 +
                            identical: false,
2103 +
                        })
2104 +
                    } else {
2105 +
                        None
2106 +
                    });
2052 2107
                }
2053 2108
                _ => out.push(None),
2054 2109
            }
…
957 unchanged lines
modified docs/spec.md +1 −1
…
389 unchanged lines
390 390
391 391
`oxy status` MUST list every conflict, MUST exit with status 2 when any conflict exists in the working-copy state, MUST exit with status 3 when only workspace conflicts (§9.7) exist, and MUST support `--json` output enumerating conflicts by node, kind, and contributing ChangeIds. The JSON object has `bookmark`, `working_copy` (ChangeId), `heads`, `clean`, `conflicted`, `paths` (`[{path, status}]`) and `conflicts`: for a text or replace conflict `{kind, path, node, changes: [ChangeId], resolved: true | false | "markers"}`, for a tree conflict `{kind: name | multi-placement | orphan | cycle | mode, nodes, path?}`.
392 392
393 -
An **adjacent insertion** is an order conflict whose sides only add lines between two lines that were next to each other: no line between them was deleted by a change that not every side builds on (two changes that each replaced the same line conflict the same way, and are not one). Keeping every side settles it without losing or repeating a line, though either order can be wrong for code, so it is never done silently: `oxy resolve --both [PATH]..` replaces the markers of each adjacent insertion in the working copy with its sides, earliest first (`--reverse`: latest first), and leaves the markers of any other conflict, or of one whose lines between the markers were edited. `oxy status` says which conflicts qualify. A file whose `.oxyattributes` say `merge=union` (§19; changelogs, lists where order does not matter) needs no command: a checkout settles its adjacent insertions the same way at once, and the next commit records the resolution. The state is still conflicted until then; nothing about what a conflict is changes.
393 +
An **adjacent insertion** is an order conflict whose sides only add lines between two lines that were next to each other: no line between them was deleted by a change that not every side builds on (two changes that each replaced the same line conflict the same way, and are not one). Keeping every side settles it without losing or repeating a line, though either order can be wrong for code, so it is never done silently: `oxy resolve --both [PATH]..` replaces the markers of each adjacent insertion in the working copy with its sides, earliest first (`--reverse`: latest first), and leaves the markers of any other conflict, or of one whose lines between the markers were edited. `oxy status` says which conflicts qualify. A file whose `.oxyattributes` say `merge=union` (§19; changelogs, lists where order does not matter) needs no command: a checkout settles its adjacent insertions the same way at once, and the next commit records the resolution. The state is still conflicted until then; nothing about what a conflict is changes. A conflict whose sides are **identical**, byte for byte, settles the same way in any file, with one side kept: two bookmarks that resolved the same conflict to the same text, then merged, agree (oxygen#146; Git merges identical changes cleanly too). A host landing a proposal settles both kinds, and only them, as a checkout does.
394 394
395 395
### 9.6 Resolution
396 396
…
796 unchanged lines
modified src/cli/workspace.rs +8 −1
…
1458 unchanged lines
1459 1459
/// Union files (`merge=union`, oxygen#145) a checkout settled itself.
1460 1460
fn print_kept_both(r: &oxy_core::repo::CheckoutReport) {
1461 1461
    for k in &r.kept_both {
1462 +
        let how = if k.identical.len() == k.settled.len() {
1463 +
            "kept one of its identical sides"
1464 +
        } else if k.identical.is_empty() {
1465 +
            "kept both sides (merge=union)"
1466 +
        } else {
1467 +
            "kept one of identical sides and both of others (merge=union)"
1468 +
        };
1462 1469
        println!(
1463 -
            "  {} kept both sides in {} (merge=union); the next commit records it",
1470 +
            "  {} settled {}: {how}; the next commit records it",
1464 1471
            ui::note(),
1465 1472
            ui::path(&k.path)
1466 1473
        );
…
1623 unchanged lines