OXYGENHUB

oxy resolve --both keeps both sides of an adjacent insertion (closes #121) 9bd98cfb4f7e

Two changes each adding lines after the same line conflict, as in Git.
oxy resolve --both [PATH].. now replaces the markers of each such
conflict with every side, the earlier change's first (--reverse: the
later's), and oxy status says which conflicts qualify. Where a line
between the neighbours was deleted by a change not every side builds on
(two changes replacing the same line), or the lines between the markers
were edited, the markers stay. Conflict sides are now written earliest
change first, by clock, not by hash (spec 9.5).
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 58c0b79164d6
When
· 2026-10-26 09:55 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
9bd98cfb4f7e2fa3b50335027f9cc9db2ba1a0c243308b1c479dd617b19a2063
Depends on
135212de5370 157d02e95586 1c2bd9abdd4f 20247a03a596 3652104ae7d3 75167672ebcd 87322662f607 8fd78eb52ad2 982864068a59 c6aa5bd9d1c8 d42369abf180 e02edccce98c e4595038502a ee06a6c557b7 fce44ae9ad29 ff0af1ee299d
Depended on by
c0d4075cadc0 ac4ec124b9f2 4afe288b7946
In bookmarks
identical-sides main push-notes subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.8.0 v0.9.0
Edits
26 atoms, 0 tree operations, 10 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Two changes adding lines at the same place are settled with one command (#121).** Like Git, oxy leaves a conflict when two changes each add lines after the same line, since either order can be wrong for code; settling it meant editing out the markers by hand. `oxy resolve --both [PATH]…` now keeps every side of each such *adjacent insertion*, the earlier change's first (`--reverse` for the other order), and `oxy status` says which conflicts qualify. It leaves the markers where the sides changed the same lines (keeping both would keep two versions of them) or where the lines between the markers were edited. Conflict markers now list their sides earliest change first, not in hash order.
6 +
5 7
## 0.7.0 (2026-10-26)
6 8
7 9
- **Many objects are read in pack order (#137):** `Store::get_many` (a clone's checkpoint, a fetch's objects) reads packed objects by pack and offset, each thread a run of them, so a spinning disk sweeps instead of seeking across the packs; results keep the asked order. The server's gathering of a VS Code-sized checkpoint (tens of thousands of objects in ~95 packs) was the slow part of a depth-0 clone on Earth.
…
273 unchanged lines
modified crates/oxy-core/src/graph.rs +53 −0
…
392 unchanged lines
393 393
            .flat_map(|a| a.deleted_by.iter())
394 394
    }
395 395
396 +
    /// Whether the conflict at `m.segments[i]` is an adjacent insertion
397 +
    /// (oxygen#121): its sides only add lines between two lines that were
398 +
    /// next to each other, so keeping every side repeats nothing. It is not
399 +
    /// when a line there was deleted by a change only some sides build on:
400 +
    /// two changes that each replaced the same line conflict the same way,
401 +
    /// and keeping both would keep two versions of it. A line deleted in
402 +
    /// history every side shares does not count.
403 +
    pub fn adjacent_insertion(&self, m: &Materialized, i: usize, deps: &dyn DepOracle) -> bool {
404 +
        let Some(Segment::Conflict(sides)) = m.segments.get(i) else {
405 +
            return false;
406 +
        };
407 +
        let before = match i.checked_sub(1).map(|j| &m.segments[j]) {
408 +
            None => Vertex::Start,
409 +
            Some(Segment::Lines(a)) => match a.last() {
410 +
                Some(k) => Vertex::Atom(*k),
411 +
                None => return false,
412 +
            },
413 +
            Some(_) => return false,
414 +
        };
415 +
        let on_side: HashSet<AtomKey> = sides.iter().flatten().copied().collect();
416 +
        let side_changes: Vec<BTreeSet<ChangeHash>> = sides
417 +
            .iter()
418 +
            .map(|s| s.iter().map(|a| a.0).collect())
419 +
            .collect();
420 +
        let shared = |d: &ChangeHash| {
421 +
            side_changes
422 +
                .iter()
423 +
                .all(|s| s.iter().any(|c| deps.depends_on(c, d)))
424 +
        };
425 +
        // From the line before, through the sides' lines and deleted ones,
426 +
        // up to the next live line.
427 +
        let mut seen: HashSet<AtomKey> = HashSet::default();
428 +
        let mut todo = vec![before];
429 +
        while let Some(v) = todo.pop() {
430 +
            for &w in self.succ.get(&v).map(Edges::as_slice).unwrap_or(&[]) {
431 +
                let Vertex::Atom(k) = w else { continue };
432 +
                if !seen.insert(k) {
433 +
                    continue;
434 +
                }
435 +
                if !on_side.contains(&k) {
436 +
                    if self.is_live(&k) {
437 +
                        continue;
438 +
                    }
439 +
                    if self.deleters(&k).any(|d| !shared(d)) {
440 +
                        return false;
441 +
                    }
442 +
                }
443 +
                todo.push(w);
444 +
            }
445 +
        }
446 +
        true
447 +
    }
448 +
396 449
    /// Number of atoms introduced by `change` in this graph (for index bookkeeping).
397 450
    pub fn atom_count(&self) -> usize {
398 451
        self.atoms.len()
…
1437 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +2 −2
…
152 unchanged lines
153 153
pub use revset::{Revset, REVSET_BUDGET};
154 154
pub use snapshot::{
155 155
    added_node_paths, diff_files_of, has_conflict_markers, is_binary, referenced_changes,
156 -
    render_change, render_hunks, similarity, ChangeStatus, DiffHunk, FileDiff, Hunk, PathStatus,
157 -
    Selection, SnapshotResult,
156 +
    render_change, render_hunks, similarity, ChangeStatus, DiffHunk, FileDiff, Hunk, KeptBoth,
157 +
    PathStatus, Selection, SnapshotResult,
158 158
};
159 159
pub use subrepos::{SubrepoInfo, SubrepoSync};
160 160
pub use sync::{FetchReport, PullReport, PushReport, RefUpdated, OBJECTS_RESULT};
…
2631 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +163 −1
…
110 unchanged lines
111 111
    matches!(base.files.get(path).map(|f| &f.content), Some(FileContent::Opaque { blobs }) if blobs.len() > 1)
112 112
}
113 113
114 +
/// What `oxy resolve --both` did to one file (oxygen#121).
115 +
#[derive(Debug, Default, Clone, PartialEq, Eq)]
116 +
pub struct KeptBoth {
117 +
    pub path: String,
118 +
    /// The conflicts settled by keeping every side, by number.
119 +
    pub settled: Vec<usize>,
120 +
    /// The conflicts that keep their markers, by number, and why.
121 +
    pub left: Vec<(usize, &'static str)>,
122 +
}
123 +
124 +
/// `text` (a working-copy file) with the marker block of each conflict that
125 +
/// `sides` (from [`State::both_sides`]) says keeping both settles replaced
126 +
/// by its sides' lines; the conflicts settled, and those left and why.
127 +
pub(crate) fn keep_both_text(
128 +
    text: &[u8],
129 +
    sides: &[Option<Vec<Vec<u8>>>],
130 +
    reverse: bool,
131 +
) -> (Vec<u8>, Vec<usize>, Vec<(usize, &'static str)>) {
132 +
    // A side as the markers hold it: the renderer ends each with a newline.
133 +
    let with_newline = |s: &[u8]| {
134 +
        let mut s = s.to_vec();
135 +
        if !s.is_empty() && !s.ends_with(b"\n") {
136 +
            s.push(b'\n');
137 +
        }
138 +
        s
139 +
    };
140 +
    let lines: Vec<&[u8]> = text.split_inclusive(|&b| b == b'\n').collect();
141 +
    let (mut out, mut settled, mut left) = (Vec::new(), Vec::new(), Vec::new());
142 +
    let mut i = 0;
143 +
    while i < lines.len() {
144 +
        let Some((n, total)) = conflict_header(lines[i]) else {
145 +
            out.extend_from_slice(lines[i]);
146 +
            i += 1;
147 +
            continue;
148 +
        };
149 +
        let end = format!(">>>>>>> oxy conflict {n} of {total} end");
150 +
        let next_side = format!("======= oxy conflict {n} of {total}, side ");
151 +
        let Some(stop) =
152 +
            (i + 1..lines.len()).find(|&j| lines[j].trim_ascii_end() == end.as_bytes())
153 +
        else {
154 +
            left.push((n, "its end marker is missing; edit it by hand"));
155 +
            out.extend_from_slice(lines[i]);
156 +
            i += 1;
157 +
            continue;
158 +
        };
159 +
        let mut found: Vec<Vec<u8>> = vec![Vec::new()];
160 +
        for line in &lines[i + 1..stop] {
161 +
            if line.starts_with(next_side.as_bytes()) {
162 +
                found.push(Vec::new());
163 +
            } else {
164 +
                found.last_mut().unwrap().extend_from_slice(line);
165 +
            }
166 +
        }
167 +
        let wanted = match sides.get(n.wrapping_sub(1)) {
168 +
            _ if total != sides.len() => {
169 +
                Err("the markers are not the file's conflicts now; see `oxy status`")
170 +
            }
171 +
            Some(Some(w)) => Ok(w),
172 +
            _ => Err("both sides changed these lines; edit the markers"),
173 +
        };
174 +
        let keep = wanted.and_then(|w| {
175 +
            let mut a: Vec<Vec<u8>> = w.iter().map(|s| with_newline(s)).collect();
176 +
            let mut b = found.clone();
177 +
            a.sort();
178 +
            b.sort();
179 +
            if a == b {
180 +
                Ok(w)
181 +
            } else {
182 +
                Err("the lines between its markers were edited; finish it by hand")
183 +
            }
184 +
        });
185 +
        match keep {
186 +
            Ok(w) => {
187 +
                let order: Vec<&Vec<u8>> = if reverse {
188 +
                    w.iter().rev().collect()
189 +
                } else {
190 +
                    w.iter().collect()
191 +
                };
192 +
                for s in order {
193 +
                    out.extend_from_slice(&with_newline(s));
194 +
                }
195 +
                settled.push(n);
196 +
            }
197 +
            Err(why) => {
198 +
                left.push((n, why));
199 +
                for line in &lines[i..=stop] {
200 +
                    out.extend_from_slice(line);
201 +
                }
202 +
            }
203 +
        }
204 +
        i = stop + 1;
205 +
    }
206 +
    (out, settled, left)
207 +
}
208 +
209 +
/// `(n, total)` from a conflict's opening marker (spec §9.5).
210 +
fn conflict_header(line: &[u8]) -> Option<(usize, usize)> {
211 +
    let rest = line.strip_prefix(b"<<<<<<< oxy conflict ")?;
212 +
    let rest = std::str::from_utf8(rest).ok()?;
213 +
    let (n, rest) = rest.split_once(" of ")?;
214 +
    let total = rest.split(|c: char| !c.is_ascii_digit()).next()?;
215 +
    Some((n.parse().ok()?, total.parse().ok()?))
216 +
}
217 +
114 218
pub fn is_binary(content: &[u8]) -> bool {
115 219
    content[..content.len().min(BINARY_SNIFF)].contains(&0)
116 220
}
…
326 unchanged lines
443 547
        for p in paths {
444 548
            if !has_replace_conflict(base, p) {
445 549
                return Err(RepoError::Msg(format!(
446 -
                    "{p} has no replace conflict to settle (a text file's conflict is settled by editing out its markers)"
550 +
                    "{p} has no replace conflict to settle (a text file's conflict is settled by editing out its markers, or `oxy resolve --both {p}`)"
447 551
                )));
448 552
            }
449 553
        }
…
2 unchanged lines
452 556
        let text: String = all.iter().map(|p| format!("{p}\n")).collect();
453 557
        crate::durable::write_atomic(&self.resolutions_path(), text.as_bytes())?;
454 558
        Ok(())
559 +
    }
560 +
561 +
    /// Settle the adjacent-insertion conflicts of the text files at `paths`
562 +
    /// (oxygen#121) by keeping every side, earliest change first (latest
563 +
    /// first with `reverse`): each one's markers in the working copy give
564 +
    /// way to its sides' lines, and the next snapshot records the result.
565 +
    /// A conflict whose sides changed the same lines, or whose text between
566 +
    /// the markers was edited, keeps its markers. With no paths, every
567 +
    /// conflicted text file.
568 +
    pub fn keep_both(
569 +
        &self,
570 +
        snap: &SnapshotResult,
571 +
        paths: &[String],
572 +
        reverse: bool,
573 +
    ) -> Result<Vec<KeptBoth>> {
574 +
        let paths: Vec<String> = if paths.is_empty() {
575 +
            snap.base_snapshot
576 +
                .files
577 +
                .iter()
578 +
                .filter(|(_, f)| matches!(&f.content, FileContent::Text { materialized, .. } if materialized.is_conflicted()))
579 +
                .map(|(p, _)| p.clone())
580 +
                .collect()
581 +
        } else {
582 +
            paths.to_vec()
583 +
        };
584 +
        let mut out = Vec::new();
585 +
        for p in paths {
586 +
            let conflicted = match snap.base_snapshot.files.get(&p).map(|f| (f, &f.content)) {
587 +
                Some((f, FileContent::Text { materialized, .. }))
588 +
                    if materialized.is_conflicted() =>
589 +
                {
590 +
                    f
591 +
                }
592 +
                Some((_, FileContent::Opaque { blobs })) if blobs.len() > 1 => {
593 +
                    return Err(RepoError::Msg(format!(
594 +
                        "{p} has a replace conflict: keep one version with `oxy resolve {p}`"
595 +
                    )))
596 +
                }
597 +
                _ => {
598 +
                    return Err(RepoError::Msg(format!(
599 +
                        "{p} has no text conflict to settle"
600 +
                    )))
601 +
                }
602 +
            };
603 +
            let sides = snap.base.both_sides(&conflicted.node);
604 +
            let disk = self.root().join(&p);
605 +
            let text = fs::read(&disk)?;
606 +
            let (bytes, settled, left) = keep_both_text(&text, &sides, reverse);
607 +
            if !settled.is_empty() {
608 +
                fs::write(&disk, bytes)?;
609 +
            }
610 +
            out.push(KeptBoth {
611 +
                path: p,
612 +
                settled,
613 +
                left,
614 +
            });
615 +
        }
616 +
        Ok(out)
455 617
    }
456 618
457 619
    /// The chosen resolutions `base` still needs; the rest are forgotten.
…
2770 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +89 −0
…
229 unchanged lines
230 230
    assert!(!repo.load_state(&[h]).unwrap().materialize().is_conflicted());
231 231
}
232 232
233 +
/// oxygen#121: two changes each adding a line after the same line conflict;
234 +
/// the markers list the earlier change first, and `oxy resolve --both`
235 +
/// keeps both lines in that order (or the other with `--reverse`).
233 236
#[test]
237 +
fn adjacent_insertions_are_settled_by_keeping_both() {
238 +
    hermetic();
239 +
    for reverse in [false, true] {
240 +
        let dir = tempfile::tempdir().unwrap();
241 +
        let root = dir.path();
242 +
        let repo = Repo::init(root).unwrap();
243 +
        write(root, "f.txt", b"a\nb\n");
244 +
        let h1 = repo.commit("base", &[]).unwrap();
245 +
        write(root, "f.txt", b"a\nfirst\nb\n");
246 +
        repo.commit("first", &[]).unwrap();
247 +
        repo.checkout(&[h1], false, "checkout").unwrap();
248 +
        write(root, "f.txt", b"a\nsecond\nb\n");
249 +
        repo.commit("second", &[]).unwrap();
250 +
        repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main")
251 +
            .unwrap();
252 +
        let marked = fs::read_to_string(root.join("f.txt")).unwrap();
253 +
        let (f, s) = (
254 +
            marked.find("first\n").unwrap(),
255 +
            marked.find("second\n").unwrap(),
256 +
        );
257 +
        assert!(f < s, "the earlier change's side comes first: {marked}");
258 +
259 +
        let snap = repo.snapshot("status").unwrap();
260 +
        let node = snap.base_snapshot.files["f.txt"].node;
261 +
        assert_eq!(snap.base.both_sides(&node).iter().flatten().count(), 1);
262 +
        let kept = repo.keep_both(&snap, &[], reverse).unwrap();
263 +
        assert_eq!(
264 +
            kept,
265 +
            vec![KeptBoth {
266 +
                path: "f.txt".into(),
267 +
                settled: vec![1],
268 +
                left: vec![]
269 +
            }]
270 +
        );
271 +
        let want: &[u8] = if reverse {
272 +
            b"a\nsecond\nfirst\nb\n"
273 +
        } else {
274 +
            b"a\nfirst\nsecond\nb\n"
275 +
        };
276 +
        assert_eq!(fs::read(root.join("f.txt")).unwrap(), want);
277 +
        let s = repo.snapshot("status").unwrap();
278 +
        assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::Resolved)]);
279 +
        let h = repo.commit("both", &[]).unwrap();
280 +
        assert_eq!(
281 +
            repo.store().get_change(&h).unwrap().kind,
282 +
            ChangeKind::Resolution
283 +
        );
284 +
        assert!(!repo.load_state(&[h]).unwrap().materialize().is_conflicted());
285 +
    }
