OXYGENHUB

Revsets: trust predicates, and -r on show, verify and abandon 2c9ddac3c387

signed_by(K) (a key prefix, a name, an email, or me, the signer being
the author or a delegate), delegated(), unsigned(), revoked(),
attested(tag|receipt|ref) and built(JOB): the closure of every state
a tag names, a runner reported on with a valid receipt for that job
from a key not revoked, or a remote attested as its tracking
bookmarks hold. Authentication is checked once per change and only
over the set the predicate is intersected with, so
release..main & unsigned() verifies what is about to ship and nothing
else.

oxy show -r prints each change (a JSON array with --json), oxy verify
-r checks the set, and oxy abandon -r lists what it selects, refuses
an empty set, asks for --yes above five, and drops the newest first,
finding each later member again by its ChangeId since dropping a
change rewrites what depended on it.

refs #65
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 03:13 UTC
Signature
✔ signed by Andy McCall
Hash
2c9ddac3c3875bfcd955f3007c6fcfab2628c36858dc2ebaf00ee5f5078432de
Depends on
1c2bd9abdd4f 23b217d75848 47ce8b8e2d4b 50a046e439e9 614541c3cebd 631fc8970bd0 6706bdcfb63e 6a81dbab471d 75167672ebcd 7aa2dc9369e3 83d61e803ccd 8ad9dfe16303 94084087a876 982864068a59 98a1620a3840 c6aa5bd9d1c8 cc7a00565161 d42369abf180 d82e44097323 fce44ae9ad29 ff0af1ee299d
Depended on by
157d02e95586 87322662f607 69953b507188 0299076f09a2 bf3a67819b59 614f55e14eee 5cde7882d767 543bbf64d127
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.10.0 v0.11.0 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
40 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified crates/oxy-core/src/repo/revset.rs +145 −8
…
32 unchanged lines
33 33
    all_loaded: bool,
34 34
    dependents: Option<HashMap<ChangeHash, Vec<ChangeHash>>>,
35 35
    names: HashMap<KeyId, Option<(String, String)>>,
36 +
    /// Who authenticated each change, once asked (signature checks cost).
37 +
    auth: HashMap<ChangeHash, SignatureStatus>,
36 38
    deadline: Instant,
37 39
    budget: Duration,
38 40
    now_ms: u64,
…
341 unchanged lines
380 382
            "author" => {
381 383
                self.arity(name, args, 1)?;
382 384
                let who = self.text_arg(name, args, 0)?.to_string();
383 -
                let me = self.repo.config.author_key;
384 -
                let needle = who.to_lowercase();
385 385
                let mut matches: HashMap<KeyId, bool> = HashMap::new();
386 386
                self.filter_metas_with(within, |ev, _, m| {
387 387
                    if let Some(hit) = matches.get(&m.author) {
388 388
                        return *hit;
389 389
                    }
390 -
                    let hit = (who == "me" && Some(m.author) == me)
391 -
                        || m.author.starts_with_hex(&who)
392 -
                        || ev.who(&m.author).is_some_and(|(n, e)| {
393 -
                            n.to_lowercase().contains(&needle) || e.to_lowercase().contains(&needle)
394 -
                        });
390 +
                    let hit = ev.names_key(&who, &m.author);
395 391
                    matches.insert(m.author, hit);
396 392
                    hit
397 393
                })
…
34 unchanged lines
432 428
                let glob = self.text_arg(name, args, 0)?.to_string();
433 429
                self.touches(within, &glob)
434 430
            }
431 +
            // Trust (§2.4): what authenticated a change, and what vouched
432 +
            // for the states it is in.
433 +
            "signed_by" => {
434 +
                self.arity(name, args, 1)?;
435 +
                let who = self.text_arg(name, args, 0)?.to_string();
436 +
                self.filter_metas_with(within, |ev, h, m| match ev.auth(h, m) {
437 +
                    SignatureStatus::Valid { key, .. } => ev.names_key(&who, &key),
438 +
                    _ => false,
439 +
                })
440 +
            }
