rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 heartwood775df96ab824af1dbe9f671f63e7c9d96f669097
{
"request": "trigger",
"version": 1,
"event_type": "patch",
"repository": {
"id": "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
"name": "heartwood",
"description": "Radicle Heartwood Protocol & Stack",
"private": false,
"default_branch": "master",
"delegates": [
"did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT",
"did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz"
]
},
"action": "Created",
"patch": {
"id": "ace2a1f10ab1f044d7d87c0de92a6f5343e1e51a",
"author": {
"id": "did:key:z6MktwkohCx8aHZ1QCjVZUiLmX92oPZFxRiFZkbq32Tk5Tkm",
"alias": "2color"
},
"title": "identity: Select head rule by specificity",
"state": {
"status": "merged",
"conflicts": []
},
"before": "fd4e4903d70ef21477653d1b50900aca2bd9a5a0",
"after": "775df96ab824af1dbe9f671f63e7c9d96f669097",
"commits": [
"775df96ab824af1dbe9f671f63e7c9d96f669097",
"9d9118966854b82a7bcf27929e90d56f019b9f4a"
],
"target": "7e1cb406bb935c3de008d2171e8961f57b4336ea",
"labels": [],
"assignees": [],
"revisions": [
{
"id": "ace2a1f10ab1f044d7d87c0de92a6f5343e1e51a",
"author": {
"id": "did:key:z6MktwkohCx8aHZ1QCjVZUiLmX92oPZFxRiFZkbq32Tk5Tkm",
"alias": "2color"
},
"description": "The canonical refs RIP requires the most specific rule matching the target of\n`HEAD` to use an `allow` of `delegates` and the document threshold.\n`validate_head_rule` took the first match from `RawRules`, which orders\npatterns lexicographically, so a glob could be validated in place of the\nrule that actually governs the branch.\n\n- Refactor the specificity ordering out of `Ord for Pattern` into\n a reusable `cmp_specificity` so logic is shared by both callers\n- Add `RawRules::most_specific_match` and use it to look up the head rule",
"base": "fd4e4903d70ef21477653d1b50900aca2bd9a5a0",
"oid": "1fa6363677c6d138540a5e27329ba78feda9b08f",
"timestamp": 1788187564
},
{
"id": "e3ce84361b8896a6b4bfbe1a08b08667cd62faf6",
"author": {
"id": "did:key:z6MktwkohCx8aHZ1QCjVZUiLmX92oPZFxRiFZkbq32Tk5Tkm",
"alias": "2color"
},
"description": "identity: add test to verify head rule specificity",
"base": "fd4e4903d70ef21477653d1b50900aca2bd9a5a0",
"oid": "c6f78dab680a431e29ff98d6f19ee87cfe8ddab1",
"timestamp": 1788202951
},
{
"id": "04f91a4b98cb9c60b663251f020f16457326102a",
"author": {
"id": "did:key:z6MktwkohCx8aHZ1QCjVZUiLmX92oPZFxRiFZkbq32Tk5Tkm",
"alias": "2color"
},
"description": "reword and refine",
"base": "fd4e4903d70ef21477653d1b50900aca2bd9a5a0",
"oid": "41c812d71e210bf8bd8a9e15454ecfaf18f143be",
"timestamp": 1788278648
},
{
"id": "40ca9cc6cd11c51af748637c89881da4da077726",
"author": {
"id": "did:key:z6MktwkohCx8aHZ1QCjVZUiLmX92oPZFxRiFZkbq32Tk5Tkm",
"alias": "2color"
},
"description": "reword",
"base": "fd4e4903d70ef21477653d1b50900aca2bd9a5a0",
"oid": "608c1b26627a28f2d8605ccc45a41317e91313c0",
"timestamp": 1788279452
},
{
"id": "464e6220886179c49de88c0c2111a37f449b6213",
"author": {
"id": "did:key:z6MktwkohCx8aHZ1QCjVZUiLmX92oPZFxRiFZkbq32Tk5Tkm",
"alias": "2color"
},
"description": "",
"base": "fd4e4903d70ef21477653d1b50900aca2bd9a5a0",
"oid": "775df96ab824af1dbe9f671f63e7c9d96f669097",
"timestamp": 1788280142
},
{
"id": "85f2978563fed168be6cc835efc260deee127756",
"author": {
"id": "did:key:z6MktwkohCx8aHZ1QCjVZUiLmX92oPZFxRiFZkbq32Tk5Tkm",
"alias": "2color"
},
"description": "Another way to look at it\u2026",
"base": "fd4e4903d70ef21477653d1b50900aca2bd9a5a0",
"oid": "c14346d93a6a6cca7097695ad11d996fc689075c",
"timestamp": 1788284638
}
]
}
}
{
"response": "triggered",
"run_id": {
"id": "da0e79d1-e86b-4b32-8bdf-7de652a5f2bc"
},
"info_url": "https://cci.rad.levitte.org//da0e79d1-e86b-4b32-8bdf-7de652a5f2bc.html"
}
Started at: 2026-09-09 02:25:50.355131+02:00
Commands:
$ rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 .
✓ Creating checkout in ./...
✓ Remote cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT added
✓ Remote-tracking branch cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/master created for z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT
✓ Remote fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM added
✓ Remote-tracking branch fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM/master created for z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM
✓ Remote lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz added
✓ Remote-tracking branch lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz/master created for z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz
✓ Repository successfully cloned under /opt/radcis/ci.rad.levitte.org/cci/state/da0e79d1-e86b-4b32-8bdf-7de652a5f2bc/w/
╭────────────────────────────────────╮
│ heartwood │
│ Radicle Heartwood Protocol & Stack │
│ 189 issues · 39 patches │
╰────────────────────────────────────╯
Run `cd ./.` to go to the repository directory.
Exit code: 0
$ rad patch checkout ace2a1f10ab1f044d7d87c0de92a6f5343e1e51a
✓ Switched to branch patch/ace2a1f at revision 464e622
✓ Branch patch/ace2a1f setup to track rad/patches/ace2a1f10ab1f044d7d87c0de92a6f5343e1e51a
Exit code: 0
$ git config advice.detachedHead false
Exit code: 0
$ git checkout 775df96ab824af1dbe9f671f63e7c9d96f669097
HEAD is now at 775df96a crefs: Test to verify head rule specificity
Exit code: 0
$ rad patch show ace2a1f10ab1f044d7d87c0de92a6f5343e1e51a -p
╭───────────────────────────────────────────────────────────────────────────────╮
│ Title identity: Select head rule by specificity │
│ Patch ace2a1f10ab1f044d7d87c0de92a6f5343e1e51a │
│ Author 2color z6Mktwk…2Tk5Tkm │
│ Head 775df96ab824af1dbe9f671f63e7c9d96f669097 │
│ Base fd4e4903d70ef21477653d1b50900aca2bd9a5a0 │
│ Target master │
│ Branches patch/ace2a1f │
│ Commits ahead 2, behind 17 │
│ Status merged │
│ │
│ The canonical refs RIP requires the most specific rule matching the target of │
│ `HEAD` to use an `allow` of `delegates` and the document threshold. │
│ `validate_head_rule` took the first match from `RawRules`, which orders │
│ patterns lexicographically, so a glob could be validated in place of the │
│ rule that actually governs the branch. │
│ │
│ - Refactor the specificity ordering out of `Ord for Pattern` into │
│ a reusable `cmp_specificity` so logic is shared by both callers │
│ - Add `RawRules::most_specific_match` and use it to look up the head rule │
├───────────────────────────────────────────────────────────────────────────────┤
│ 775df96 crefs: Test to verify head rule specificity │
│ 9d91189 identity: Select head rule by specificity │
├───────────────────────────────────────────────────────────────────────────────┤
│ ● Revision ace2a1f @ fd4e490..1fa6363 by 2color z6Mktwk…2Tk5Tkm 1 week ago │
│ ↑ Revision e3ce843 @ fd4e490..c6f78da by 2color z6Mktwk…2Tk5Tkm 1 week ago │
│ ↑ Revision 04f91a4 @ fd4e490..41c812d by 2color z6Mktwk…2Tk5Tkm 1 week ago │
│ ↑ Revision 40ca9cc @ fd4e490..608c1b2 by 2color z6Mktwk…2Tk5Tkm 1 week ago │
│ ↑ Revision 464e622 @ fd4e490..775df96 by 2color z6Mktwk…2Tk5Tkm 1 week ago │
│ ↑ Revision 85f2978 @ fd4e490..c14346d by lorenz z6MkkPv…WX5sTEz 1 week ago │
│ └─ ✓ merged by lorenz z6MkkPv…WX5sTEz │
╰───────────────────────────────────────────────────────────────────────────────╯
commit 775df96ab824af1dbe9f671f63e7c9d96f669097
Author: Daniel Norman <daniel@norman.life>
Date: Mon Aug 31 16:54:53 2026 +0200
crefs: Test to verify head rule specificity
In case of more than one match, pick the match for HEAD
based on specificity rather than lexicographic order
diff --git a/crates/radicle/src/git/canonical/rules/test.rs b/crates/radicle/src/git/canonical/rules/test.rs
index 9931d1facb..586e16680c 100644
--- a/crates/radicle/src/git/canonical/rules/test.rs
+++ b/crates/radicle/src/git/canonical/rules/test.rs
@@ -563,3 +563,51 @@ fn matches_exactly_curly_braces() {
assert!(!matches(&exact, &git::fmt::qualified!("refs/heads/foo")));
assert!(!matches(&exact, &git::fmt::qualified!("refs/heads/bar")));
}
+
+/// `RawRules` orders lexicographically, so its iteration order disagrees with the specificity
+/// rules of canonical references. `most_specific_match` applies that priority.
+#[test]
+fn most_specific_match_ignores_lexicographic_order() {
+ use helper::*;
+
+ let glob = raw_pattern(qualified_pattern!("refs/heads/*"));
+ let nested = raw_pattern(qualified_pattern!("refs/heads/feature/*"));
+ let exact = raw_pattern(qualified_pattern!("refs/heads/main"));
+ let rules = RawRules::from_iter([
+ (glob.clone(), Rule::new(Allowed::Delegates, 1)),
+ (nested.clone(), Rule::new(Allowed::Delegates, 2)),
+ (exact.clone(), Rule::new(Allowed::Delegates, 3)),
+ ]);
+
+ // The glob sorts first (lexicographically), since '*' < 'f' < 'm'.
+ assert_eq!(
+ rules.iter().map(|(pattern, _)| pattern).collect::<Vec<_>>(),
+ [&glob, &nested, &exact]
+ );
+
+ let main = git::fmt::qualified!("refs/heads/main");
+ assert_eq!(
+ rules.matches(&main).next().map(|(pattern, _)| pattern),
+ Some(&glob),
+ "iteration order yields the glob, not the rule that governs the branch"
+ );
+ assert_eq!(
+ rules.most_specific_match(&main).map(|(pattern, _)| pattern),
+ Some(&exact)
+ );
+
+ let feature = git::fmt::qualified!("refs/heads/feature/x");
+ assert_eq!(
+ rules
+ .most_specific_match(&feature)
+ .map(|(pattern, _)| pattern),
+ Some(&nested),
+ "more components beat the glob"
+ );
+
+ assert!(
+ rules
+ .most_specific_match(&git::fmt::qualified!("refs/tags/v1.0"))
+ .is_none()
+ );
+}
commit 9d9118966854b82a7bcf27929e90d56f019b9f4a
Author: Daniel Norman <daniel@norman.life>
Date: Tue Aug 18 17:32:27 2026 +0200
identity: Select head rule by specificity
When an identity document has a symbolic reference rule for HEAD, it is
validated by `validate_head_rule` to ensure that it matches the default
branch in the project payload and threshold in the root of the identity
document.
However, the validation function didn’t validate against the correct
canonical ref rule under certain circumstances. Canonical refs requires
the most specific rule matching the target of `HEAD` to use an `allow`
of `delegates` and the document threshold. `validate_head_rule` took the
first match from `RawRules`, without selecting based on specificity in
case of multiple matches, so the first match in lexicographic order
would be picked, so a glob could be picked instead of the actual rule
that actually governs the branch.
- Abstract specificity ordering out of `Ord for Pattern` into
`cmp_specificity`, so both callers share one definition
- Add `RawRules::most_specific_match` and use it to look up the head
rule
diff --git a/crates/radicle/src/git/canonical/rules.rs b/crates/radicle/src/git/canonical/rules.rs
index 1df5e4e034..66d69f60bd 100644
--- a/crates/radicle/src/git/canonical/rules.rs
+++ b/crates/radicle/src/git/canonical/rules.rs
@@ -119,89 +119,94 @@ fn matches(pattern: &RawPattern, refname: &Qualified) -> bool {
/// refs/heads/* <(3.) refs/tags/*
/// refs/heads/main <(3.) refs/tags/v1.0
/// ```
-impl Ord for Pattern {
- fn cmp(&self, other: &Self) -> Ordering {
- #[derive(Debug, Clone, Copy)]
- #[repr(i8)]
- enum ComponentOrdering {
- MatchLength(Ordering),
- Lexicographic(Ordering),
- }
-
- impl ComponentOrdering {
- fn merge(&mut self, other: Self) {
- *self = match (*self, other) {
- (Self::Lexicographic(Ordering::Equal), Self::Lexicographic(other)) => {
- Self::Lexicographic(other)
- }
- (Self::Lexicographic(_), Self::MatchLength(other)) => Self::MatchLength(other),
- (Self::MatchLength(Ordering::Equal), Self::MatchLength(other)) => {
- Self::MatchLength(other)
- }
- (clone, _) => clone,
+pub(super) fn cmp_specificity(lhs: &RawPattern, rhs: &RawPattern) -> Ordering {
+ #[derive(Debug, Clone, Copy)]
+ #[repr(i8)]
+ enum ComponentOrdering {
+ MatchLength(Ordering),
+ Lexicographic(Ordering),
+ }
+
+ impl ComponentOrdering {
+ fn merge(&mut self, other: Self) {
+ *self = match (*self, other) {
+ (Self::Lexicographic(Ordering::Equal), Self::Lexicographic(other)) => {
+ Self::Lexicographic(other)
+ }
+ (Self::Lexicographic(_), Self::MatchLength(other)) => Self::MatchLength(other),
+ (Self::MatchLength(Ordering::Equal), Self::MatchLength(other)) => {
+ Self::MatchLength(other)
}
+ (clone, _) => clone,
}
}
+ }
- impl From<ComponentOrdering> for Ordering {
- fn from(value: ComponentOrdering) -> Self {
- match value {
- ComponentOrdering::MatchLength(ordering) => ordering,
- ComponentOrdering::Lexicographic(ordering) => ordering,
- }
+ impl From<ComponentOrdering> for Ordering {
+ fn from(value: ComponentOrdering) -> Self {
+ match value {
+ ComponentOrdering::MatchLength(ordering) => ordering,
+ ComponentOrdering::Lexicographic(ordering) => ordering,
}
}
+ }
- impl Default for ComponentOrdering {
- /// The weakest value of Self, which will be absorbed by any
- /// other in [`ComponentOrdering::merge`].
- fn default() -> Self {
- Self::Lexicographic(Ordering::Equal)
- }
+ impl Default for ComponentOrdering {
+ /// The weakest value of Self, which will be absorbed by any
+ /// other in [`ComponentOrdering::merge`].
+ fn default() -> Self {
+ Self::Lexicographic(Ordering::Equal)
}
+ }
- use git::fmt::refspec::Component;
-
- fn cmp_component(lhs: Component<'_>, rhs: Component<'_>) -> ComponentOrdering {
- let (l, r) = (lhs.as_str(), rhs.as_str());
- match (l.find(ASTERISK), r.find(ASTERISK)) {
- // (2.a.)
- (Some(_), None) => ComponentOrdering::MatchLength(Ordering::Greater),
- // (2.a.)
- (None, Some(_)) => ComponentOrdering::MatchLength(Ordering::Less),
- (Some(li), Some(ri)) => {
- if li != ri {
- // (2.b.A)
- ComponentOrdering::MatchLength(li.cmp(&ri).reverse())
- } else if l.len() != r.len() {
- // (2.b.B)
- ComponentOrdering::MatchLength(l.len().cmp(&r.len()).reverse())
- } else {
- // (3.)
- ComponentOrdering::Lexicographic(l.cmp(r))
- }
+ use git::fmt::refspec::Component;
+
+ fn cmp_component(lhs: Component<'_>, rhs: Component<'_>) -> ComponentOrdering {
+ let (l, r) = (lhs.as_str(), rhs.as_str());
+ match (l.find(ASTERISK), r.find(ASTERISK)) {
+ // (2.a.)
+ (Some(_), None) => ComponentOrdering::MatchLength(Ordering::Greater),
+ // (2.a.)
+ (None, Some(_)) => ComponentOrdering::MatchLength(Ordering::Less),
+ (Some(li), Some(ri)) => {
+ if li != ri {
+ // (2.b.A)
+ ComponentOrdering::MatchLength(li.cmp(&ri).reverse())
+ } else if l.len() != r.len() {
+ // (2.b.B)
+ ComponentOrdering::MatchLength(l.len().cmp(&r.len()).reverse())
+ } else {
+ // (3.)
+ ComponentOrdering::Lexicographic(l.cmp(r))
}
- // (3.)
- (None, None) => ComponentOrdering::Lexicographic(l.cmp(r)),
}
+ // (3.)
+ (None, None) => ComponentOrdering::Lexicographic(l.cmp(r)),
}
+ }
- let mut result = ComponentOrdering::default();
- let mut lhs = self.as_ref().components();
- let mut rhs = other.as_ref().components();
- loop {
- match (lhs.next(), rhs.next()) {
- (None, Some(_)) => return Ordering::Greater, // (1.)
- (Some(_), None) => return Ordering::Less, // (1.)
- (Some(lhs), Some(rhs)) => {
- result.merge(cmp_component(lhs, rhs));
- }
- (None, None) => return result.into(),
+ let mut result = ComponentOrdering::default();
+ let mut lhs = lhs.components();
+ let mut rhs = rhs.components();
+ loop {
+ match (lhs.next(), rhs.next()) {
+ (None, Some(_)) => return Ordering::Greater, // (1.)
+ (Some(_), None) => return Ordering::Less, // (1.)
+ (Some(lhs), Some(rhs)) => {
+ result.merge(cmp_component(lhs, rhs));
}
+ (None, None) => return result.into(),
}
}
}
+/// Patterns are ordered by specificity, see [`cmp_specificity`].
+impl Ord for Pattern {
+ fn cmp(&self, other: &Self) -> Ordering {
+ cmp_specificity(self.as_ref(), other.as_ref())
+ }
+}
+
impl PartialOrd for Pattern {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
@@ -297,6 +302,16 @@ impl RawRules {
.iter()
.filter(move |(pattern, _)| matches(pattern, &refname))
}
+
+ /// Return the most specific rule matching the `refname`, if any.
+ ///
+ /// Note that [`RawRules`] is keyed by [`RawPattern`], which orders
+ /// lexicographically, so the matches must be ordered by
+ /// [`cmp_specificity`] rather than taken in iteration order.
+ pub fn most_specific_match(&self, refname: &Qualified<'_>) -> Option<(&RawPattern, &RawRule)> {
+ self.matches(refname)
+ .min_by(|(lhs, _), (rhs, _)| cmp_specificity(lhs, rhs))
+ }
}
impl Extend<(RawPattern, RawRule)> for RawRules {
diff --git a/crates/radicle/src/identity/doc.rs b/crates/radicle/src/identity/doc.rs
index 11cdabbd9d..dff5c4da1a 100644
--- a/crates/radicle/src/identity/doc.rs
+++ b/crates/radicle/src/identity/doc.rs
@@ -860,7 +860,8 @@ impl Doc {
raw_crefs: &RawCanonicalRefs,
default_branch: &Qualified,
) -> Result<(), CanonicalRefsError> {
- let Some((pattern, rule)) = raw_crefs.raw_rules().matches(default_branch).next() else {
+ let Some((pattern, rule)) = raw_crefs.raw_rules().most_specific_match(default_branch)
+ else {
return Ok(());
};
Exit code: 0
shell: 'export RUSTDOCFLAGS=''-D warnings'' cargo --version rustc --version cargo fmt --check cargo clippy --all-targets --workspace -- --deny warnings cargo build --all-targets --workspace cargo doc --workspace --no-deps --all-features cargo test --workspace --no-fail-fast '
Commands:
$ podman run --name da0e79d1-e86b-4b32-8bdf-7de652a5f2bc -v /opt/radcis/ci.rad.levitte.org/cci/state/da0e79d1-e86b-4b32-8bdf-7de652a5f2bc/s:/da0e79d1-e86b-4b32-8bdf-7de652a5f2bc/s:ro -v /opt/radcis/ci.rad.levitte.org/cci/state/da0e79d1-e86b-4b32-8bdf-7de652a5f2bc/w:/da0e79d1-e86b-4b32-8bdf-7de652a5f2bc/w -w /da0e79d1-e86b-4b32-8bdf-7de652a5f2bc/w -v /opt/radcis/ci.rad.levitte.org/.radicle:/${id}/.radicle:ro -e RAD_HOME=/${id}/.radicle rust:trixie bash /da0e79d1-e86b-4b32-8bdf-7de652a5f2bc/s/script.sh
+ export 'RUSTDOCFLAGS=-D warnings'
+ RUSTDOCFLAGS='-D warnings'
+ cargo --version
info: syncing channel updates for '1.98.0-x86_64-unknown-linux-gnu'
info: latest update on 2026-08-20, rust version 1.98.0 (88d9e12ae 2026-08-18)
info: downloading component 'cargo'
info: downloading component 'clippy'
info: downloading component 'rust-docs'
info: downloading component 'rust-src'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: downloading component 'rustfmt'
info: installing component 'cargo'
info: installing component 'clippy'
info: installing component 'rust-docs'
info: installing component 'rust-src'
info: installing component 'rust-std'
info: installing component 'rustc'
info: installing component 'rustfmt'
cargo 1.98.0 (797e8a9bc 2026-08-05)
+ rustc --version
rustc 1.98.0 (88d9e12ae 2026-08-18)
+ cargo fmt --check
Diff in /da0e79d1-e86b-4b32-8bdf-7de652a5f2bc/w/crates/radicle/src/git/canonical/rules/test.rs:564:
assert!(!matches(&exact, &git::fmt::qualified!("refs/heads/bar")));
}
-/// `RawRules` orders lexicographically, so its iteration order disagrees with the specificity
+/// `RawRules` orders lexicographically, so its iteration order disagrees with the specificity
/// rules of canonical references. `most_specific_match` applies that priority.
#[test]
fn most_specific_match_ignores_lexicographic_order() {
Exit code: 1
{
"response": "finished",
"result": "failure"
}