286 +
}
287 +
288 +
/// oxygen#121: two changes that each replaced the same line are not an
289 +
/// adjacent insertion: keeping both would keep two versions of the line,
290 +
/// so `--both` leaves the markers, as it does for markers already edited.
291 +
#[test]
292 +
fn keeping_both_refuses_replaced_or_edited_lines() {
293 +
    hermetic();
294 +
    let (dir, repo, _, _, _) = conflicted_repo();
295 +
    let root = dir.path();
296 +
    repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main")
297 +
        .unwrap();
298 +
    let marked = fs::read(root.join("f.txt")).unwrap();
299 +
    let snap = repo.snapshot("status").unwrap();
300 +
    let node = snap.base_snapshot.files["f.txt"].node;
301 +
    assert_eq!(snap.base.both_sides(&node), vec![None]);
302 +
    let kept = repo.keep_both(&snap, &["f.txt".into()], false).unwrap();
303 +
    assert!(kept[0].settled.is_empty());
304 +
    assert_eq!(kept[0].left[0].0, 1);
305 +
    assert!(kept[0].left[0].1.contains("both sides changed"), "{kept:?}");
306 +
    assert_eq!(fs::read(root.join("f.txt")).unwrap(), marked);
307 +
308 +
    // An adjacent insertion whose lines were edited between the markers.
309 +
    let sides = vec![Some(vec![b"x\n".to_vec(), b"y\n".to_vec()])];
310 +
    let text = b"a\n<<<<<<< oxy conflict 1 of 1, side 1 of 2: change k \"x\"\nx!\n======= oxy conflict 1 of 1, side 2 of 2: change q \"y\"\ny\n>>>>>>> oxy conflict 1 of 1 end\nb\n";
311 +
    let (out, settled, left) = super::snapshot::keep_both_text(text, &sides, false);
312 +
    assert_eq!(out, text.to_vec());
313 +
    assert!(settled.is_empty());
314 +
    assert!(left[0].1.contains("edited"), "{left:?}");
315 +
    // Unedited, in either order on disk: kept in the graph's order.
316 +
    let text = b"a\n<<<<<<< oxy conflict 1 of 1, side 1 of 2: change q \"y\"\ny\n======= oxy conflict 1 of 1, side 2 of 2: change k \"x\"\nx\n>>>>>>> oxy conflict 1 of 1 end\nb\n";
317 +
    let (out, settled, _) = super::snapshot::keep_both_text(text, &sides, false);
318 +
    assert_eq!(out, b"a\nx\ny\nb\n".to_vec());
319 +
    assert_eq!(settled, vec![1]);
320 +
}
321 +
322 +
#[test]
234 323
fn concurrent_edits_conflict_and_can_be_resolved() {
235 324
    hermetic();
236 325
    let (dir, repo, h1, h2, h3) = conflicted_repo();
…
10169 unchanged lines
modified crates/oxy-core/src/state.rs +50 −1
…
2006 unchanged lines
2007 2007
        out
2008 2008
    }