441 +
            "delegated" => {
442 +
                self.arity(name, args, 0)?;
443 +
                self.filter_metas_with(within, |ev, h, m| {
444 +
                    !m.author.is_zero()
445 +
                        && matches!(ev.auth(h, m), SignatureStatus::Valid { key, .. } if key != m.author)
446 +
                })
447 +
            }
448 +
            "unsigned" => {
449 +
                self.arity(name, args, 0)?;
450 +
                self.filter_metas_with(within, |ev, h, m| {
451 +
                    m.kind != ChangeKind::WorkingCopy
452 +
                        && !matches!(ev.auth(h, m), SignatureStatus::Valid { .. })
453 +
                })
454 +
            }
455 +
            "revoked" => {
456 +
                self.arity(name, args, 0)?;
457 +
                self.filter_metas_with(within, |ev, h, m| {
458 +
                    matches!(ev.auth(h, m), SignatureStatus::Valid { revoked: true, .. })
459 +
                })
460 +
            }
461 +
            "attested" => {
462 +
                self.arity(name, args, 1)?;
463 +
                let kind = self.text_arg(name, args, 0)?.to_string();
464 +
                self.attested(within, &kind)
465 +
            }
466 +
            "built" => {
467 +
                self.arity(name, args, 1)?;
468 +
                let job = self.text_arg(name, args, 0)?.to_string();
469 +
                self.built(within, &job)
470 +
            }
435 471
            other => {
436 472
                // A saved revset: `oxy config revset.NAME 'EXPR'` makes NAME().
437 473
                if let Some(saved) = self.repo.config.revsets.get(other).cloned() {
…
11 unchanged lines
449 485
                Err(err(format!(
450 486
                    "unknown function {other}(); see docs/revset_query_language.md"
451 487
                )))
488 +
            }
489 +
        }
490 +
    }
491 +
492 +
    /// The authentication of a change: its signature or seal, checked once.
493 +
    fn auth(&mut self, h: &ChangeHash, m: &ChangeMeta) -> SignatureStatus {
494 +
        if let Some(s) = self.auth.get(h) {
495 +
            return s.clone();
496 +
        }
497 +
        let s = self
498 +
            .repo
499 +
            .authenticate_change_known(h, Some(m))
500 +
            .unwrap_or_else(|e| SignatureStatus::Invalid(e.to_string()));
501 +
        self.auth.insert(*h, s.clone());
502 +
        s
503 +
    }
504 +
505 +
    /// Whether `who` names key `key`: by hex prefix, `me`, or the name or
506 +
    /// email of its identity.
507 +
    fn names_key(&mut self, who: &str, key: &KeyId) -> bool {
508 +
        (who == "me" && Some(*key) == self.repo.config.author_key)
509 +
            || key.starts_with_hex(who)
510 +
            || self.who(key).is_some_and(|(n, e)| {
511 +
                let needle = who.to_lowercase();
512 +
                n.to_lowercase().contains(&needle) || e.to_lowercase().contains(&needle)
513 +
            })
514 +
    }
515 +
516 +
    /// The closure of the states whose heads these are, within `within`.
517 +
    fn closure_of(&mut self, mut heads: Vec<ChangeHash>, within: Option<&Set>) -> Result<Set> {
518 +
        heads.sort();
519 +
        heads.dedup();
520 +
        self.ensure(&heads)?;
521 +
        let set: Set = heads.into_iter().collect();
522 +
        let closure = self.ancestors(&set)?;
523 +
        Ok(match within {
524 +
            Some(w) => closure.intersection(w).copied().collect(),
525 +
            None => closure,
526 +
        })
527 +
    }
528 +
529 +
    /// The closure of every state that carries an attestation of `kind`:
530 +
    /// `tag` (tagged states), `receipt` (states a runner reported on) or
531 +
    /// `ref` (states a remote attested, as its tracking bookmarks hold).
