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 +37 −9
| … | 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!( |
|
| … | 4 unchanged lines |
||
| 1316 | 1343 | Ok(pl::state_ref(&heads).to_hex()) |
|
| 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 |