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 |