Delete bookmarks on a remote: oxy push REMOTE --delete NAME f8c695764c95
A push update to the zero state deletes the bookmark on the remote. It is attested like any update and checked the same way: the bookmark must be at old_state, a policy needs an authorized key, and a server never deletes its default bookmark (oxy serve: the repository's bookmark; hosts say which through Backend::default_bookmark). The deletion's attestation stays the latest, so an old one cannot be replayed to bring the bookmark back; a bookmark new to a remote therefore starts its serial at the clock. The local bookmark is kept; the remote-tracking entry goes. Spec §16.4, Draft 0.8l. refs #13
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 22:32 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
f8c695764c957deb54d78e50332c677f5cda21b534cd2eb0f8d843d1fbf4a27c- Depends on
- 54cd08a1e7bf 750251b21a96 75036530b621 75167672ebcd 76f752503e7e 7aa2dc9369e3 7b9809e67c3d 86e682c242c6 982864068a59 c1fe1fb25021 cc7a00565161 d42369abf180 e02bf0cd0533 fce44ae9ad29 ff0af1ee299d
- Depended on by
- 642fa213de80 d3b270909c78 b63fc7a77fc6 ee130dd9eef2 67a4fe1aa2d5 d36ba2f79f95 028545dfa7a4 767ac6cd0545 4b9d06778581 f53cbe7a204e f0a517d28237 34fad4a92451 cb8b8082fa73 7ba155f20aa0 b35df36b7dc5 14edbaaf5291 f7510b63cceb be451356e3ef 2dd42fa169c8 8c30d814f2cc
- In bookmarks
- agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 25 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified crates/oxy-core/src/repo/sync.rs +136 −1
| … | 604 unchanged lines |
||
| 605 | 605 | Ok(report) |
|
| 606 | 606 | } |
|
| 607 | 607 | ||
| 608 | + | /// A deletion (spec §16.4): `new_state` is zero. The bookmark must exist |
|
| 609 | + | /// at `old_state`, is never the repository's own bookmark (its default), |
|
| 610 | + | /// and under a policy needs an attestation by a key that may update it. |
|
| 611 | + | /// The deletion's attestation stays the bookmark's latest, so an older |
|
| 612 | + | /// one cannot be replayed to bring it back. |
|
| 613 | + | fn apply_deletion( |
|
| 614 | + | &self, |
|
| 615 | + | view: &mut View, |
|
| 616 | + | u: &RefUpdate, |
|
| 617 | + | policy: Option<&crate::policy::Policy>, |
|
| 618 | + | ) -> std::result::Result<(), String> { |
|
| 619 | + | let Some(current) = view.bookmarks.get(&u.name).copied() else { |
|
| 620 | + | return Err(format!("no bookmark {:?} to delete", u.name)); |
|
| 621 | + | }; |
|
| 622 | + | if current != u.old_state { |
|
| 623 | + | return Err("stale: the remote bookmark has moved; pull first".into()); |
|
| 624 | + | } |
|
| 625 | + | if u.name == self.config.bookmark { |
|
| 626 | + | return Err(format!( |
|
| 627 | + | "{:?} is this repository's default bookmark and cannot be deleted", |
|
| 628 | + | u.name |
|
| 629 | + | )); |
|
| 630 | + | } |
|
| 631 | + | if let Some(p) = policy { |
|
| 632 | + | let Some(sa) = &u.attestation else { |
|
| 633 | + | return Err(format!( |
|
| 634 | + | "deleting {:?} requires a signed reference attestation under this repository's policy", |
|
| 635 | + | u.name |
|
| 636 | + | )); |
|
| 637 | + | }; |
|
| 638 | + | let signer = sa |
|
| 639 | + | .sigs |
|
| 640 | + | .first() |
|
| 641 | + | .map(|s| s.key) |
|
| 642 | + | .ok_or("attestation carries no signature")?; |
|
| 643 | + | if !p.authorizes(&signer, &u.name) && !self.delegate_authorized(p, &signer, &u.name) { |
|
| 644 | + | return Err(format!( |
|
| 645 | + | "key {} may not delete {:?} under this repository's policy", |
|
| 646 | + | signer.short(12), |
|
| 647 | + | u.name |
|
| 648 | + | )); |
|
| 649 | + | } |
|
| 650 | + | } |
|
| 651 | + | if let Some(sa) = &u.attestation { |
|
| 652 | + | let last = self |
|
| 653 | + | .latest_ref_attest(&u.name) |
|
| 654 | + | .map_err(|e| e.to_string())? |
|
| 655 | + | .and_then(|(_, a)| match a.attest.body { |
|
| 656 | + | AttestBody::Ref { serial, .. } => Some(serial), |
|
| 657 | + | _ => None, |
|
| 658 | + | }); |
|
| 659 | + | self.check_ref_attest(sa, &u.name, &u.old_state, &u.new_state, last, false)?; |
|
| 660 | + | let r = self |
|
| 661 | + | .store |
|
| 662 | + | .put_attest(&sa.attest) |
|
| 663 | + | .map_err(|e| e.to_string())?; |
|
| 664 | + | for sig in &sa.sigs { |
|
| 665 | + | self.store |
|
| 666 | + | .put_signature(&r, sig) |
|
| 667 | + | .map_err(|e| e.to_string())?; |
|
| 668 | + | } |
|
| 669 | + | self.set_ref_attest(&u.name, &r) |
|
| 670 | + | .map_err(|e| e.to_string())?; |
|
| 671 | + | } |
|
| 672 | + | view.bookmarks.remove(&u.name); |
|
| 673 | + | Ok(()) |
|
| 674 | + | } |
|
| 675 | + | ||
| 608 | 676 | fn apply_update( |
|
| 609 | 677 | &self, |
|
| 610 | 678 | view: &mut View, |
|
| … | 9 unchanged lines |
||
| 620 | 688 | .store |
|
| 621 | 689 | .put_state(&StateObject::new(vec![])) |
|
| 622 | 690 | .map_err(|e| e.to_string())?; |
|
| 691 | + | if u.new_state.is_zero() { |
|
| 692 | + | return self.apply_deletion(view, u, policy); |
|
| 693 | + | } |
|
| 623 | 694 | let current = view.bookmarks.get(&u.name).copied().unwrap_or(empty); |
|
| 624 | 695 | if current != u.old_state { |
|
| 625 | 696 | return Err("stale: the remote bookmark has moved; pull first".into()); |
|
| … | 607 unchanged lines |
||
| 1233 | 1304 | ||
| 1234 | 1305 | /// Push the current bookmark (spec §16.3), sealing this author's unsigned |
|
| 1235 | 1306 | /// changes and attesting the reference when a key is available. |
|
| 1307 | + | /// Delete `bookmark` on a remote (spec §16.4): an update to the zero |
|
| 1308 | + | /// state, attested when we have an identity. The local bookmark, if |
|
| 1309 | + | /// any, is kept; only the remote-tracking entry goes. |
|
| 1310 | + | pub fn push_delete(&self, remote_name: &str, bookmark: &str) -> Result<Vec<RefResult>> { |
|
| 1311 | + | let remote = self.open_named_remote(remote_name)?; |
|
| 1312 | + | self.check_remote_id(remote.as_ref(), remote_name, true)?; |
|
| 1313 | + | let resp = remote.refs()?; |
|
| 1314 | + | let Some(r) = resp.refs.iter().find(|r| r.name == bookmark) else { |
|
| 1315 | + | return fail(format!("{remote_name} has no bookmark {bookmark}")); |
|
| 1316 | + | }; |
|
| 1317 | + | if resp.policy.is_some() && self.secret_key()?.is_none() { |
|
| 1318 | + | return fail(format!("{remote_name} enforces a trust policy; deleting needs an identity (`oxy identity create`)")); |
|
| 1319 | + | } |
|
| 1320 | + | let attestation = match self.secret_key()? { |
|
| 1321 | + | Some(_) => Some(self.sign_attest(&Attest { |
|
| 1322 | + | repo: self.id()?, |
|
| 1323 | + | hlc: Hlc::now(), |
|
| 1324 | + | body: AttestBody::Ref { |
|
| 1325 | + | name: bookmark.to_string(), |
|
| 1326 | + | old_state: r.state, |
|
| 1327 | + | new_state: ObjectRef::ZERO, |
|
| 1328 | + | serial: r.last_serial.unwrap_or(0) + 1, |
|
| 1329 | + | }, |
|
| 1330 | + | })?), |
|
| 1331 | + | None => None, |
|
| 1332 | + | }; |
|
| 1333 | + | let results = remote.push( |
|
| 1334 | + | Bundle::default(), |
|
| 1335 | + | vec![RefUpdate { |
|
| 1336 | + | name: bookmark.to_string(), |
|
| 1337 | + | old_state: r.state, |
|
| 1338 | + | new_state: ObjectRef::ZERO, |
|
| 1339 | + | force: true, |
|
| 1340 | + | attestation, |
|
| 1341 | + | }], |
|
| 1342 | + | None, |
|
| 1343 | + | vec![], |
|
| 1344 | + | )?; |
|
| 1345 | + | if results.iter().any(|x| x.ok && x.name == bookmark) { |
|
| 1346 | + | let (op_ref, op) = self.head_op()?; |
|
| 1347 | + | let mut view = op.view.clone(); |
|
| 1348 | + | if let Some(m) = view.remotes.get_mut(remote_name) { |
|
| 1349 | + | m.remove(bookmark); |
|
| 1350 | + | } |
|
| 1351 | + | self.record_op( |
|
| 1352 | + | &[op_ref], |
|
| 1353 | + | &format!("oxy push {remote_name} --delete {bookmark}"), |
|
| 1354 | + | view, |
|
| 1355 | + | )?; |
|
| 1356 | + | } |
|
| 1357 | + | Ok(results) |
|
| 1358 | + | } |
|
| 1359 | + | ||
| 1236 | 1360 | pub fn push(&self, remote_name: &str, force: bool) -> Result<Vec<RefResult>> { |
|
| 1237 | 1361 | let bookmark = self.config.bookmark.clone(); |
|
| 1238 | 1362 | self.push_bookmark(remote_name, &bookmark, force) |
|
| … | 116 unchanged lines |
||
| 1355 | 1479 | name: bookmark.clone(), |
|
| 1356 | 1480 | old_state: remote_state, |
|
| 1357 | 1481 | new_state, |
|
| 1358 | - | serial: last_serial.unwrap_or(0) + 1, |
|
| 1482 | + | serial: next_serial(last_serial), |
|
| 1359 | 1483 | }, |
|
| 1360 | 1484 | })?), |
|
| 1361 | 1485 | None => None, |
|
| … | 143 unchanged lines |
||
| 1505 | 1629 | repo.checkout(&heads, false, "oxy clone (checkout)")?; |
|
| 1506 | 1630 | } |
|
| 1507 | 1631 | Ok(repo) |
|
| 1632 | + | } |
|
| 1633 | + | } |
|
| 1634 | + | ||
| 1635 | + | /// The serial of a reference's next attestation: one more than the last the |
|
| 1636 | + | /// remote reports, or, for a bookmark the remote does not have, the clock in |
|
| 1637 | + | /// milliseconds, so a bookmark created again after a deletion goes past the |
|
| 1638 | + | /// deletion's serial the remote keeps. |
|
| 1639 | + | fn next_serial(last: Option<u64>) -> u64 { |
|
| 1640 | + | match last { |
|
| 1641 | + | Some(l) => l + 1, |
|
| 1642 | + | None => Hlc::now().wall_ms.max(1), |
|
| 1508 | 1643 | } |
|
| 1509 | 1644 | } |
modified crates/oxy-core/src/repo/tests.rs +67 −6
| … | 514 unchanged lines |
||
| 515 | 515 | origin.verify_change(&h1).unwrap(), |
|
| 516 | 516 | SignatureStatus::Unsigned |
|
| 517 | 517 | ); |
|
| 518 | + | let serial_of = |att: &SignedAttest| match att.attest.body { |
|
| 519 | + | AttestBody::Ref { serial, .. } => serial, |
|
| 520 | + | _ => unreachable!(), |
|
| 521 | + | }; |
|
| 518 | 522 | let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap(); |
|
| 519 | - | assert!(matches!(att.attest.body, AttestBody::Ref { serial: 1, .. })); |
|
| 523 | + | // A bookmark new to the remote starts at the clock (past any deleted |
|
| 524 | + | // namesake's serial); later updates count up from there. |
|
| 525 | + | let first = serial_of(&att); |
|
| 526 | + | assert!(first > 1_000_000_000_000, "{first}"); |
|
| 520 | 527 | let again = a.push("origin", false).unwrap(); |
|
| 521 | 528 | assert!(again[0].ok && again[0].reason.contains("up to date")); |
|
| 522 | 529 | let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap(); |
|
| 523 | - | assert!( |
|
| 524 | - | matches!(att.attest.body, AttestBody::Ref { serial: 1, .. }), |
|
| 525 | - | "a no-op push does not attest again" |
|
| 526 | - | ); |
|
| 530 | + | assert_eq!(serial_of(&att), first, "a no-op push does not attest again"); |
|
| 527 | 531 | ||
| 528 | 532 | // B clones and sees the file and the seal. |
|
| 529 | 533 | let b = Repo::clone(&origin_url, &root.join("b")).unwrap(); |
|
| … | 27 unchanged lines |
||
| 557 | 561 | origin_heads.sort(); |
|
| 558 | 562 | assert_eq!(origin_heads, want); |
|
| 559 | 563 | let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap(); |
|
| 560 | - | assert!(matches!(att.attest.body, AttestBody::Ref { serial: 2, .. })); |
|
| 564 | + | assert_eq!(serial_of(&att), first + 1); |
|
| 561 | 565 | ||
| 562 | 566 | // B pulls and ends up identical. |
|
| 563 | 567 | let pr = b.pull("origin").unwrap(); |
|
| … | 4422 unchanged lines |
||
| 4986 | 4990 | assert!(repo.latest_checkpoint("feature").unwrap().is_none()); |
|
| 4987 | 4991 | let heads = repo.resolve_heads("feature/x").unwrap(); |
|
| 4988 | 4992 | assert!(repo.load_state_checkpointed(&heads).unwrap().is_some()); |
|
| 4993 | + | } |
|
| 4994 | + | ||
| 4995 | + | /// Deleting a bookmark on a remote (spec §16.4): attested like any update, |
|
| 4996 | + | /// never the remote's own bookmark, and a bookmark created again later is |
|
| 4997 | + | /// accepted although the deletion's attestation stays the latest. |
|
| 4998 | + | #[test] |
|
| 4999 | + | fn bookmarks_are_deleted_on_a_remote() { |
|
| 5000 | + | hermetic(); |
|
| 5001 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 5002 | + | let root = dir.path(); |
|
| 5003 | + | let origin = Repo::init(&root.join("origin")).unwrap(); |
|
| 5004 | + | let url = root.join("origin").to_string_lossy().to_string(); |
|
| 5005 | + | let mut repo = Repo::clone(&url, &root.join("r")) |
|
| 5006 | + | .unwrap() |
|
| 5007 | + | .with_home(root.join("home")); |
|
| 5008 | + | repo.create_identity("Ada", "[email protected]", IdentityScope::Repo) |
|
| 5009 | + | .unwrap(); |
|
| 5010 | + | let r = root.join("r"); |
|
| 5011 | + | write(&r, "a.txt", b"a\n"); |
|
| 5012 | + | repo.commit("a", &[]).unwrap(); |
|
| 5013 | + | assert!(repo.push("origin", false).unwrap()[0].ok); |
|
| 5014 | + | repo.bookmark_create("feature/x", None).unwrap(); |
|
| 5015 | + | assert!(repo.push_bookmark("origin", "feature/x", false).unwrap()[0].ok); |
|
| 5016 | + | assert!(origin.resolve_heads("feature/x").is_ok()); |
|
| 5017 | + | ||
| 5018 | + | let res = repo.push_delete("origin", "feature/x").unwrap(); |
|
| 5019 | + | assert!(res[0].ok, "{res:?}"); |
|
| 5020 | + | assert!(!origin.view().unwrap().bookmarks.contains_key("feature/x")); |
|
| 5021 | + | let (_, att) = origin.latest_ref_attest("feature/x").unwrap().unwrap(); |
|
| 5022 | + | assert!( |
|
| 5023 | + | matches!(att.attest.body, AttestBody::Ref { new_state, .. } if new_state.is_zero()), |
|
| 5024 | + | "the deletion is attested" |
|
| 5025 | + | ); |
|
| 5026 | + | assert!( |
|
| 5027 | + | repo.view().unwrap().bookmarks.contains_key("feature/x"), |
|
| 5028 | + | "the local bookmark stays" |
|
| 5029 | + | ); |
|
| 5030 | + | assert!(!repo |
|
| 5031 | + | .view() |
|
| 5032 | + | .unwrap() |
|
| 5033 | + | .remotes |
|
| 5034 | + | .get("origin") |
|
| 5035 | + | .is_some_and(|m| m.contains_key("feature/x"))); |
|
| 5036 | + | ||
| 5037 | + | // Pushing it again creates it again, past the deletion's serial. |
|
| 5038 | + | let res = repo.push_bookmark("origin", "feature/x", false).unwrap(); |
|
| 5039 | + | assert!(res[0].ok, "{res:?}"); |
|
| 5040 | + | assert!(origin.view().unwrap().bookmarks.contains_key("feature/x")); |
|
| 5041 | + | ||
| 5042 | + | // Nothing to delete, and never the remote's own bookmark. |
|
| 5043 | + | assert!(repo.push_delete("origin", "nope").is_err()); |
|
| 5044 | + | let res = repo.push_delete("origin", "main").unwrap(); |
|
| 5045 | + | assert!( |
|
| 5046 | + | !res[0].ok && res[0].reason.contains("default bookmark"), |
|
| 5047 | + | "{res:?}" |
|
| 5048 | + | ); |
|
| 5049 | + | assert!(origin.view().unwrap().bookmarks.contains_key("main")); |
|
| 4989 | 5050 | } |
modified crates/oxy-server/src/lib.rs +21 −1
| … | 62 unchanged lines |
||
| 63 | 63 | /// (accepted or refused), the states each accepted reference moved |
|
| 64 | 64 | /// between, and the changes stored, for logs and cache invalidation. |
|
| 65 | 65 | fn after_push(&self, _name: &str, _report: &oxy_core::repo::PushReport, _headers: &HeaderMap) {} |
|
| 66 | + | ||
| 67 | + | /// The bookmark of `name` that pushes may never delete (its default), |
|
| 68 | + | /// when the host keeps one apart from the repository's own config. |
|
| 69 | + | fn default_bookmark(&self, _name: &str) -> Option<String> { |
|
| 70 | + | None |
|
| 71 | + | } |
|
| 66 | 72 | } |
|
| 67 | 73 | ||
| 68 | 74 | /// A protocol server over any [`Backend`], serialising the pushes to each |
|
| … | 204 unchanged lines |
||
| 273 | 279 | )) |
|
| 274 | 280 | } |
|
| 275 | 281 | } |
|
| 282 | + | } |
|
| 283 | + | let protected = self.backend.default_bookmark(name); |
|
| 284 | + | let (refused, updates): (Vec<_>, Vec<_>) = updates.into_iter().partition(|u| { |
|
| 285 | + | u.new_state.is_zero() && protected.as_deref() == Some(u.name.as_str()) |
|
| 286 | + | }); |
|
| 287 | + | let mut report = r.receive_push_report(bundle, updates, policy, tags)?; |
|
| 288 | + | for u in refused { |
|
| 289 | + | report.results.push(oxy_core::protocol::RefResult { |
|
| 290 | + | name: u.name.clone(), |
|
| 291 | + | ok: false, |
|
| 292 | + | reason: format!( |
|
| 293 | + | "{:?} is this repository's default bookmark and cannot be deleted", |
|
| 294 | + | u.name |
|
| 295 | + | ), |
|
| 296 | + | }); |
|
| 276 | 297 | } |
|
| 277 | - | let report = r.receive_push_report(bundle, updates, policy, tags)?; |
|
| 278 | 298 | self.backend.after_push(name, &report, headers); |
|
| 279 | 299 | ok(wire::encode_push_response(&report.results)) |
|
| 280 | 300 | } |
|
| … | 111 unchanged lines |
modified docs/backlog-overnight-2.md +2 −0
| … | 65 unchanged lines |
||
| 66 | 66 | - [x] O10e Checkpoint payload version 2: large file graphs as shared blobs named from an index, read and decoded in parallel; readers accept both versions (spec §11). Agreed by Andy on 2026-09-28. |
|
| 67 | 67 | - [x] M1 (metalgear feedback 1, reported by Andy) Bookmarks with `/` in their names could not be pushed: attestation and checkpoint files are now flat, escaped names (oxygen#12). The hub is pinned to the fix and deployed. |
|
| 68 | 68 | - [x] M2 Flow on/off per repository in the hub (Andy's request; feedback 10): a switch in repository settings for site admins, with hub.toml's `[flow] repos` as the default; no restart. |
|
| 69 | + | - [x] M11 (feedback 12, Andy: urgent) Flow runs numbered per repository, not by the hub-wide id; existing runs renumbered and their check links rewritten by migration 0019 (oxygenhub#23). |
|
| 70 | + | - [ ] M12 (Andy) Delete a bookmark on a remote: `oxy push REMOTE --delete NAME` (a signed, attested deletion the server checks like any update; never the default bookmark), and a delete button on the hub's bookmarks page for writers. |
|
| 69 | 71 | - [ ] M3 Start a run without moving a bookmark (feedback 9): "Run pipeline" on a state and "Re-run" on a finished run, in the web and the API. |
|
| 70 | 72 | - [ ] M4 Guest `/dev/fd`, `/dev/stdin|stdout|stderr` links in flow-init (feedback 15), and job variables `FLOW_STATE`, `FLOW_HEADS`, `FLOW_BOOKMARK`, `FLOW_RUN`, `FLOW_REPO` (feedback 17). |
|
| 71 | 73 | - [ ] M5 Sandbox-level `env` in pipeline.kdl, and the image's environment kept through `flow rootfs import` (feedback 16). |
|
| … | 392 unchanged lines |
modified docs/spec.md +4 −2
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.8k | |
|
| 5 | + | | **Status** | Draft 0.8l | |
|
| 6 | 6 | | **Format version** | 1 | |
|
| 7 | 7 | | **Date** | 2026-09-26 | |
|
| 8 | 8 | | **Audience** | Implementers, security reviewers, and contributors | |
|
| … | 733 unchanged lines |
||
| 742 | 742 | ### 16.4 Push validation |
|
| 743 | 743 | ||
| 744 | 744 | On push, the server MUST, in order: verify that every change has a valid signature or seal from a key embedded in policy and satisfies the author-or-delegate rule (§14.4), verify the attestation for each reference, check that `old_state` matches its current state (compare-and-swap), check policy rules for the reference including `allow_conflicts`, check that every dependency of every change is either included or already stored, and only then update references atomically. A failure rejects the whole push. |
|
| 745 | + | ||
| 746 | + | **Deleting a reference.** An update whose `new_state` is the zero ObjectRef deletes the bookmark (`oxy push REMOTE --delete NAME`). The bookmark MUST exist at `old_state`, and a server MUST refuse to delete its default bookmark. The update is attested like any other: under a policy the attestation is required and its key MUST be authorized for the reference. The deletion's attestation stays the reference's latest, so an older attestation cannot be replayed to bring the bookmark back. The first attestation of a reference the remote does not list takes the client's clock in milliseconds as its serial, which is past the serial of any deleted namesake; later ones increment it. Changes of a deleted bookmark stay until unreachable (§21). |
|
| 745 | 747 | ||
| 746 | 748 | ## 17. Partial, sparse, and shallow repositories |
|
| 747 | 749 | ||
| … | 194 unchanged lines |
||
| 942 | 944 | ||
| 943 | 945 | --- |
|
| 944 | 946 | ||
| 945 | - | *Changelog.* Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11). |
|
| 947 | + | *Changelog.* Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11). |
|
| 946 | 948 | ||
| 947 | 949 | Draft 0.8i (2026-09-28): portability rules for exec bits and path names (§7.7); found by verifying a real Git import commit by commit: deletions depend on the inserters of the lines right after them, and replacements on the change that set their old blob (§8.1); replace records are superseded causally, so a reverted binary keeps its blob (§9.3); merges resolve opaque conflicts to the commit's blob, and `oxy git verify` (§23). Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6); the operation head moves by compare-and-swap and concurrent operations are merged (§12.1); `oxy doctor` (§21.2). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 948 | 950 | ||
| … | 61 unchanged lines |
modified src/cli/mod.rs +9 −1
| … | 346 unchanged lines |
||
| 347 | 347 | /// Push this bookmark instead of the workspace's current one |
|
| 348 | 348 | #[arg(short = 'b', long)] |
|
| 349 | 349 | bookmark: Option<String>, |
|
| 350 | + | /// Delete this bookmark on the remote (never its default bookmark); |
|
| 351 | + | /// the local bookmark is kept |
|
| 352 | + | #[arg(long, value_name = "NAME", conflicts_with_all = ["bookmark", "force"])] |
|
| 353 | + | delete: Option<String>, |
|
| 350 | 354 | /// Machine-readable output: one JSON document on stdout |
|
| 351 | 355 | #[arg(long)] |
|
| 352 | 356 | json: bool, |
|
| … | 598 unchanged lines |
||
| 951 | 955 | remote, |
|
| 952 | 956 | force, |
|
| 953 | 957 | bookmark, |
|
| 958 | + | delete, |
|
| 954 | 959 | json, |
|
| 955 | - | } => sync::push(remote.as_deref(), force, bookmark.as_deref(), json), |
|
| 960 | + | } => match delete { |
|
| 961 | + | Some(name) => sync::push_delete(remote.as_deref(), &name, json), |
|
| 962 | + | None => sync::push(remote.as_deref(), force, bookmark.as_deref(), json), |
|
| 963 | + | }, |
|
| 956 | 964 | Cmd::Serve { |
|
| 957 | 965 | root, |
|
| 958 | 966 | bind, |
|
| … | 115 unchanged lines |
modified src/cli/sync.rs +44 −0
| … | 241 unchanged lines |
||
| 242 | 242 | Ok(0) |
|
| 243 | 243 | } |
|
| 244 | 244 | ||
| 245 | + | /// `oxy push [REMOTE] --delete NAME`. |
|
| 246 | + | pub fn push_delete(remote: Option<&str>, name: &str, as_json: bool) -> Result<i32> { |
|
| 247 | + | let repo = open()?; |
|
| 248 | + | let remote = remote_name(remote); |
|
| 249 | + | let sp = ui::spinner(&format!("Deleting {remote}/{name}…")); |
|
| 250 | + | let results = repo.push_delete(&remote, name)?; |
|
| 251 | + | sp.clear(); |
|
| 252 | + | if as_json { |
|
| 253 | + | println!( |
|
| 254 | + | "{}", |
|
| 255 | + | json::arr(results.iter().map(|r| { |
|
| 256 | + | json::obj([ |
|
| 257 | + | ("name", json::s(&r.name)), |
|
| 258 | + | ("ok", json::b(r.ok)), |
|
| 259 | + | ("reason", json::s(&r.reason)), |
|
| 260 | + | ("state", json::s("")), |
|
| 261 | + | ]) |
|
| 262 | + | })) |
|
| 263 | + | ); |
|
| 264 | + | return Ok(if results.iter().all(|r| r.ok) { 0 } else { 1 }); |
|
| 265 | + | } |
|
| 266 | + | let mut code = 0; |
|
| 267 | + | for r in results { |
|
| 268 | + | if r.ok { |
|
| 269 | + | println!( |
|
| 270 | + | "{} {}", |
|
| 271 | + | ui::done("Deleted"), |
|
| 272 | + | ui::id(format!("{remote}/{}", r.name)) |
|
| 273 | + | ); |
|
| 274 | + | } else { |
|
| 275 | + | println!( |
|
| 276 | + | "{} {} {}{}{}", |
|
| 277 | + | ui::err(ui::glyph("✖", "x")), |
|
| 278 | + | ui::err("Rejected"), |
|
| 279 | + | ui::id(format!("{remote}/{}", r.name)), |
|
| 280 | + | ui::sep(), |
|
| 281 | + | r.reason |
|
| 282 | + | ); |
|
| 283 | + | code = 1; |
|
| 284 | + | } |
|
| 285 | + | } |
|
| 286 | + | Ok(code) |
|
| 287 | + | } |
|
| 288 | + | ||
| 245 | 289 | pub fn push( |
|
| 246 | 290 | remote: Option<&str>, |
|
| 247 | 291 | force: bool, |
|
| … | 411 unchanged lines |