2009 2009
2010 +
    /// A file graph materialized with each conflict's sides earliest change
2011 +
    /// first (oxygen#121), by the clock of the oldest change on a side, so
2012 +
    /// markers read in the order the work was done and `oxy resolve --both`
2013 +
    /// keeps that order.
2014 +
    fn materialize_ordered(&self, g: &FileGraph) -> Materialized {
2015 +
        let mut m = g.materialize(self);
2016 +
        let when = |h: &ChangeHash| match self.changes.get(h) {
2017 +
            Some(c) => (false, c.hlc, *h),
2018 +
            None => (true, Default::default(), *h),
2019 +
        };
2020 +
        for seg in &mut m.segments {
2021 +
            if let crate::graph::Segment::Conflict(sides) = seg {
2022 +
                sides.sort_by_cached_key(|s| s.iter().map(|a| when(&a.0)).min());
2023 +
            }
2024 +
        }
2025 +
        m
2026 +
    }
2027 +
2028 +
    /// For each conflict of a text node, in marker order (spec §9.5): the
2029 +
    /// text of its sides, earliest change first, when it is an adjacent
2030 +
    /// insertion that keeping both sides settles (oxygen#121); `None` for
2031 +
    /// the others.
2032 +
    pub fn both_sides(&self, node: &NodeId) -> Vec<Option<Vec<Vec<u8>>>> {
2033 +
        let Some(g) = self.graph(node) else {
2034 +
            return Vec::new();
2035 +
        };
2036 +
        let m = self.materialize_ordered(g);
2037 +
        let mut out = Vec::new();
2038 +
        for (i, seg) in m.segments.iter().enumerate() {
2039 +
            match seg {
2040 +
                crate::graph::Segment::Lines(_) => {}
2041 +
                crate::graph::Segment::Conflict(sides) if g.adjacent_insertion(&m, i, self) => {
2042 +
                    out.push(Some(
2043 +
                        sides
2044 +
                            .iter()
2045 +
                            .map(|s| {
2046 +
                                s.iter()
2047 +
                                    .flat_map(|a| g.content(a).unwrap_or_default().to_vec())
2048 +
                                    .collect()
2049 +
                            })
2050 +
                            .collect(),
2051 +
                    ));
2052 +
                }
2053 +
                _ => out.push(None),
2054 +
            }
2055 +
        }
2056 +
        out
2057 +
    }
