OXYGENHUB

A proposal whose only conflicts are union adjacent insertions lands with their resolution (closes andymccall/oxygen#145) dd0788b756f2

Two proposals each adding a line at the same place in a merge=union file
(CHANGELOG.md merge=union) left the second unlandable where conflicts
are refused. Landing now adds the resolution, both sides kept, the
earlier change's first, authored by whoever lands it and signed by the
hub as their delegate (Repo::union_resolution, oxy main). Pins
OxygenFlow main with oxygen main.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key e38ce511114b
When
· 2026-10-26 10:34 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
dd0788b756f2d77e53606aef21f1927b97ebdfd6948b7c39d25e71950b77448d
Depends on
145306044896 1e23d0105921 1f1eb06a9009 1f78655a0466 380996db84e5 42fa95540468 69e4f27c5633 757195ad7a5d 8501fd5815b9 92a797b20912 93be9ce4653e a7e6e8e6094c acc85ac0f70b b5668108487f d061ac87618c d23464c0ac60 de505df5cf8d f4d6ef418a96 f7bcee49bf24 ff6569530173
Depended on by
12325e1b1020 27babfa16580
In bookmarks
api-unknown-fields diffusion-pin-bookmark main proposal-list-fast
In tags
v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.7 v0.9.8 v0.9.9
Edits
21 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Changelog entries no longer block a landing (oxygen#145).** Two proposals that each add a line at the same place in a file marked `merge=union` in `.oxyattributes` (`CHANGELOG.md merge=union`) used to leave the second unlandable where conflicts are refused. When a proposal's only conflicts are such adjacent insertions, landing it now adds the resolution, both sides kept, the earlier change's first, authored by whoever lands it and signed by the hub as their delegate. Any other conflict blocks as before.
8 +
7 9
## 0.9.6 (2026-10-26)
8 10
9 11
- **A highlighting test no longer fails a busy machine's gate (#327):** it timed a debug build against a release build's 500 ms; debug builds now have 3 s, still far below what highlighting the line would take.
…
342 unchanged lines
modified Cargo.lock +5 −5
…
1111 unchanged lines
1112 1112
1113 1113
[[package]]
1114 1114
name = "flow-core"
1115 -
version = "0.3.1"
1115 +
version = "0.4.0"
1116 1116
dependencies = [
1117 1117
 "blake3",
1118 1118
 "globset",
…
630 unchanged lines
1749 1749
1750 1750
[[package]]
1751 1751
name = "jet"
1752 -
version = "0.3.1"
1752 +
version = "0.4.0"
1753 1753
dependencies = [
1754 1754
 "blake3",
1755 1755
 "hex",
…
546 unchanged lines
2302 2302
2303 2303
[[package]]
2304 2304
name = "oxy-core"
2305 -
version = "0.6.0"
2305 +
version = "0.7.0"
2306 2306
dependencies = [
2307 2307
 "blake3",
2308 2308
 "ed25519-dalek",
…
18 unchanged lines
2327 2327
2328 2328
[[package]]
2329 2329
name = "oxy-server"
2330 -
version = "0.6.0"
2330 +
version = "0.7.0"
2331 2331
dependencies = [
2332 2332
 "axum",
2333 2333
 "axum-server",
…
4 unchanged lines
2338 2338
2339 2339
[[package]]
2340 2340
name = "oxy-strata"
2341 -
version = "0.6.0"
2341 +
version = "0.7.0"
2342 2342
dependencies = [
2343 2343
 "async-trait",
2344 2344
 "bytes",
…
2530 unchanged lines
modified crates/hub/src/repos/edits.rs +34 −0
…
88 unchanged lines
89 89
        })
90 90
        .await
91 91
}
92 +
93 +
/// Land onto `bookmark` the state with `heads`, its conflicts settled by
94 +
/// `edits` (oxygen#145: union files' adjacent insertions, both sides kept):
95 +
/// a resolution change on `heads`, authored by the user and signed by the
96 +
/// hub key as their delegate, and the bookmark moved to it in one update.
97 +
/// The new heads.
98 +
pub async fn land_resolved(
99 +
    repos: &Repos,
100 +
    repo: &RepoName,
101 +
    bookmark: String,
102 +
    heads: Vec<ChangeHash>,
103 +
    edits: Vec<FileEdit>,
104 +
    message: String,
105 +
    signer: Signer,
106 +
) -> Result<Vec<ChangeHash>> {
107 +
    repos
108 +
        .write(repo, move |r: &Repo, _| {
109 +
            let change = r.build_change(&heads, &edits, &message, signer.author.hash())?;
110 +
            let hash = change.hash();
111 +
            let sig = signer.hub_secret.sign(Domain::Change, &signer.hub_key, &hash);
112 +
            let mut state = r.load_state(&heads)?;
113 +
            state.add(change.clone()).map_err(|e| fail(e.to_string()))?;
114 +
            let mut candidates = heads.clone();
115 +
            candidates.push(hash);
116 +
            let new_heads = state.maximal(&candidates);
117 +
            let update = r.host_ref_update(&bookmark, &new_heads, Some((&signer.hub_key, &signer.hub_secret)))?;
118 +
            let report = r.apply_hosted(Some((change, vec![sig])), &[signer.hub_identity.clone(), signer.author.clone()], update)?;
119 +
            if let Some(refused) = report.results.iter().find(|x| !x.ok) {
120 +
                return Err(fail(format!("the repository refused the landing: {}", refused.reason)));
121 +
            }
122 +
            Ok(new_heads)
123 +
        })
124 +
        .await
125 +
}
modified crates/hub/src/web/proposals.rs +36 −8
…
400 unchanged lines
401 401
    pub target_state: String,
402 402
    pub conflicted: bool,
403 403
    pub conflicts: Vec<ConflictView>,
404 +
    /// When every conflict is an adjacent insertion in a `merge=union`
405 +
    /// file (oxygen#145): each such file's text with both sides kept, which
406 +
    /// landing commits as the resolution.
407 +
    pub union: Option<Vec<(String, Vec<u8>)>>,
404 408
    pub files: Vec<FileRows>,
405 409
    /// The per-file hunks and bases behind `files`, to render again with other options.
406 410
    pub diffs: Vec<oxy_core::repo::FileDiff>,
…
96 unchanged lines
503 507
                target_state: p.target_state.to_hex(),
504 508
                conflicted: p.conflicted,
505 509
                conflicts: p.conflicts,
510 +
                union: if p.conflicted { oxy_core::repo::Repo::union_resolution(&p.loaded.state, &p.loaded.snapshot) } else { None },
506 511
                files,
507 512
                diffs: p.files.clone(),
508 513
                bases: p.bases.clone(),
…
130 unchanged lines
639 644
            blockers.push(format!("code owners still to approve: {}", missing.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
640 645
        }
641 646
    }
642 -
    if loaded.conflicted && !requirements.allow_conflicts {
647 +
    if loaded.conflicted && loaded.union.is_none() && !requirements.allow_conflicts {
643 648
        blockers.push(format!(
644 649
            "landing would carry conflicts, which this repository refuses: resolve them on the source (oxy merge {} there, fix the marked files, oxy commit, push)",
645 650
            loaded.row.target
…
656 unchanged lines
1302 1307
    // the target (spec §14.5); without one, only a policy-free repository
1303 1308
    // can be landed here.
1304 1309
    let signer = if as_hub { super::edit::hub_signer(app) } else { super::edit::signer_for(app, me.id).await };
1305 -
    let result = match signer {
1306 -
        Ok(signer) => crate::repos::edits::move_bookmark(&app.repos, repo, target.clone(), heads.clone(), signer).await.map(|_| pl::state_ref(&heads).to_hex()),
1307 -
        Err(why) => {
1308 -
            app.repos
1310 +
    // Only adjacent insertions in union files in the way (oxygen#145): the
1311 +
    // landing carries their resolution, both sides kept.
1312 +
    let union = loaded.union.clone().filter(|_| loaded.conflicted);
1313 +
    let mut heads = heads;
1314 +
    let result = match (signer, union) {
1315 +
        (Ok(signer), Some(files)) => {
1316 +
            let names: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
1317 +
            let message = format!("Keep both sides in {} (merge=union), landing #{number}", names.join(", "));
1318 +
            let edits = files.into_iter().map(|(path, text)| oxy_core::repo::FileEdit { path, content: Some(text) }).collect();
1319 +
            crate::repos::edits::land_resolved(&app.repos, repo, target.clone(), heads.clone(), edits, message, signer).await.map(|h| {
1320 +
                heads = h;
1321 +
                pl::state_ref(&heads).to_hex()
1322 +
            })
1323 +
        }
1324 +
        (Err(why), Some(_)) => {
1325 +
            return Err(refused(format!(
1326 +
                "Cannot land: its changelog-style conflicts are settled by a resolution the hub signs for you, and it cannot: {why}"
1327 +
            )))
1328 +
        }
1329 +
        (signer, None) => match signer {
1330 +
            Ok(signer) => crate::repos::edits::move_bookmark(&app.repos, repo, target.clone(), heads.clone(), signer)
1331 +
                .await
1332 +
                .map(|_| pl::state_ref(&heads).to_hex()),
1333 +
            Err(why) => {
1334 +
                let (target, heads) = (target.clone(), heads.clone());
1335 +
                app.repos
1309 1336
                .write(repo, move |r, _| {
1310 1337
                    if r.current_policy()?.is_some() {
1311 1338
                        return Err(crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!(
…
5 unchanged lines
1317 1344
                })
1318 1345
                .await
1319 1346
            }
1347 +
        },
1320 1348
    };
1321 1349
    let state = result.map_err(|e| refused(e.to_string()))?;
1322 1350
    pdb::set_landed(&app.db, p.id, &state, &me.username, via).await.map_err(|e| refused(e.to_string()))?;
…
53 unchanged lines
1376 1404
    // ...and its pipeline runs, as a push of the same heads would start it
1377 1405
    // (#197): without the run a consumer following it when green would wait
1378 1406
    // for a state that never runs.
1379 -
    crate::flow::on_push(app, row, &p.target, &state, &loaded.heads, &me.username).await;
1407 +
    crate::flow::on_push(app, row, &p.target, &state, &heads, &me.username).await;
1380 1408
    // A landing on the default bookmark closes the issues its changes (and
1381 1409
    // its description) say they close, as a push there would.
1382 1410
    if p.target == row.default_bookmark {
1383 1411
        let body = (format!("proposal #{number}"), p.body.clone());
1384 -
        crate::web::issues::close_from_arrived(app, row, loaded.target_heads.clone(), loaded.heads.clone(), Some(body)).await;
1412 +
        crate::web::issues::close_from_arrived(app, row, loaded.target_heads.clone(), heads.clone(), Some(body)).await;
1385 1413
    }
1386 1414
    // Diffusion (#150): a landing moves the target as a push would, so the
1387 1415
    // pin index follows it, and the consumers of a moved default bookmark
1388 1416
    // are bumped.
1389 -
    let heads = loaded.heads.clone();
1417 +
    let heads = heads.clone();
1390 1418
    let moved = vec![(p.target.clone(), state.clone(), heads.clone())];
1391 1419
    crate::diffusion::after_push(app, row, &moved).await;
1392 1420
    if p.target == row.default_bookmark {
…
670 unchanged lines
modified crates/hub/tests/edits.rs +51 −0
…
489 unchanged lines
490 490
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
491 491
    assert_eq!(send(app, "GET", "/andy/demo/file/main/docs/guide.md", None, None).await.status, StatusCode::NOT_FOUND);
492 492
493 +
    // A union file (oxygen#145): two proposals each add a changelog line
494 +
    // after the same one. Conflicts are refused here again, but these are
495 +
    // only adjacent insertions in a `merge=union` file, so the second
496 +
    // lands with a resolution keeping both, the earlier first.
497 +
    db::proposals::set_requirements(
498 +
        &app.db,
499 +
        row.id,
500 +
        db::proposals::Requirements { required_approvals: 0, require_owners: false, allow_conflicts: false },
501 +
    )
502 +
    .await
503 +
    .unwrap();
504 +
    for (path, text) in [(".oxyattributes", "CHANGELOG.md merge=union\n"), ("CHANGELOG.md", "# Changelog\n## Unreleased\n## 1.0\n")] {
505 +
        let r = send(
506 +
            app,
507 +
            "POST",
508 +
            "/andy/demo/edit/main",
509 +
            Some(&andy),
510 +
            Some(&format!("_csrf={csrf}&path={}&content={}&message=add&mode=commit", enc(path), enc(text))),
511 +
        )
512 +
        .await;
513 +
        assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
514 +
    }
515 +
    for (b, line) in [("log-a", "- first\n"), ("log-b", "- second\n")] {
516 +
        let text = format!("# Changelog\n## Unreleased\n{line}## 1.0\n");
517 +
        let r = send(
518 +
            app,
519 +
            "POST",
520 +
            "/andy/demo/edit/main",
521 +
            Some(&andy),
522 +
            Some(&format!(
523 +
                "_csrf={csrf}&path=CHANGELOG.md&original=CHANGELOG.md&content={}&message={b}&mode=propose&new_bookmark={b}",
524 +
                enc(&text)
525 +
            )),
526 +
        )
527 +
        .await;
528 +
        assert!(r.location.as_deref().is_some_and(|l| l.contains("/proposal/")), "{}", r.body);
529 +
    }
530 +
    for n in [9, 10] {
531 +
        let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await;
532 +
        assert!(!page.body.contains("landing would carry conflicts"), "#{n}: {}", page.body);
533 +
        let r =
534 +
            send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
535 +
        assert!(r.location.as_deref().is_some_and(|l| l.contains("notice=Landed.")), "#{n}: {:?} {}", r.location, r.body);
536 +
    }
537 +
    assert_eq!(
538 +
        send(app, "GET", "/andy/demo/raw/main/CHANGELOG.md", None, None).await.body,
539 +
        "# Changelog\n## Unreleased\n- first\n- second\n## 1.0\n"
540 +
    );
541 +
    assert!(send(app, "GET", "/andy/demo/conflicts", None, None).await.body.contains("No bookmark carries a conflict"));
542 +
    assert!(send(app, "GET", "/andy/demo/changes/main", Some(&andy), None).await.body.contains("Keep both sides in CHANGELOG.md"));
543 +
493 544
    // Andy's client pulls it all and every change verifies there too.
494 545
    client.pull("origin").unwrap();
495 546
    let v: Vec<_> =
…
390 unchanged lines
replaced deps/oxygenflow +0 −0

No line changes.

modified docs/spec.md +1 −1
…
401 unchanged lines
402 402
403 403
A review is `comment`, `request changes`, or `approve`. An approval MUST be backed by a **seal attestation** (spec §14.4, `kind = seal`) over the proposal's change hashes, signed with a key linked to the reviewer's account. Private keys never enter the browser, so the reviewer signs from a client: `oxy seal --proposal <url>` or `oxy seal <hashes>` (§18, a new command; the seal object already exists in the model) signs and pushes the seal; the hub matches the seal to the proposal and records the approval. The page shows the command ready to copy. An approval not yet backed by a seal is shown as "approval pending signature" and does not count towards requirements.
404 404
405 -
Landing requirements per repository (settings): N approvals, approvals from code owners (`.oxyowners` in the target state, GitHub `CODEOWNERS` syntax), required checks green on the projected state, no conflicts (or conflicts allowed, matching the policy rule's `allow_conflicts`), and no outstanding "request changes". A repository under an organisation of two or more members starts with one approval required (someone other than the author can give it; nobody may approve their own proposal), any other with none; an admin changes either in Settings. When none is required and nobody has approved, the proposal page says so beside Land, and Land asks for confirmation.
405 +
Landing requirements per repository (settings): N approvals, approvals from code owners (`.oxyowners` in the target state, GitHub `CODEOWNERS` syntax), required checks green on the projected state, no conflicts (or conflicts allowed, matching the policy rule's `allow_conflicts`; conflicts that are all adjacent insertions in `merge=union` files, oxygen#145, do not count: landing adds their resolution, both sides kept, earlier change first, authored by the person landing and signed by the hub as their delegate), and no outstanding "request changes". A repository under an organisation of two or more members starts with one approval required (someone other than the author can give it; nobody may approve their own proposal), any other with none; an admin changes either in Settings. When none is required and nobody has approved, the proposal page says so beside Land, and Land asks for confirmation.
406 406
407 407
### 7.5 Stacked proposals
408 408
…
486 unchanged lines