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 |