532 +
    fn attested(&mut self, within: Option<&Set>, kind: &str) -> Result<Set> {
533 +
        let mut heads: Vec<ChangeHash> = Vec::new();
534 +
        match kind {
535 +
            "tag" => {
536 +
                for t in self.repo.tags()? {
537 +
                    heads.extend(t.heads);
538 +
                }
539 +
            }
540 +
            "receipt" => {
541 +
                for st in self.repo.store().receipt_states()? {
542 +
                    if let Ok(s) = self.repo.store().get_state(&st) {
543 +
                        heads.extend(s.heads);
544 +
                    }
545 +
                }
546 +
            }
547 +
            "ref" => {
548 +
                let view = self.repo.view()?;
549 +
                for bookmarks in view.remotes.values() {
550 +
                    for st in bookmarks.values() {
551 +
                        heads.extend(self.repo.store().get_state(st)?.heads);
552 +
                    }
553 +
                }
554 +
            }
555 +
            other => return Err(err(format!("attested({other:?}): use tag, receipt or ref"))),
556 +
        }
557 +
        self.closure_of(heads, within)
558 +
    }
559 +
560 +
    /// The closure of every state with a valid receipt for Flow job `job`
561 +
    /// from a runner whose key is not revoked.
562 +
    fn built(&mut self, within: Option<&Set>, job: &str) -> Result<Set> {
563 +
        let mut heads: Vec<ChangeHash> = Vec::new();
564 +
        for st in self.repo.store().receipt_states()? {
565 +
            let Ok(state) = self.repo.store().get_state(&st) else {
566 +
                continue;
567 +
            };
568 +
            let good = self
569 +
                .repo
570 +
                .receipts(&state.heads)?
571 +
                .into_iter()
572 +
                .any(|r| r.signed.receipt.job_name == job && r.problem.is_none() && !r.revoked);
573 +
            if good {
574 +
                heads.extend(state.heads);
452 575
            }
576 +
            self.tick()?;
453 577
        }
578 +
        self.closure_of(heads, within)
454 579
    }
455 580
456 581
    /// `filter_metas` for a test that needs the evaluator (identity lookups).
