Marker-like text no longer blocks a conflict resolution 3652104ae7d3
Resolving proposal #125's conflict in docs/spec.md was refused ('markers
remain') because §9.5 holds a fenced example of oxy's own conflict
markers: any line starting '<<<<<<< oxy conflict' counted. A file now
still has markers only when it holds one of the marker lines oxy wrote
for its conflicts: the marker-like lines of the materialized file that
are not among its unconflicted lines. Documentation or fixtures that
show markers no longer stop a resolution; a real marker left behind is
still caught.
Closes #126
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 433af17e3790
- When
- · 2026-10-18 20:19 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
3652104ae7d3a03e79396c304e66cec0b979896bb6be16e45c7f74d327b52d5e- Depends on
- 75167672ebcd 982864068a59 cdb5beba7f05
- Depended on by
- 9bd98cfb4f7e
- In bookmarks
- doctor-trust identical-sides keyless-tags main push-notes subrepo-pin-state
- In tags
- v0.10.0 v0.11.0 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 5 atoms, 0 tree operations, 2 files
Browse the tree at this change
modified crates/oxy-core/src/repo/snapshot.rs +31 −2
| … | 25 unchanged lines |
||
| 26 | 26 | }) |
|
| 27 | 27 | } |
|
| 28 | 28 | ||
| 29 | + | /// Whether `content` still holds a conflict marker oxy wrote for this file |
|
| 30 | + | /// (oxygen#126): a marker-like line of the materialized conflicted file |
|
| 31 | + | /// that is not one of its unconflicted lines (`plain`). Text that merely |
|
| 32 | + | /// looks like a marker, as oxy's own documentation or a test fixture has, |
|
| 33 | + | /// is in the unconflicted lines, so it is not taken for one. |
|
| 34 | + | pub fn has_own_conflict_markers(content: &[u8], materialized: &[u8], plain: &[&[u8]]) -> bool { |
|
| 35 | + | let line = |l: &[u8]| -> Vec<u8> { |
|
| 36 | + | let l = l.strip_suffix(b"\n").unwrap_or(l); |
|
| 37 | + | l.strip_suffix(b"\r").unwrap_or(l).to_vec() |
|
| 38 | + | }; |
|
| 39 | + | let marker = |l: &[u8]| { |
|
| 40 | + | l.starts_with(b"<<<<<<< oxy conflict") |
|
| 41 | + | || l.starts_with(b"======= oxy conflict") |
|
| 42 | + | || l.starts_with(b">>>>>>> oxy conflict") |
|
| 43 | + | }; |
|
| 44 | + | let unconflicted: HashSet<Vec<u8>> = plain.iter().map(|l| line(l)).collect(); |
|
| 45 | + | let own: HashSet<Vec<u8>> = split_lines(materialized) |
|
| 46 | + | .into_iter() |
|
| 47 | + | .map(line) |
|
| 48 | + | .filter(|l| marker(l) && !unconflicted.contains(l)) |
|
| 49 | + | .collect(); |
|
| 50 | + | !own.is_empty() |
|
| 51 | + | && split_lines(content) |
|
| 52 | + | .into_iter() |
|
| 53 | + | .any(|l| own.contains(&line(l))) |
|
| 54 | + | } |
|
| 55 | + | ||
| 29 | 56 | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 30 | 57 | pub struct PathStatus { |
|
| 31 | 58 | pub path: String, |
|
| … | 1467 unchanged lines |
||
| 1499 | 1526 | if bytes == &disk_text { |
|
| 1500 | 1527 | return Ok(()); |
|
| 1501 | 1528 | } |
|
| 1502 | - | if has_conflict_markers(&e.content) { |
|
| 1529 | + | let regions = self.base.conflict_regions(&node); |
|
| 1530 | + | let plain: Vec<&[u8]> = |
|
| 1531 | + | regions.plain.iter().map(|(_, l)| l.as_slice()).collect(); |
|
| 1532 | + | if has_own_conflict_markers(&e.content, bytes, &plain) { |
|
| 1503 | 1533 | self.statuses.push(PathStatus { |
|
| 1504 | 1534 | path: path.to_string(), |
|
| 1505 | 1535 | status: ChangeStatus::ConflictEdited, |
|
| … | 4 unchanged lines |
||
| 1510 | 1540 | // with each region as a gap, then retire every region atom. |
|
| 1511 | 1541 | // Whatever the author kept from a region is re-inserted as |
|
| 1512 | 1542 | // new atoms, which leaves the graph totally ordered. |
|
| 1513 | - | let regions = self.base.conflict_regions(&node); |
|
| 1514 | 1543 | let mut edits = text_edits(node, ®ions.plain, &disk_text); |
|
| 1515 | 1544 | if !regions.region_atoms.is_empty() { |
|
| 1516 | 1545 | edits.insert( |
|
| … | 474 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +59 −0
| … | 170 unchanged lines |
||
| 171 | 171 | (dir, repo, h1, h2, h3) |
|
| 172 | 172 | } |
|
| 173 | 173 | ||
| 174 | + | /// oxygen#126: a file whose text includes an example of oxy's markers (as |
|
| 175 | + | /// the spec's §9.5 does) can still have its conflict resolved; a real |
|
| 176 | + | /// marker left behind is still caught. |
|
| 174 | 177 | #[test] |
|
| 178 | + | fn marker_like_text_does_not_block_a_resolution() { |
|
| 179 | + | hermetic(); |
|
| 180 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 181 | + | let root = dir.path(); |
|
| 182 | + | let repo = Repo::init(root).unwrap(); |
|
| 183 | + | let example = "```\n<<<<<<< oxy conflict 1 of 2, side 1: change k7m2p9 \"Add retry loop\"\n... lines ...\n======= oxy conflict 1 of 2, side 2: change q3x8w1 \"Refactor client\"\n>>>>>>> oxy conflict 1 of 2 end\n```\n"; |
|
| 184 | + | write( |
|
| 185 | + | root, |
|
| 186 | + | "doc.md", |
|
| 187 | + | format!("intro\n{example}b\nend\n").as_bytes(), |
|
| 188 | + | ); |
|
| 189 | + | let h1 = repo.commit("base", &[]).unwrap(); |
|
| 190 | + | write( |
|
| 191 | + | root, |
|
| 192 | + | "doc.md", |
|
| 193 | + | format!("intro\n{example}B\nend\n").as_bytes(), |
|
| 194 | + | ); |
|
| 195 | + | repo.commit("upper", &[]).unwrap(); |
|
| 196 | + | repo.checkout(&[h1], false, "checkout").unwrap(); |
|
| 197 | + | write( |
|
| 198 | + | root, |
|
| 199 | + | "doc.md", |
|
| 200 | + | format!("intro\n{example}X\nend\n").as_bytes(), |
|
| 201 | + | ); |
|
| 202 | + | repo.commit("ex", &[]).unwrap(); |
|
| 203 | + | repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main") |
|
| 204 | + | .unwrap(); |
|
| 205 | + | let marked = fs::read_to_string(root.join("doc.md")).unwrap(); |
|
| 206 | + | assert!(marked.contains("oxy conflict 1 of 1"), "{marked}"); |
|
| 207 | + | // A real marker left: still caught. |
|
| 208 | + | write(root, "doc.md", marked.replace("B\n", "BB\n").as_bytes()); |
|
| 209 | + | let s = repo.snapshot("status").unwrap(); |
|
| 210 | + | assert_eq!( |
|
| 211 | + | statuses(&s), |
|
| 212 | + | vec![("doc.md".into(), ChangeStatus::ConflictEdited)] |
|
| 213 | + | ); |
|
| 214 | + | // Resolved, the example kept: a resolution. |
|
| 215 | + | write( |
|
| 216 | + | root, |
|
| 217 | + | "doc.md", |
|
| 218 | + | format!("intro\n{example}X\nend\n").as_bytes(), |
|
| 219 | + | ); |
|
| 220 | + | let s = repo.snapshot("status").unwrap(); |
|
| 221 | + | assert_eq!( |
|
| 222 | + | statuses(&s), |
|
| 223 | + | vec![("doc.md".into(), ChangeStatus::Resolved)] |
|
| 224 | + | ); |
|
| 225 | + | let h = repo.commit("resolve", &[]).unwrap(); |
|
| 226 | + | assert_eq!( |
|
| 227 | + | repo.store().get_change(&h).unwrap().kind, |
|
| 228 | + | ChangeKind::Resolution |
|
| 229 | + | ); |
|
| 230 | + | assert!(!repo.load_state(&[h]).unwrap().materialize().is_conflicted()); |
|
| 231 | + | } |
|
| 232 | + | ||
| 233 | + | #[test] |
|
| 175 | 234 | fn concurrent_edits_conflict_and_can_be_resolved() { |
|
| 176 | 235 | hermetic(); |
|
| 177 | 236 | let (dir, repo, h1, h2, h3) = conflicted_repo(); |
|
| … | 3693 unchanged lines |