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 |