2058 +
2010 2059
    pub fn render_file(&self, node: &NodeId) -> (Vec<u8>, Materialized) {
2011 2060
        let Some(g) = self.graph(node) else {
2012 2061
            return (Vec::new(), Materialized::default());
2013 2062
        };
2014 -
        let m = g.materialize(self);
2063 +
        let m = self.materialize_ordered(g);
2015 2064
        let bytes = g.render(&m, &|h| self.label(h));
2016 2065
        (bytes, m)
2017 2066
    }
…
945 unchanged lines
modified docs/guide.md +9 −0
…
327 unchanged lines
328 328
oxy resolve assets/logo.png deps/lib   # these paths' content settles them
329 329
```
330 330
331 +
Two changes that each add lines at the same place (two new imports, two
332 +
changelog entries) conflict too, since oxy will not guess their order.
333 +
`oxy status` says when a conflict is one of these; keep both sides, the
334 +
earlier change's first, with one command:
335 +
336 +
```
337 +
oxy resolve --both CHANGELOG.md   # --reverse for the later change's first
338 +
```
339 +
331 340
```
332 341
oxy resolve      # which conflicts your edits resolve, which still have markers
333 342
oxy commit -m "Resolve retry vs refactor"
…
384 unchanged lines
modified docs/spec.md +4 −2
…
385 unchanged lines
386 386
>>>>>>> oxy conflict 1 of 2 end
387 387
```
388 388
389 -
The short identifiers are ChangeId prefixes. Delete/insert conflicts render the deleted context as a `-------` section. Tree conflicts are materialized by writing each candidate with a suffix `.oxy-conflict-<n>` and are listed by `oxy status`. Replace conflicts materialize as `<name>.oxy-conflict-<n>` files.
389 +
The short identifiers are ChangeId prefixes. A conflict's sides are written earliest first, by the clock (§6.4) of the oldest change on each side, then by ChangeHash. Delete/insert conflicts render the deleted context as a `-------` section. Tree conflicts are materialized by writing each candidate with a suffix `.oxy-conflict-<n>` and are listed by `oxy status`. Replace conflicts materialize as `<name>.oxy-conflict-<n>` files.
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.
394 +
393 395
### 9.6 Resolution
394 396
395 397
A **resolution** is a change with `kind = resolution` whose deletions and insertions leave the affected graphs totally ordered and the tree conflict-free. `oxy resolve` computes it from the edited working copy by parsing the markers; a resolution MAY also be authored by any tool that can produce a change. Because a resolution depends on all contributing changes (§8.1), any peer that fetches it necessarily has the conflict it resolves.
…
624 unchanged lines
1020 1022
oxy commit -m MSG [--depends-on CHANGE]..
1021 1023
oxy log [-r REVSET] [-n N] [-v] [-L S,E:PATH]   oxy show CHANGE | -r REVSET [--stat]
1022 1024
oxy describe [CHANGE] [-m MSG]      oxy amend [-m MSG] [--into CHANGE | PATH..]
1023 -
oxy split CHANGE [PATH]..           oxy abandon CHANGE | -r REVSET [--yes]   oxy resolve [PATH]..
1025 +
oxy split CHANGE [PATH]..           oxy abandon CHANGE | -r REVSET [--yes]   oxy resolve [--both [--reverse]] [PATH]..
1024 1026
oxy checkout [CHANGE].. [--discard|--carry] oxy undo                     oxy op log
1025 1027
oxy tag create NAME [CHANGE].. [-m MSG]   oxy tag list
1026 1028
oxy bookmark create|set|delete|list|switch  oxy merge BOOKMARK
…
163 unchanged lines
modified src/cli/mod.rs +15 −2
…
298 unchanged lines
299 299
    /// with paths, settle their replace conflicts (binary files, subrepo pins) with what the
300 300
    /// working copy holds there
301 301
    Resolve {
302 -
        /// Opaque files or subrepos whose replace conflict to settle with their current content
302 +
        /// Opaque files or subrepos whose replace conflict to settle with their current content;
303 +
        /// with --both, the text files whose conflicts to settle (all of them when none)
303 304
        paths: Vec<String>,
305 +
        /// Settle each adjacent insertion (two changes adding lines at the same place) by keeping
306 +
        /// every side, earliest change first; refused where the sides changed the same lines
307 +
        #[arg(long)]
308 +
        both: bool,
309 +
        /// With --both, the latest change first
310 +
        #[arg(long, requires = "both")]
311 +
        reverse: bool,
304 312
        /// Machine-readable output: one JSON document on stdout
305 313
        #[arg(long)]
306 314
        json: bool,
…
967 unchanged lines
1274 1282
            discard,
1275 1283
            carry,
1276 1284
        } => workspace::checkout(&changes, discard, carry),
1277 -
        Cmd::Resolve { paths, json } => workspace::resolve(&paths, json),
1285 +
        Cmd::Resolve {
1286 +
            paths,
1287 +
            both,
1288 +
            reverse,
1289 +
            json,
1290 +
        } => workspace::resolve(&paths, both, reverse, json),
1278 1291
        Cmd::Undo => workspace::undo(),
1279 1292
        Cmd::Gc {
1280 1293
            now,
…
292 unchanged lines
modified src/cli/workspace.rs +43 −6
…
282 unchanged lines
283 283
                _ => ui::err(format!("{} unresolved", ui::glyph("✱", "!"))),
284 284
            };
285 285
            println!("  {}  {state}", ui::path(p));
286 +
            let resolved = matches!(
287 +
                snap.statuses
288 +
                    .iter()
289 +
                    .find(|s| s.path == *p)
290 +
                    .map(|s| s.status),
291 +
                Some(ChangeStatus::Resolved)
292 +
            );
293 +
            let both = if resolved {
294 +
                0
295 +
            } else {
296 +
                snap.base.both_sides(&f.node).iter().flatten().count()
297 +
            };
298 +
            if both > 0 {
299 +
                println!(
300 +
                    "     {}",
301 +
                    ui::dim(format!(
302 +
                        "{} adjacent {}: `oxy resolve --both {p}` keeps both sides, earliest first",
303 +
                        both,
304 +
                        if both == 1 { "insertion" } else { "insertions" }
305 +
                    ))
306 +
                );
307 +
            }
286 308
        }
287 309
    }
288 310
    Ok(2)
…
1121 unchanged lines
1410 1432
    Ok(0)
1411 1433
}
1412 1434
1413 -
pub fn resolve(paths: &[String], as_json: bool) -> Result<i32> {
1435 +
pub fn resolve(paths: &[String], both: bool, reverse: bool, as_json: bool) -> Result<i32> {
1414 1436
    let repo = open()?;
1415 1437
    let mut snap = repo.snapshot("oxy resolve")?;
1416 -
    if !paths.is_empty() {
1438 +
    let paths: Vec<String> = paths
1439 +
        .iter()
1440 +
        .map(|p| p.trim_start_matches("./").trim_end_matches('/').to_string())
1441 +
        .collect();
1442 +
    if both {
1443 +
        // Adjacent insertions (oxygen#121): every side kept, in order.
1444 +
        let kept = repo.keep_both(&snap, &paths, reverse)?;
1445 +
        if !as_json {
1446 +
            for k in &kept {
1447 +
                if !k.settled.is_empty() {
1448 +
                    let n: Vec<String> = k.settled.iter().map(|n| n.to_string()).collect();
1449 +
                    println!("kept both  {}: conflict {}", k.path, n.join(", "));
1450 +
                }
1451 +
                for (n, why) in &k.left {
1452 +
                    println!("markers    {}: conflict {n}: {why}", k.path);
1453 +
                }
1454 +
            }
1455 +
        }
1456 +
        snap = repo.snapshot("oxy resolve")?;
1457 +
    } else if !paths.is_empty() {
1417 1458
        // A replace conflict has no markers to edit out: the user says which
1418 1459
        // content wins (oxygen#140, spec §9.3), and the snapshot builds it.
1419 -
        let paths: Vec<String> = paths
1420 -
            .iter()
1421 -
            .map(|p| p.trim_start_matches("./").trim_end_matches('/').to_string())
1422 -
            .collect();
1423 1460
        repo.choose_resolutions(&snap.base_snapshot, &paths)?;
1424 1461
        snap = repo.snapshot("oxy resolve")?;
1425 1462
    }
…
1590 unchanged lines