OXYGENHUB

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