merge=union: a checkout keeps both sides of a union file's adjacent insertions (refs #145) 4afe288b7946
A file marked merge=union in .oxyattributes (CHANGELOG.md merge=union) has its adjacent insertions settled by the checkout itself, as oxy resolve --both does: both sides kept, the earlier change's first, and the next commit records the resolution. Edit/edit conflicts keep their markers; what a conflict is does not change. Oxygen's own changelog is marked so. The hub's half (land with the resolution) follows.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 1c45cc48eed6
- When
- · 2026-10-26 10:11 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
4afe288b7946f47e4a29a8e2058ce528327b16473a824b0c1f241623c3f9ed5b- Depends on
- 157d02e95586 44f1ebe7aa5b 4dee277c327a 50a046e439e9 6d3eefbce2f0 75167672ebcd 7b9809e67c3d 8fd78eb52ad2 982864068a59 9bd98cfb4f7e c5ce0367eddf cdb5beba7f05 d3cd7ef95da6 d4dbab97e948 e4595038502a ee06a6c557b7 fce44ae9ad29 ff0af1ee299d
- Depended on by
- c0d4075cadc0 2452b4407ab7 ac4ec124b9f2
- 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
- 21 atoms, 1 tree operation, 9 files
Browse the tree at this change
added .oxyattributes +2 −0
| 1 | + | # oxygen#145: two changes adding changelog entries keep both. |
|
| 2 | + | CHANGELOG.md merge=union |
modified CHANGELOG.md +2 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Changelogs stop conflicting (#145).** A file marked `merge=union` in `.oxyattributes` (`CHANGELOG.md merge=union`) has its adjacent insertions settled by the checkout itself: both sides kept, the earlier change's first, as `oxy resolve --both` does, and the next commit records the resolution. Only adjacent insertions: where two changes changed the same lines, the markers stay. Oxygen's own changelog is marked so. |
|
| 6 | + | ||
| 5 | 7 | - **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 | 8 | ||
| 7 | 9 | ## 0.7.0 (2026-10-26) |
|
| … | 275 unchanged lines |
modified crates/oxy-core/src/attributes.rs +13 −2
| 1 | 1 | //! `.oxyattributes` (spec §19): per-path attributes in gitattributes syntax. |
|
| 2 | 2 | //! Each line is `pattern attr...` where an attribute is `binary`, `text` |
|
| 3 | - | //! (or `-binary`), `eol=lf`, `eol=crlf`, `nocompress`, or `lockable`. A |
|
| 3 | + | //! (or `-binary`), `eol=lf`, `eol=crlf`, `nocompress`, `lockable`, or |
|
| 4 | + | //! `merge=union`. A |
|
| 4 | 5 | //! pattern without a slash matches a basename anywhere below the file's |
|
| 5 | 6 | //! directory; one with a slash is anchored to that directory. Later rules |
|
| 6 | 7 | //! override earlier ones per attribute. |
|
| … | 15 unchanged lines |
||
| 22 | 23 | pub eol: Option<Eol>, |
|
| 23 | 24 | pub nocompress: bool, |
|
| 24 | 25 | pub lockable: bool, |
|
| 26 | + | /// `merge=union` (oxygen#145): two changes adding lines at the same |
|
| 27 | + | /// place keep both, without asking (`oxy resolve --both`, spec §9.5). |
|
| 28 | + | pub union: bool, |
|
| 25 | 29 | } |
|
| 26 | 30 | ||
| 27 | 31 | struct Rule { |
|
| … | 80 unchanged lines |
||
| 108 | 112 | ("-nocompress", None) => a.nocompress = false, |
|
| 109 | 113 | ("lockable", None) => a.lockable = true, |
|
| 110 | 114 | ("-lockable", None) => a.lockable = false, |
|
| 115 | + | ("merge", Some("union")) => a.union = true, |
|
| 116 | + | ("merge", Some(_)) | ("-merge", None) => a.union = false, |
|
| 111 | 117 | _ => {} |
|
| 112 | 118 | } |
|
| 113 | 119 | } |
|
| … | 52 unchanged lines |
||
| 166 | 172 | binary: Some(true), |
|
| 167 | 173 | eol: None, |
|
| 168 | 174 | nocompress: true, |
|
| 169 | - | lockable: false |
|
| 175 | + | lockable: false, |
|
| 176 | + | union: false |
|
| 170 | 177 | } |
|
| 171 | 178 | ); |
|
| 172 | 179 | assert_eq!(a.lookup("notes.txt").eol, Some(Eol::Lf)); |
|
| 173 | 180 | assert_eq!(a.lookup("docs/notes.txt").eol, Some(Eol::Crlf)); |
|
| 174 | 181 | assert!(a.lookup("models/x/y.bin").lockable); |
|
| 182 | + | let u = Attributes::from_files([(".oxyattributes", &b"CHANGELOG.md merge=union\n"[..])]); |
|
| 183 | + | assert!(u.lookup("CHANGELOG.md").union); |
|
| 184 | + | assert!(u.lookup("docs/CHANGELOG.md").union); |
|
| 185 | + | assert!(!u.lookup("README.md").union); |
|
| 175 | 186 | assert_eq!(a.lookup("src/deep/x.dat").binary, Some(true)); |
|
| 176 | 187 | assert_eq!(a.lookup("other/x.dat").binary, None); |
|
| 177 | 188 | assert_eq!(a.lookup("src/gen.txt").eol, None, "deeper file overrides"); |
|
| … | 11 unchanged lines |
modified crates/oxy-core/src/repo/checkout.rs +6 −0
| … | 12 unchanged lines |
||
| 13 | 13 | pub notes: Vec<String>, |
|
| 14 | 14 | /// How many changes of the state left behind the new one lacks. |
|
| 15 | 15 | pub lacks: usize, |
|
| 16 | + | /// Union files whose adjacent insertions were settled by keeping both |
|
| 17 | + | /// sides (oxygen#145). |
|
| 18 | + | pub kept_both: Vec<KeptBoth>, |
|
| 16 | 19 | } |
|
| 17 | 20 | ||
| 18 | 21 | /// Text files rendered at a time by a checkout: bounds its memory. |
|
| … | 433 unchanged lines |
||
| 452 | 455 | *self.walked_dirs.borrow_mut() = Some((dirs, old_manifest.nested.clone())); |
|
| 453 | 456 | } |
|
| 454 | 457 | self.write_manifest_entries(report.conflicted, false, &entries, &HashMap::new())?; |
|
| 458 | + | if report.conflicted && !self.bare { |
|
| 459 | + | report.kept_both = self.settle_unions(command)?; |
|
| 460 | + | } |
|
| 455 | 461 | let _ = self.run_hook( |
|
| 456 | 462 | "post-checkout", |
|
| 457 | 463 | &format!( |
|
| … | 187 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +24 −0
| … | 615 unchanged lines |
||
| 616 | 616 | Ok(out) |
|
| 617 | 617 | } |
|
| 618 | 618 | ||
| 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>> { |
|
| 625 | + | 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()); |
|
| 638 | + | } |
|
| 639 | + | let kept = self.keep_both(&snap, &paths, false)?; |
|
| 640 | + | Ok(kept.into_iter().filter(|k| !k.settled.is_empty()).collect()) |
|
| 641 | + | } |
|
| 642 | + | ||
| 619 | 643 | /// The chosen resolutions `base` still needs; the rest are forgotten. |
|
| 620 | 644 | fn resolutions_for(&self, base: &Snapshot) -> Result<HashSet<String>> { |
|
| 621 | 645 | let all = self.resolutions()?; |
|
| … | 2768 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +51 −0
| … | 284 unchanged lines |
||
| 285 | 285 | } |
|
| 286 | 286 | } |
|
| 287 | 287 | ||
| 288 | + | /// oxygen#145: a file marked `merge=union` has its adjacent insertions |
|
| 289 | + | /// settled by the checkout itself, both kept, earliest first; the next |
|
| 290 | + | /// commit records the resolution. Other files keep their markers. |
|
| 291 | + | #[test] |
|
| 292 | + | fn union_files_keep_both_sides_on_checkout() { |
|
| 293 | + | hermetic(); |
|
| 294 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 295 | + | let root = dir.path(); |
|
| 296 | + | let repo = Repo::init(root).unwrap(); |
|
| 297 | + | write(root, ".oxyattributes", b"CHANGELOG.md merge=union\n"); |
|
| 298 | + | write( |
|
| 299 | + | root, |
|
| 300 | + | "CHANGELOG.md", |
|
| 301 | + | b"# Changelog\n## Unreleased\n## 1.0\n", |
|
| 302 | + | ); |
|
| 303 | + | write(root, "f.txt", b"a\nb\n"); |
|
| 304 | + | let h1 = repo.commit("base", &[]).unwrap(); |
|
| 305 | + | write( |
|
| 306 | + | root, |
|
| 307 | + | "CHANGELOG.md", |
|
| 308 | + | b"# Changelog\n## Unreleased\n- one\n## 1.0\n", |
|
| 309 | + | ); |
|
| 310 | + | write(root, "f.txt", b"a\none\nb\n"); |
|
| 311 | + | repo.commit("one", &[]).unwrap(); |
|
| 312 | + | repo.checkout(&[h1], false, "checkout").unwrap(); |
|
| 313 | + | write( |
|
| 314 | + | root, |
|
| 315 | + | "CHANGELOG.md", |
|
| 316 | + | b"# Changelog\n## Unreleased\n- two\n## 1.0\n", |
|
| 317 | + | ); |
|
| 318 | + | write(root, "f.txt", b"a\ntwo\nb\n"); |
|
| 319 | + | repo.commit("two", &[]).unwrap(); |
|
| 320 | + | let r = repo |
|
| 321 | + | .checkout(&repo.bookmark_heads().unwrap(), false, "checkout main") |
|
| 322 | + | .unwrap(); |
|
| 323 | + | assert!(r.conflicted); |
|
| 324 | + | assert_eq!(r.kept_both.len(), 1, "{:?}", r.kept_both); |
|
| 325 | + | assert_eq!(r.kept_both[0].path, "CHANGELOG.md"); |
|
| 326 | + | assert_eq!( |
|
| 327 | + | fs::read_to_string(root.join("CHANGELOG.md")).unwrap(), |
|
| 328 | + | "# Changelog\n## Unreleased\n- one\n- two\n## 1.0\n" |
|
| 329 | + | ); |
|
| 330 | + | assert!(has_conflict_markers(&fs::read(root.join("f.txt")).unwrap())); |
|
| 331 | + | let s = repo.snapshot("status").unwrap(); |
|
| 332 | + | assert_eq!( |
|
| 333 | + | statuses(&s), |
|
| 334 | + | vec![("CHANGELOG.md".into(), ChangeStatus::Resolved)] |
|
| 335 | + | ); |
|
| 336 | + | } |
|
| 337 | + | ||
| 288 | 338 | /// oxygen#121: two changes that each replaced the same line are not an |
|
| 289 | 339 | /// adjacent insertion: keeping both would keep two versions of the line, |
|
| 290 | 340 | /// so `--both` leaves the markers, as it does for markers already edited. |
|
| … | 363 unchanged lines |
||
| 654 | 704 | conflicted: false, |
|
| 655 | 705 | notes: vec![], |
|
| 656 | 706 | lacks: 1, |
|
| 707 | + | kept_both: vec![], |
|
| 657 | 708 | } |
|
| 658 | 709 | ); |
|
| 659 | 710 | assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n"); |
|
| … | 9841 unchanged lines |
modified docs/guide.md +7 −0
| … | 336 unchanged lines |
||
| 337 | 337 | oxy resolve --both CHANGELOG.md # --reverse for the later change's first |
|
| 338 | 338 | ``` |
|
| 339 | 339 | ||
| 340 | + | For files where the order never matters, such as a changelog, say so once |
|
| 341 | + | in `.oxyattributes` and checkouts keep both sides without asking: |
|
| 342 | + | ||
| 343 | + | ``` |
|
| 344 | + | CHANGELOG.md merge=union |
|
| 345 | + | ``` |
|
| 346 | + | ||
| 340 | 347 | ``` |
|
| 341 | 348 | oxy resolve # which conflicts your edits resolve, which still have markers |
|
| 342 | 349 | oxy commit -m "Resolve retry vs refactor" |
|
| … | 384 unchanged lines |
modified docs/spec.md +3 −3
| … | 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. |
|
| 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. |
|
| 394 | 394 | ||
| 395 | 395 | ### 9.6 Resolution |
|
| 396 | 396 | ||
| … | 556 unchanged lines |
||
| 953 | 953 | | File | Purpose | |
|
| 954 | 954 | |---|---| |
|
| 955 | 955 | | `.oxyignore` | Ignore rules. Gitignore syntax and semantics, per directory, plus `.oxy/info/ignore` and the global ignore file. A directory's `.gitignore` and `.ignore` files are read too, with lower precedence than its `.oxyignore` (a `!` rule there re-includes what they exclude), so a project coming from Git leaves out what Git did. Rules apply to untracked paths only: a tracked path stays tracked, and its edits are seen, until it is deleted or removed from the state. | |
|
| 956 | - | | `.oxyattributes` | Per-path attributes: `binary` (force opaque), `text` (text unless NUL bytes), `eol=lf|crlf` (store LF; check out as given), `nocompress` (chunks packed raw), `lockable` (read-only until `oxy lock`), `filter=<name>`. Gitattributes syntax: a pattern without `/` matches basenames below the file's directory, one with `/` is anchored to it; later rules and deeper files override. The attributes in force for a change are those in the files being committed. | |
|
| 956 | + | | `.oxyattributes` | Per-path attributes: `binary` (force opaque), `text` (text unless NUL bytes), `eol=lf|crlf` (store LF; check out as given), `nocompress` (chunks packed raw), `lockable` (read-only until `oxy lock`), `merge=union` (adjacent insertions settled by keeping both sides, §9.5), `filter=<name>`. Gitattributes syntax: a pattern without `/` matches basenames below the file's directory, one with `/` is anchored to it; later rules and deeper files override. The attributes in force for a change are those in the files being committed. | |
|
| 957 | 957 | | `.oxy/config` | Repository-local settings, TOML. | |
|
| 958 | 958 | | `~/.config/oxy/config.toml` | User settings, the same keys as `.oxy/config`: `author.name`, `author.email`, `author.key`, `author.signer` (the key that signs and seals in place of `author.key`, which must list it as a delegate: an agent's own key; or `keyless`, for fresh keys the hub certifies under an agent's grant, §14.7, from the grant token `oxy auth grant` keeps; also `$OXY_SIGNER`), `keyless.hub` (the hub that certifies them; default the default remote's), `core.sign`, and `remote.<name>.token`; also `auth.helper`. Overridden by `$OXY_HOME/config.toml` when `$OXY_HOME` is set. | |
|
| 959 | 959 | | `~/.config/oxy/credentials.toml` | Tokens from `oxy auth login`, one `[scheme://host[:port]]` section per hub with `token`, `account` and `ca`. Written owner-only; ignored when others can read it. With `auth.helper` set, the token is kept by that helper (Git's credential-helper protocol, user name `oxy-token`) and the section holds the rest. | |
|
| … | 141 unchanged lines |
||
| 1101 | 1101 | | HTTPS transport and endpoints (§16) | Implemented, HTTP only; Noise and ZCT (§15) reserved; negotiation is exact (no Merkle buckets); `refs` takes no `patterns` | |
|
| 1102 | 1102 | | Partial, sparse, shallow (§17) | Implemented | |
|
| 1103 | 1103 | | Locks (§18) | Local advisory locks only; server locks and the `lock` endpoint reserved | |
|
| 1104 | - | | Configuration, attributes, hooks (§19, §20) | Implemented (`.oxyattributes`: binary, text, eol, nocompress, lockable) | |
|
| 1104 | + | | Configuration, attributes, hooks (§19, §20) | Implemented (`.oxyattributes`: binary, text, eol, nocompress, lockable, merge=union) | |
|
| 1105 | 1105 | | Thin profile and proxy (§22) | Reserved | |
|
| 1106 | 1106 | | Git interoperability (§23) | Implemented: import, export, fetch, push, rename detection | |
|
| 1107 | 1107 | | Conformance suite (§26) | Partial: encoding, signature, permutation, conflict, rewrite, protocol, chunking and Git tests exist as unit and integration tests in the crates; a standalone vector suite is not published yet | |
|
| … | 84 unchanged lines |
modified src/cli/workspace.rs +13 −0
| … | 1443 unchanged lines |
||
| 1444 | 1444 | for n in &r.notes { |
|
| 1445 | 1445 | println!(" {} {n}", ui::note()); |
|
| 1446 | 1446 | } |
|
| 1447 | + | print_kept_both(&r); |
|
| 1447 | 1448 | if r.conflicted { |
|
| 1448 | 1449 | println!( |
|
| 1449 | 1450 | " {} {}", |
|
| … | 5 unchanged lines |
||
| 1455 | 1456 | Ok(0) |
|
| 1456 | 1457 | } |
|
| 1457 | 1458 | ||
| 1459 | + | /// Union files (`merge=union`, oxygen#145) a checkout settled itself. |
|
| 1460 | + | fn print_kept_both(r: &oxy_core::repo::CheckoutReport) { |
|
| 1461 | + | for k in &r.kept_both { |
|
| 1462 | + | println!( |
|
| 1463 | + | " {} kept both sides in {} (merge=union); the next commit records it", |
|
| 1464 | + | ui::note(), |
|
| 1465 | + | ui::path(&k.path) |
|
| 1466 | + | ); |
|
| 1467 | + | } |
|
| 1468 | + | } |
|
| 1469 | + | ||
| 1458 | 1470 | pub fn resolve(paths: &[String], both: bool, reverse: bool, as_json: bool) -> Result<i32> { |
|
| 1459 | 1471 | let repo = open()?; |
|
| 1460 | 1472 | let mut snap = repo.snapshot("oxy resolve")?; |
|
| … | 1444 unchanged lines |
||
| 2905 | 2917 | )); |
|
| 2906 | 2918 | print_removed(&r); |
|
| 2907 | 2919 | print_carried(&carried); |
|
| 2920 | + | print_kept_both(&r); |
|
| 2908 | 2921 | if r.conflicted { |
|
| 2909 | 2922 | println!( |
|
| 2910 | 2923 | " {} {}", |
|
| … | 166 unchanged lines |