…
23 unchanged lines
480 605
        match e {
481 606
            Expr::Call(name, _) => matches!(
482 607
                name.as_str(),
483 -
                "author" | "description" | "date" | "kind" | "touches" | "all"
608 +
                "author"
609 +
                    | "description"
610 +
                    | "date"
611 +
                    | "kind"
612 +
                    | "touches"
613 +
                    | "all"
614 +
                    | "signed_by"
615 +
                    | "delegated"
616 +
                    | "unsigned"
617 +
                    | "revoked"
618 +
                    | "attested"
619 +
                    | "built"
484 620
            ),
485 621
            Expr::Intersection(a, b) => Self::is_predicate(a) || Self::is_predicate(b),
486 622
            Expr::Union(a, b) => Self::is_predicate(a) && Self::is_predicate(b),
…
77 unchanged lines
564 700
            all_loaded: false,
565 701
            dependents: None,
566 702
            names: HashMap::new(),
703 +
            auth: HashMap::new(),
567 704
            deadline: Instant::now() + budget,
568 705
            budget,
569 706
            now_ms: Hlc::now().wall_ms,
…
39 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +22 −0
…
7124 unchanged lines
7125 7125
    // The log entries carry the bookmark's heads and the working copy.
7126 7126
    let entries = repo.log_revset("::@").unwrap();
7127 7127
    assert!(entries[0].is_working_copy && entries.iter().any(|e| e.is_head && e.hash == d));
7128 +
    // Trust predicates (§2.4): nothing is signed yet (core.sign = seal, no
7129 +
    // push); then a signed change, a tag, and a revocation.
7130 +
    assert_eq!(set("unsigned() & ::main"), sorted(&[a, b, c, d]));
7131 +
    assert_eq!(set("signed_by(me)"), vec![]);
7132 +
    repo.sign_change(&d).unwrap();
7133 +
    assert_eq!(set("signed_by(me)"), vec![d]);
7134 +
    assert_eq!(set("signed_by([email protected])"), vec![d]);
7135 +
    assert_eq!(set(&format!("signed_by({})", key.short(12))), vec![d]);
7136 +
    assert_eq!(set("unsigned() & ::main"), sorted(&[a, b, c]));
7137 +
    assert_eq!(set("delegated()"), vec![]);
7138 +
    assert_eq!(set("revoked()"), vec![]);
7139 +
    assert_eq!(set("attested(tag)"), vec![]);
7140 +
    repo.tag_create("v1", &[b], "first").unwrap();
7141 +
    assert_eq!(set("attested(tag)"), sorted(&[a, b]));
7142 +
    assert_eq!(set("attested(tag) & unsigned()"), sorted(&[a, b]));
7143 +
    assert_eq!(set("attested(receipt)"), vec![]);
7144 +
    assert_eq!(set("built(test)"), vec![]);
7145 +
    let e = repo.revset("attested(seal)").unwrap_err().to_string();
7146 +
    assert!(e.contains("tag, receipt or ref"), "{e}");
7147 +
    repo.revoke_identity(&key, "test").unwrap();
7148 +
    assert_eq!(set("revoked()"), vec![d]);
7149 +
    assert_eq!(set("signed_by(me) ~ revoked()"), vec![]);
7128 7150
}
modified docs/revset_query_language.md +15 −12
…
23 unchanged lines
24 24
Oxygen also has things no other system can query: signatures, delegates,
25 25
attestations and build receipts. Section 2.4 adds those.
26 26
27 -
**Implemented (2026-10-04, oxygen#65):** `oxy log -r REVSET` with the
28 -
symbols, relations, operators, `author`, `description` (substring),
29 -
`date`, `kind`, `touches` and `latest` below, saved revsets, and a
30 -
time budget. Not yet: `independent()`, `conflicts()`, `description(re:…)`,
31 -
the trust predicates of §2.4, `-r` on other commands, and the hub's
32 -
`?r=`. Other commands still take a single change (a ChangeId prefix, a
33 -
`#hash` prefix or `@`) or a bookmark or tag name; symbols in a revset
34 -
resolve exactly as they do there.
27 +
**Implemented (2026-10-04, oxygen#65):** `oxy log -r`, `oxy show -r`,
28 +
`oxy verify -r` and `oxy abandon -r` with the symbols, relations,
29 +
operators, `author`, `description` (substring), `date`, `kind`,
30 +
`touches`, `latest` and the trust predicates of §2.4 below, saved
31 +
revsets, and a time budget. Not yet: `independent()`, `conflicts()`,
32 +
`description(re:…)`, `diff -r`, `push -r`, `fold`, and the hub's `?r=`.
33 +
Other commands take a single change (a ChangeId prefix, a `#hash`
34 +
prefix or `@`) or a bookmark or tag name; symbols in a revset resolve
35 +
exactly as they do there.
35 36
36 37
## 2. Syntax
37 38
…
47 unchanged lines
85 86
86 87
| Expression | Selects |
87 88
|---|---|
88 -
| `signed_by(K)` | changes whose signature or seal is by key or identity K, including a delegate such as an agent key |
89 +
| `signed_by(K)` | changes whose signature or seal is by key or identity K (a key prefix, a name, an email, or `me`), including a delegate such as an agent key |
89 90
| `delegated()` | changes signed by a delegate rather than their author |
90 91
| `unsigned()` | changes with no valid signature or seal |
91 92
| `revoked()` | changes signed by a key now revoked |
92 -
| `attested(KIND)` | changes in states carrying an attestation of that kind, such as `tag` or `receipt` |
93 +
| `attested(KIND)` | changes in states carrying an attestation of that kind: `tag`, `receipt`, or `ref` (states a remote attested, as its tracking bookmarks hold them) |
93 94
| `built(JOB)` | changes in states with a valid receipt for Flow job JOB |
94 95
95 96
For example, `release..main & (unsigned() | revoked())` asks: "is
…
20 unchanged lines
116 117
- **Commands that act on changes** take `-r` too: `abandon`, the agent
117 118
  workflow's `fold`, and `push -r` (push only these heads). Evaluating
118 119
  the revset never changes anything. A command that acts prints the
119 -
  selected set and refuses to act if it is empty. Above a threshold it
120 -
  asks first, or proceeds with `--yes`.
120 +
  selected set and refuses to act if it is empty. Above a threshold
121 +
  (five, for `abandon`) it refuses unless given `--yes`. `abandon -r`
122 +
  drops the newest first and finds each later member again by its
123 +
  ChangeId, since dropping a change rewrites what depended on it.
121 124
- **The hub API** takes `?r=` on history endpoints, evaluated only over
122 125
  what the caller can read.
123 126
- **Saved revsets:** `oxy config revset.mine 'author(me) & ::@'` lets
…
36 unchanged lines
modified docs/spec.md +3 −3
…
873 unchanged lines
874 874
oxy init [PATH] [--bare]            oxy clone URL [--sparse PATH].. [--depth N] [--max-blob SIZE]
875 875
oxy status [--json]                 oxy diff                     oxy prompt [--format F]
876 876
oxy commit -m MSG [--depends-on CHANGE]..
877 -
oxy log [-n N] [-v] [-L S,E:PATH]   oxy show CHANGE
877 +
oxy log [-r REVSET] [-n N] [-v] [-L S,E:PATH]   oxy show CHANGE | -r REVSET
878 878
oxy describe [CHANGE] [-m MSG]      oxy amend [-m MSG] [--into CHANGE]
879 -
oxy split CHANGE [PATH]..           oxy abandon CHANGE           oxy resolve
879 +
oxy split CHANGE [PATH]..           oxy abandon CHANGE | -r REVSET [--yes]   oxy resolve
880 880
oxy checkout [CHANGE].. [--discard|--carry] oxy undo                     oxy op log
881 881
oxy tag create NAME [CHANGE].. [-m MSG]   oxy tag list
882 882
oxy bookmark create|set|delete|list|switch  oxy merge BOOKMARK
…
3 unchanged lines
886 886
oxy fetch [REMOTE]                  oxy pull [REMOTE]            oxy push [REMOTE] [--force]
887 887
oxy remote add|remove|list          oxy serve                    oxy lock|unlock PATH
888 888
oxy identity create|show|export|rotate|renew|revoke|delegate|sign-challenge
889 -
oxy policy init|show|grant|revoke|root|sign                      oxy verify [CHANGE]
889 +
oxy policy init|show|grant|revoke|root|sign                      oxy verify [CHANGE | -r REVSET]
890 890
oxy seal [CHANGE].. | --proposal URL  seal your unauthenticated and anonymous changes (§14.4), or a hub proposal's changes as your approval
891 891
oxy proposal show|land URL          hub proposals from a client (hub spec §7)
892 892
oxy git import|export|fetch|push|verify   (fetch and push go through a bare mirror under .oxy/git/)
…
153 unchanged lines
modified src/cli/identity.rs +12 −4
…
250 unchanged lines
251 251
    Ok(0)
252 252
}
253 253
254 -
pub fn verify(change: Option<&str>, as_json: bool) -> Result<i32> {
254 +
pub fn verify(change: Option<&str>, revset: Option<&str>, as_json: bool) -> Result<i32> {
255 255
    let repo = open()?;
256 -
    let hashes: Vec<ChangeHash> = match change {
257 -
        Some(c) => vec![repo.resolve_change(c)?],
258 -
        None => repo
256 +
    let hashes: Vec<ChangeHash> = match (change, revset) {
257 +
        (_, Some(r)) => {
258 +
            let wc = repo.working_copy().ok().map(|(h, _)| h);
259 +
            repo.revset(r)?
260 +
                .hashes
261 +
                .into_iter()
262 +
                .filter(|h| Some(*h) != wc)
263 +
                .collect()
264 +
        }
265 +
        (Some(c), None) => vec![repo.resolve_change(c)?],
266 +
        (None, None) => repo
259 267
            .log()?
260 268
            .into_iter()
261 269
            .filter(|e| !e.is_working_copy)
…
511 unchanged lines
modified src/cli/mod.rs +34 −8
…
244 unchanged lines
245 245
        /// Paths whose edits become the first change
246 246
        paths: Vec<String>,
247 247
    },
248 -
    /// Drop a head from the bookmark; its content stays on disk as uncommitted work
248 +
    /// Drop a change from the bookmark; its content stays on disk as uncommitted work
249 249
    Abandon {
250 -
        /// The head to drop (ChangeId prefix, #hash prefix)
251 -
        change: String,
250 +
        /// The change to drop (ChangeId prefix, #hash prefix)
251 +
        #[arg(required_unless_present = "revset")]
252 +
        change: Option<String>,
253 +
        /// Drop every change a revset selects, newest first
254 +
        #[arg(short = 'r', long, value_name = "REVSET", conflicts_with = "change")]
255 +
        revset: Option<String>,
256 +
        /// Drop more than five changes without asking
257 +
        #[arg(long, short = 'y')]
258 +
        yes: bool,
252 259
    },
253 -
    /// Show one change (ChangeId prefix, #hash prefix, or @)
260 +
    /// Show one change (ChangeId prefix, #hash prefix, or @), or those a revset selects
254 261
    Show {
255 262
        /// ChangeId prefix, #hash prefix, or @ for the working copy
256 -
        change: String,
263 +
        #[arg(required_unless_present = "revset")]
264 +
        change: Option<String>,
265 +
        /// Show every change a revset selects
266 +
        #[arg(short = 'r', long, value_name = "REVSET", conflicts_with = "change")]
267 +
        revset: Option<String>,
257 268
        /// Machine-readable output: one JSON document on stdout
258 269
        #[arg(long)]
259 270
        json: bool,
…
185 unchanged lines
445 456
    Verify {
446 457
        /// The change to verify (default: every change in the log)
447 458
        change: Option<String>,
459 +
        /// Verify every change a revset selects: `release..main & unsigned()`
460 +
        #[arg(short = 'r', long, value_name = "REVSET", conflicts_with = "change")]
461 +
        revset: Option<String>,
448 462
        /// Machine-readable output: one JSON document on stdout
449 463
        #[arg(long)]
450 464
        json: bool,
…
642 unchanged lines
1093 1107
        },
1094 1108
        Cmd::Amend { message, into } => workspace::amend(message.as_deref(), into.as_deref()),
1095 1109
        Cmd::Split { change, paths } => workspace::split(&change, &paths),
1096 -
        Cmd::Abandon { change } => workspace::abandon(&change),
1097 -
        Cmd::Show { change, json } => workspace::show(&change, json),
1110 +
        Cmd::Abandon {
1111 +
            change,
1112 +
            revset,
1113 +
            yes,
1114 +
        } => workspace::abandon(change.as_deref(), revset.as_deref(), yes),
1115 +
        Cmd::Show {
1116 +
            change,
1117 +
            revset,
1118 +
            json,
1119 +
        } => workspace::show(change.as_deref(), revset.as_deref(), json),
1098 1120
        Cmd::Checkout {
1099 1121
            changes,
1100 1122
            discard,
…
184 unchanged lines
1285 1307
            ProposalCmd::Show { url, json } => proposal::show(&url, json),
1286 1308
            ProposalCmd::Land { url, force } => proposal::land(&url, force),
1287 1309
        },
1288 -
        Cmd::Verify { change, json } => identity::verify(change.as_deref(), json),
1310 +
        Cmd::Verify {
1311 +
            change,
1312 +
            revset,
1313 +
            json,
1314 +
        } => identity::verify(change.as_deref(), revset.as_deref(), json),
1289 1315
        Cmd::Config { key, value, user } => identity::config(&key, &value, user),
1290 1316
    }
1291 1317
}
…
38 unchanged lines
modified src/cli/workspace.rs +102 −10
…
1032 unchanged lines
1033 1033
    Ok(0)
1034 1034
}
1035 1035
1036 -
pub fn show(change: &str, as_json: bool) -> Result<i32> {
1036 +
pub fn show(change: Option<&str>, revset: Option<&str>, as_json: bool) -> Result<i32> {
1037 1037
    let repo = open()?;
1038 1038
    repo.snapshot_status("oxy show")?;
1039 -
    let h = repo.resolve_change(change)?;
1039 +
    let Some(r) = revset else {
1040 +
        let h = repo.resolve_change(change.unwrap_or("@"))?;
1041 +
        return show_one(&repo, h, as_json, false);
1042 +
    };
1043 +
    // Several changes: a JSON array, or the changes one after another.
1044 +
    let hashes = repo.revset(r)?.hashes;
1045 +
    if as_json {
1046 +
        print!("[");
1047 +
        for (i, h) in hashes.iter().enumerate() {
1048 +
            if i > 0 {
1049 +
                print!(",");
1050 +
            }
1051 +
            show_one(&repo, *h, true, true)?;
1052 +
        }
1053 +
        println!("]");
1054 +
        return Ok(0);
1055 +
    }
1056 +
    if hashes.is_empty() {
1057 +
        println!("{}", ui::dim(format!("No change matches {r:?}.")));
1058 +
        return Ok(0);
1059 +
    }
1060 +
    for (i, h) in hashes.iter().enumerate() {
1061 +
        if i > 0 {
1062 +
            println!();
1063 +
        }
1064 +
        show_one(&repo, *h, false, false)?;
1065 +
    }
1066 +
    Ok(0)
1067 +
}
1068 +
1069 +
/// One change, as `oxy show` prints it; `inline` JSON goes without its
1070 +
/// newline, for an array.
1071 +
fn show_one(repo: &Repo, h: ChangeHash, as_json: bool, inline: bool) -> Result<i32> {
1040 1072
    let c = repo.store().get_change(&h)?;
1041 1073
    if as_json {
1042 1074
        let files = repo.diff_files(Some(&h))?;
…
4 unchanged lines
1047 1079
            is_working_copy: c.kind == oxy_core::objects::ChangeKind::WorkingCopy,
1048 1080
            is_head: heads.contains(&h),
1049 1081
        };
1050 -
        let mut body = log_entry_json(&repo, &entry)?;
1082 +
        let mut body = log_entry_json(repo, &entry)?;
1051 1083
        body.pop();
1052 1084
        body.push_str(&format!(
1053 1085
            ",{}:{}}}",
…
4 unchanged lines
1058 1090
                ("from", json::opt(f.from.as_deref(), json::s))
1059 1091
            ])))
1060 1092
        ));
