OXYGENHUB

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