1061 -
        println!("{body}");
1093 +
        if inline {
1094 +
            print!("{body}");
1095 +
        } else {
1096 +
            println!("{body}");
1097 +
        }
1062 1098
        return Ok(0);
1063 1099
    }
1064 1100
    println!(
…
19 unchanged lines
1084 1120
        "{}",
1085 1121
        ui::kv(
1086 1122
            "author",
1087 -
            ui::author(author_name(&repo, &oxy_core::state::ChangeMeta::of(&c)))
1123 +
            ui::author(author_name(repo, &oxy_core::state::ChangeMeta::of(&c)))
1088 1124
        )
1089 1125
    );
1090 1126
    println!(
…
8 unchanged lines
1099 1135
        )
1100 1136
    );
1101 1137
    println!("{}", ui::kv("deps", ui::dim(deps_line(&c))));
1102 -
    println!("{}", ui::kv("sig", signature_line(&repo, &h)?));
1138 +
    println!("{}", ui::kv("sig", signature_line(repo, &h)?));
1103 1139
    println!();
1104 1140
    for line in (if c.message.is_empty() {
1105 1141
        "(no description)"
…
649 unchanged lines
1755 1791
    Ok(0)
1756 1792
}
1757 1793
1758 -
pub fn abandon(change: &str) -> Result<i32> {
1794 +
pub fn abandon(change: Option<&str>, revset: Option<&str>, yes: bool) -> Result<i32> {
1759 1795
    let repo = open()?;
1760 -
    let h = repo.resolve_change(change)?;
1796 +
    let Some(r) = revset else {
1797 +
        let h = repo.resolve_change(change.unwrap_or_default())?;
1798 +
        abandon_one(&repo, h)?;
1799 +
        where_now(&repo);
1800 +
        return Ok(0);
1801 +
    };
1802 +
    // A revset: say what it selects, refuse an empty set, ask above a few.
1803 +
    let selected = repo.revset(r)?;
1804 +
    let wc = repo.working_copy().ok().map(|(h, _)| h);
1805 +
    let hashes: Vec<ChangeHash> = selected
1806 +
        .hashes
1807 +
        .iter()
1808 +
        .copied()
1809 +
        .filter(|h| Some(*h) != wc)
1810 +
        .collect();
1811 +
    if hashes.is_empty() {
1812 +
        anyhow::bail!("{r:?} selects no change to abandon");
1813 +
    }
1814 +
    println!(
1815 +
        "{} {} selects {}:",
1816 +
        ui::section("abandon"),
1817 +
        ui::id(r),
1818 +
        plural(hashes.len(), "change")
1819 +
    );
1820 +
    for h in &hashes {
1821 +
        if let Some(m) = selected.metas.get(h) {
1822 +
            println!(
1823 +
                "  {} {}  {}",
1824 +
                ui::id(m.change_id.short(8)),
1825 +
                ui::hash(format!("#{}", h.short(12))),
1826 +
                subject_of(&m.message)
1827 +
            );
1828 +
        }
1829 +
    }
1830 +
    const ASK_ABOVE: usize = 5;
1831 +
    if hashes.len() > ASK_ABOVE && !yes {
1832 +
        anyhow::bail!("that is more than {ASK_ABOVE} changes; pass --yes to abandon them all");
1833 +
    }
1834 +
    // Newest first: abandoning a change rewrites what depends on it, so a
1835 +
    // later member is found again by its ChangeId.
1836 +
    let mut done = 0;
1837 +
    for h in &hashes {
1838 +
        let Some(m) = selected.metas.get(h) else {
1839 +
            continue;
1840 +
        };
1841 +
        let now = match repo.resolve_change(&m.change_id.to_hex()) {
1842 +
            Ok(h) => h,
1843 +
            Err(_) => continue,
1844 +
        };
1845 +
        abandon_one(&repo, now)?;
1846 +
        done += 1;
1847 +
    }
1848 +
    println!("{} {}", ui::done("Abandoned"), plural(done, "change"));
1849 +
    where_now(&repo);
1850 +
    Ok(0)
1851 +
}
1852 +
1853 +
fn abandon_one(repo: &Repo, h: ChangeHash) -> Result<()> {
1761 1854
    let c = repo.store().get_change_meta(&h)?;
1762 1855
    let notes = repo.abandon(&h)?;
1763 1856
    println!(
…
6 unchanged lines
1770 1863
    for n in notes {
1771 1864
        println!("  {} {n}", ui::note());
1772 1865
    }
1773 -
    where_now(&repo);
1774 -
    Ok(0)
1866 +
    Ok(())
1775 1867
}
1776 1868
1777 1869
pub fn checkpoint() -> Result<i32> {
…
666 unchanged lines