Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
Canonical Refs Annotated Tags Support
Merged fintohaps opened 9 months ago
28 files changed +1335 -468 54fd8c40 3d352f23
modified crates/radicle-cli/examples/git/git-push-amend.md
@@ -21,7 +21,7 @@ $ git commit --amend -m "Neue Änderungen" --allow-empty -q

``` ~alice (stderr)
$ git push rad master -f
-
✓ Canonical reference refs/heads/master updated to 9170c8795d3a78f0381a0ffafb20ea69fb0f5b6b
+
✓ Canonical reference refs/heads/master updated to target commit 9170c8795d3a78f0381a0ffafb20ea69fb0f5b6b
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 + fb25886...9170c87 master -> master (forced update)
added crates/radicle-cli/examples/git/git-push-canonical-annotated-tags.md
@@ -0,0 +1,233 @@
+
In this example, we will show how we can make other references become canonical.
+
To illustrate, we will use annotated Git tags as an example. The storage of the repository
+
should look something like this by the end of the example:
+

+
~~~
+
storage/z6cFWeWpnZNHh9rUW8phgA3b5yGt/refs
+
├── heads
+
│   └── main
+
├── namespaces
+
│   ├── z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
│   │   └── refs
+
│   │       ├── cobs
+
│   │       │   └── xyz.radicle.id
+
│   │       │       └── 865c48204bd7bb7f088b8db90ffdccb48cfa0a50
+
│   │       ├── heads
+
│   │       │   └── master
+
│   │       ├── tags
+
│   │       │   ├── v1.0-hotfix
+
│   │       │   └── v1.0
+
│   │       └── rad
+
│   │           ├── id
+
│   │           └── sigrefs
+
│   └── z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+
│       └── refs
+
│           ├── heads
+
│           │   └── master
+
│           ├── tags
+
│           │   ├── v1.0-hotfix
+
│           │   └── v1.0
+
│           └── rad
+
│               ├── id
+
│               └── sigrefs
+
├── rad
+
│   └── id
+
└── tags
+
    ├── v1.0-hotfix
+
    └── v1.0
+
~~~
+

+
Noting that there are tags under `refs/tags` now.
+

+
To start, Alice will add a new payload to the repository identity. The
+
identifier for this payload is `xyz.radicle.crefs`. It contains a single field
+
with the key `rules`, and the value for this key is an array of rules. In this
+
case, we will have two rules: one for `refs/tags/*` and one for `refs/tags/qa/*`
+
(see RIP-0004 for more information on the rules).
+

+
``` ~alice
+
$ rad id update --title "Add canonical reference rules" --payload xyz.radicle.crefs rules '{ "refs/tags/*": { "threshold": 1, "allow": "delegates" }, "refs/tags/qa/*": { "threshold": 1, "allow": "delegates" }}'
+
✓ Identity revision [..] created
+
╭────────────────────────────────────────────────────────────────────────╮
+
│ Title    Add canonical reference rules                                 │
+
│ Revision c3349f07bfe6a82bbeb2989d2de4a918408f9831                      │
+
│ Blob     85fa09e2de93b825d5231778dbb34143004a4bca                      │
+
│ Author   did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi      │
+
│ State    accepted                                                      │
+
│ Quorum   yes                                                           │
+
├────────────────────────────────────────────────────────────────────────┤
+
│ ✓ did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi alice (you) │
+
╰────────────────────────────────────────────────────────────────────────╯
+

+
@@ -1,13 +1,25 @@
+
 {
+
   "payload": {
+
+    "xyz.radicle.crefs": {
+
+      "rules": {
+
+        "refs/tags/*": {
+
+          "allow": "delegates",
+
+          "threshold": 1
+
+        },
+
+        "refs/tags/qa/*": {
+
+          "allow": "delegates",
+
+          "threshold": 1
+
+        }
+
+      }
+
+    },
+
     "xyz.radicle.project": {
+
       "defaultBranch": "master",
+
       "description": "Radicle Heartwood Protocol & Stack",
+
       "name": "heartwood"
+
     }
+
   },
+
   "delegates": [
+
     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
+
   ],
+
   "threshold": 1
+
 }
+
```
+

+
Now, Alice will create a tag and push it:
+

+
``` ~alice
+
$ git tag -a -m "Hotfix for release 1" v1.0-hotfix
+
$ git cat-file -t v1.0-hotfix
+
tag
+
$ git cat-file -t ac51a0746a5e8311829bc481202909a1e3acc0c2
+
tag
+
```
+

+
``` ~alice (stderr)
+
$ git push rad --tags
+
✓ Canonical reference refs/tags/v1.0-hotfix updated to target tag ac51a0746a5e8311829bc481202909a1e3acc0c2
+
✓ Synced with 1 seed(s)
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new tag]         v1.0-hotfix -> v1.0-hotfix
+
```
+

+
Notice that the output included a message about a canonical reference being
+
updated:
+

+
~~~
+
✓ Canonical reference refs/tags/v1.0-hotfix updated to target tag ac51a0746a5e8311829bc481202909a1e3acc0c2
+
~~~
+

+
On the other side, Bob performs a fetch and now has the tags locally:
+

+
``` ~bob (stderr)
+
$ cd heartwood
+
$ git fetch rad
+
From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
 * [new tag]         v1.0-hotfix -> rad/tags/v1.0-hotfix
+
 * [new tag]         v1.0-hotfix -> v1.0-hotfix
+
```
+

+
Since Alice crated an annotated tag, resolving the reference on Bob's end yields an object of type 'tag'.
+

+
``` ~bob
+
$ git cat-file -t v1.0-hotfix
+
tag
+
```
+

+
In the next portion of this example, we want to show that using a `threshold` of
+
`2` requires both delegates. To do this, Bob creates a `master` reference, Alice
+
adds him as a remote, and adds him to the identity delegates, as well as setting
+
the `threshold` to `2` for the `refs/tags/*` rule:
+

+
``` ~bob
+
$ rad remote add z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --name alice
+
✓ Follow policy updated for z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (alice)
+
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
+
✓ Target met: 1 seed(s)
+
✓ Remote alice added
+
✓ Remote-tracking branch alice/master created for z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
$ git push rad master
+
```
+

+
``` ~alice
+
$ rad remote add z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --name bob
+
✓ Follow policy updated for z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk (bob)
+
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
+
✓ Target met: 1 seed(s)
+
✓ Remote bob added
+
✓ Remote-tracking branch bob/master created for z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+
$ rad id update --title "Add Bob" --delegate did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --no-confirm -q
+
27ab0d77a95581c59ca9d30e679ceb06a9f758db
+
$ rad id update --title "Update canonical reference rules" --payload xyz.radicle.crefs rules '{ "refs/tags/*": { "threshold": 2, "allow": "delegates" }, "refs/tags/qa/*": { "threshold": 1, "allow": "delegates" } }' -q
+
dace164ba43fa51802697ec28d0b1965a9d7808b
+
```
+

+
**Note:** here we have to specify all the rules again to update the `threshold`.
+
In reality, you can use `rad id update --edit` and edit the payload in your
+
editor instead.
+

+
``` ~bob
+
$ rad sync -f
+
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
+
✓ Target met: 1 seed(s)
+
🌱 Fetched from z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
$ rad id accept dace164ba43fa51802697ec28d0b1965a9d7808b -q
+
```
+

+
When Bob creates a new tag and pushes it, we see that there's a warning that
+
no quorum was found for the new tag:
+

+
``` ~bob
+
$ git tag -l
+
v1.0-hotfix
+
$ git rev-parse v1.0-hotfix
+
ac51a0746a5e8311829bc481202909a1e3acc0c2
+
```
+

+
``` ~bob (stderr)
+
$ git tag -a -m "Release 2" v2.0
+
$ git push rad --tags
+
warn: could not determine target for canonical reference 'refs/tags/v2.0', no object with at least 2 vote(s) found (threshold not met)
+
warn: it is recommended to find a commit to agree upon
+
✓ Synced with 1 seed(s)
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+
 * [new tag]         v1.0-hotfix -> v1.0-hotfix
+
 * [new tag]         v2.0 -> v2.0
+
```
+

+
Alice can then fetch and checkout the new tag, create one on her side, and push
+
it:
+

+
``` ~alice (stderr)
+
$ git fetch bob
+
From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+
 * [new tag]         v1.0-hotfix -> bob/tags/v1.0-hotfix
+
 * [new tag]         v2.0        -> bob/tags/v2.0
+
```
+

+
At this point Alice might check out `v2.0` and consider whether she agrees with Bob.
+
Let's say that Alice agrees, so she copies the tag to her repository using `git tag`:
+

+
``` ~alice
+
$ git tag v2.0 bob/tags/v2.0
+
```
+

+
``` ~alice (stderr)
+
$ git push rad --tags
+
✓ Canonical reference refs/tags/v2.0 updated to target tag 89f935f27a16f8ed97915ade4accab8fe48057aa
+
✓ Synced with 1 seed(s)
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new tag]         v2.0 -> v2.0
+
```
+

+
Now that Bob has also pushed this tag, we can see that the tag was made
+
canonical.
+

+
For the final portion of the example, we will show that both delegates aren't
+
required for pushing tags that match the rule `refs/tags/qa/*`. To show this,
+
Bob will create a lightweight tag and push it, and we should see that the canonical
+
reference is created:
+

+
``` ~bob (stderr)
+
$ git tag qa/v2.1
+
$ git push rad --tags
+
✓ Canonical reference refs/tags/qa/v2.1 updated to target commit f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
+
✓ Synced with 1 seed(s)
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+
 * [new tag]         qa/v2.1 -> qa/v2.1
+
```
added crates/radicle-cli/examples/git/git-push-canonical-lightweight-tags.md
@@ -0,0 +1,222 @@
+
In this example, we will show how we can make other references become canonical.
+
To illustrate, we will use lightweight Git tags as an example. The storage of the repository
+
should look something like this by the end of the example:
+

+
~~~
+
storage/z6cFWeWpnZNHh9rUW8phgA3b5yGt/refs
+
├── heads
+
│   └── main
+
├── namespaces
+
│   ├── z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
│   │   └── refs
+
│   │       ├── cobs
+
│   │       │   └── xyz.radicle.id
+
│   │       │       └── 865c48204bd7bb7f088b8db90ffdccb48cfa0a50
+
│   │       ├── heads
+
│   │       │   └── master
+
│   │       ├── tags
+
│   │       │   ├── v1.0-hotfix
+
│   │       │   └── v1.0
+
│   │       └── rad
+
│   │           ├── id
+
│   │           └── sigrefs
+
│   └── z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+
│       └── refs
+
│           ├── heads
+
│           │   └── master
+
│           ├── tags
+
│           │   ├── v1.0-hotfix
+
│           │   └── v1.0
+
│           └── rad
+
│               ├── id
+
│               └── sigrefs
+
├── rad
+
│   └── id
+
└── tags
+
    ├── v1.0-hotfix
+
    └── v1.0
+
~~~
+

+
Noting that there are tags under `refs/tags` now.
+

+
To start, Alice will add a new payload to the repository identity. The
+
identifier for this payload is `xyz.radicle.crefs`. It contains a single field
+
with the key `rules`, and the value for this key is an array of rules. In this
+
case, we will have two rules: one for `refs/tags/*` and one for `refs/tags/qa/*`
+
(see RIP-0004 for more information on the rules).
+

+
``` ~alice
+
$ rad id update --title "Add canonical reference rules" --payload xyz.radicle.crefs rules '{ "refs/tags/*": { "threshold": 1, "allow": "delegates" }, "refs/tags/qa/*": { "threshold": 1, "allow": "delegates" }}'
+
✓ Identity revision [..] created
+
╭────────────────────────────────────────────────────────────────────────╮
+
│ Title    Add canonical reference rules                                 │
+
│ Revision c3349f07bfe6a82bbeb2989d2de4a918408f9831                      │
+
│ Blob     85fa09e2de93b825d5231778dbb34143004a4bca                      │
+
│ Author   did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi      │
+
│ State    accepted                                                      │
+
│ Quorum   yes                                                           │
+
├────────────────────────────────────────────────────────────────────────┤
+
│ ✓ did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi alice (you) │
+
╰────────────────────────────────────────────────────────────────────────╯
+

+
@@ -1,13 +1,25 @@
+
 {
+
   "payload": {
+
+    "xyz.radicle.crefs": {
+
+      "rules": {
+
+        "refs/tags/*": {
+
+          "allow": "delegates",
+
+          "threshold": 1
+
+        },
+
+        "refs/tags/qa/*": {
+
+          "allow": "delegates",
+
+          "threshold": 1
+
+        }
+
+      }
+
+    },
+
     "xyz.radicle.project": {
+
       "defaultBranch": "master",
+
       "description": "Radicle Heartwood Protocol & Stack",
+
       "name": "heartwood"
+
     }
+
   },
+
   "delegates": [
+
     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
+
   ],
+
   "threshold": 1
+
 }
+
```
+

+
Now, Alice will create a tag and push it:
+

+
``` ~alice
+
$ git tag v1.0-hotfix
+
```
+

+
``` ~alice (stderr)
+
$ git push rad --tags
+
✓ Canonical reference refs/tags/v1.0-hotfix updated to target commit f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
+
✓ Synced with 1 seed(s)
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new tag]         v1.0-hotfix -> v1.0-hotfix
+
```
+

+
Notice that the output included a message about a canonical reference being
+
updated:
+

+
~~~
+
✓ Canonical reference refs/tags/v1.0-hotfix updated to target commit f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
+
~~~
+

+
On the other side, Bob performs a fetch and now has the tags locally:
+

+
``` ~bob (stderr)
+
$ cd heartwood
+
$ git fetch rad
+
From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
 * [new tag]         v1.0-hotfix -> rad/tags/v1.0-hotfix
+
 * [new tag]         v1.0-hotfix -> v1.0-hotfix
+
```
+

+
Since Alice crated a lightweight tag, resolving the reference on Bob's end yields an object of type 'commit'.
+

+
``` ~bob
+
$ git cat-file -t v1.0-hotfix
+
commit
+
```
+

+
In the next portion of this example, we want to show that using a `threshold` of
+
`2` requires both delegates. To do this, Bob creates a `master` reference, Alice
+
adds him as a remote, and adds him to the identity delegates, as well as setting
+
the `threshold` to `2` for the `refs/tags/*` rule:
+

+
``` ~bob
+
$ rad remote add z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --name alice
+
✓ Follow policy updated for z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (alice)
+
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
+
✓ Target met: 1 seed(s)
+
✓ Remote alice added
+
✓ Remote-tracking branch alice/master created for z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
$ git push rad master
+
```
+

+
``` ~alice
+
$ rad remote add z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --name bob
+
✓ Follow policy updated for z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk (bob)
+
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
+
✓ Target met: 1 seed(s)
+
✓ Remote bob added
+
✓ Remote-tracking branch bob/master created for z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+
$ rad id update --title "Add Bob" --delegate did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --no-confirm -q
+
27ab0d77a95581c59ca9d30e679ceb06a9f758db
+
$ rad id update --title "Update canonical reference rules" --payload xyz.radicle.crefs rules '{ "refs/tags/*": { "threshold": 2, "allow": "delegates" }, "refs/tags/qa/*": { "threshold": 1, "allow": "delegates" } }' -q
+
dace164ba43fa51802697ec28d0b1965a9d7808b
+
```
+

+
**Note:** here we have to specify all the rules again to update the `threshold`.
+
In reality, you can use `rad id update --edit` and edit the payload in your
+
editor instead.
+

+
``` ~bob
+
$ rad sync -f
+
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
+
✓ Target met: 1 seed(s)
+
🌱 Fetched from z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
$ rad id accept dace164ba43fa51802697ec28d0b1965a9d7808b -q
+
```
+

+
When Bob creates a new tag and pushes it, we see that there's a warning that
+
no quorum was found for the new tag:
+

+
``` ~bob (stderr)
+
$ git tag v2.0
+
$ git push rad --tags
+
warn: could not determine target for canonical reference 'refs/tags/v2.0', no object with at least 2 vote(s) found (threshold not met)
+
warn: it is recommended to find a commit to agree upon
+
✓ Synced with 1 seed(s)
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+
 * [new tag]         v1.0-hotfix -> v1.0-hotfix
+
 * [new tag]         v2.0 -> v2.0
+
```
+

+
Alice can then fetch and checkout the new tag, create one on her side, and push
+
it:
+

+
``` ~alice (stderr)
+
$ git fetch bob
+
From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+
 * [new tag]         v1.0-hotfix -> bob/tags/v1.0-hotfix
+
 * [new tag]         v2.0        -> bob/tags/v2.0
+
```
+

+
At this point Alice might check out `v2.0` and consider whether she agrees with Bob.
+
Let's say that Alice agrees, so she copies the tag to her repository using `git tag`:
+

+
``` ~alice
+
$ git tag v2.0 bob/tags/v2.0
+
```
+

+
``` ~alice (stderr)
+
$ git push rad --tags
+
✓ Canonical reference refs/tags/v2.0 updated to target commit f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
+
✓ Synced with 1 seed(s)
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new tag]         v2.0 -> v2.0
+
```
+

+
Now that Bob has also pushed this tag, we can see that the tag was made
+
canonical.
+

+
For the final portion of the example, we will show that both delegates aren't
+
required for pushing tags that match the rule `refs/tags/qa/*`. To show this,
+
Bob will create a tag and push it, and we should see that the canonical
+
reference is created:
+

+
``` ~bob (stderr)
+
$ git tag qa/v2.1
+
$ git push rad --tags
+
✓ Canonical reference refs/tags/qa/v2.1 updated to target commit f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
+
✓ Synced with 1 seed(s)
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+
 * [new tag]         qa/v2.1 -> qa/v2.1
+
```
deleted crates/radicle-cli/examples/git/git-push-canonical-tags.md
@@ -1,213 +0,0 @@
-
In this example, we will show how we can make other references become canonical.
-
To illustrate, we will use Git tags as an example. The storage of the repository
-
should look something like this by the end of the example:
-

-
~~~
-
storage/z6cFWeWpnZNHh9rUW8phgA3b5yGt/refs
-
├── heads
-
│   └── main
-
├── namespaces
-
│   ├── z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
│   │   └── refs
-
│   │       ├── cobs
-
│   │       │   └── xyz.radicle.id
-
│   │       │       └── 865c48204bd7bb7f088b8db90ffdccb48cfa0a50
-
│   │       ├── heads
-
│   │       │   └── master
-
│   │       ├── tags
-
│   │       │   ├── v1.0-hotfix
-
│   │       │   └── v1.0
-
│   │       └── rad
-
│   │           ├── id
-
│   │           └── sigrefs
-
│   └── z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
-
│       └── refs
-
│           ├── heads
-
│           │   └── master
-
│           ├── tags
-
│           │   ├── v1.0-hotfix
-
│           │   └── v1.0
-
│           └── rad
-
│               ├── id
-
│               └── sigrefs
-
├── rad
-
│   └── id
-
└── tags
-
    ├── v1.0-hotfix
-
    └── v1.0
-
~~~
-

-
Noting that there are tags under `refs/tags` now.
-

-
To start, Alice will add a new payload to the repository identity. The
-
identifier for this payload is `xyz.radicle.crefs`. It contains a single field
-
with the key `rules`, and the value for this key is an array of rules. In this
-
case, we will have two rules: one for `refs/tags/*` and one for `refs/tags/qa/*`
-
(see RIP-0004 for more information on the rules).
-

-
``` ~alice
-
$ rad id update --title "Add canonical reference rules" --payload xyz.radicle.crefs rules '{ "refs/tags/*": { "threshold": 1, "allow": "delegates" }, "refs/tags/qa/*": { "threshold": 1, "allow": "delegates" }}'
-
✓ Identity revision [..] created
-
╭────────────────────────────────────────────────────────────────────────╮
-
│ Title    Add canonical reference rules                                 │
-
│ Revision c3349f07bfe6a82bbeb2989d2de4a918408f9831                      │
-
│ Blob     85fa09e2de93b825d5231778dbb34143004a4bca                      │
-
│ Author   did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi      │
-
│ State    accepted                                                      │
-
│ Quorum   yes                                                           │
-
├────────────────────────────────────────────────────────────────────────┤
-
│ ✓ did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi alice (you) │
-
╰────────────────────────────────────────────────────────────────────────╯
-

-
@@ -1,13 +1,25 @@
-
 {
-
   "payload": {
-
+    "xyz.radicle.crefs": {
-
+      "rules": {
-
+        "refs/tags/*": {
-
+          "allow": "delegates",
-
+          "threshold": 1
-
+        },
-
+        "refs/tags/qa/*": {
-
+          "allow": "delegates",
-
+          "threshold": 1
-
+        }
-
+      }
-
+    },
-
     "xyz.radicle.project": {
-
       "defaultBranch": "master",
-
       "description": "Radicle Heartwood Protocol & Stack",
-
       "name": "heartwood"
-
     }
-
   },
-
   "delegates": [
-
     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
   ],
-
   "threshold": 1
-
 }
-
```
-

-
Now, Alice will create a tag and push it:
-

-
``` ~alice
-
$ git tag v1.0-hotfix
-
```
-

-
``` ~alice (stderr)
-
$ git push rad --tags
-
✓ Canonical reference refs/tags/v1.0-hotfix updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
-
✓ Synced with 1 seed(s)
-
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
 * [new tag]         v1.0-hotfix -> v1.0-hotfix
-
```
-

-
Notice that the output included a message about a canonical reference being
-
updated:
-

-
~~~
-
✓ Canonical reference refs/tags/v1.0-hotfix updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
-
~~~
-

-
On the other side, Bob performs a fetch and now has the tags locally:
-

-
``` ~bob (stderr)
-
$ cd heartwood
-
$ git fetch rad
-
From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji
-
 * [new tag]         v1.0-hotfix -> rad/tags/v1.0-hotfix
-
 * [new tag]         v1.0-hotfix -> v1.0-hotfix
-
```
-

-
In the next portion of this example, we want to show that using a `threshold` of
-
`2` requires both delegates. To do this, Bob creates a `master` reference, Alice
-
adds him as a remote, and adds him to the identity delegates, as well as setting
-
the `threshold` to `2` for the `refs/tags/*` rule:
-

-
``` ~bob
-
$ rad remote add z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --name alice
-
✓ Follow policy updated for z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (alice)
-
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
-
✓ Target met: 1 seed(s)
-
✓ Remote alice added
-
✓ Remote-tracking branch alice/master created for z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
$ git push rad master
-
```
-

-
``` ~alice
-
$ rad remote add z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --name bob
-
✓ Follow policy updated for z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk (bob)
-
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
-
✓ Target met: 1 seed(s)
-
✓ Remote bob added
-
✓ Remote-tracking branch bob/master created for z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
-
$ rad id update --title "Add Bob" --delegate did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --no-confirm -q
-
27ab0d77a95581c59ca9d30e679ceb06a9f758db
-
$ rad id update --title "Update canonical reference rules" --payload xyz.radicle.crefs rules '{ "refs/tags/*": { "threshold": 2, "allow": "delegates" }, "refs/tags/qa/*": { "threshold": 1, "allow": "delegates" } }' -q
-
dace164ba43fa51802697ec28d0b1965a9d7808b
-
```
-

-
**Note:** here we have to specify all the rules again to update the `threshold`.
-
In reality, you can use `rad id update --edit` and edit the payload in your
-
editor instead.
-

-
``` ~bob
-
$ rad sync -f
-
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
-
✓ Target met: 1 seed(s)
-
🌱 Fetched from z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
$ rad id accept dace164ba43fa51802697ec28d0b1965a9d7808b -q
-
```
-

-
When Bob creates a new tag and pushes it, we see that there's a warning that
-
no quorum was found for the new tag:
-

-
``` ~bob (stderr)
-
$ git tag v2.0
-
$ git push rad --tags
-
warn: could not determine commit for canonical reference 'refs/tags/v2.0', no commit with at least 2 vote(s) found (threshold not met)
-
warn: it is recommended to find a commit to agree upon
-
✓ Synced with 1 seed(s)
-
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
-
 * [new tag]         v1.0-hotfix -> v1.0-hotfix
-
 * [new tag]         v2.0 -> v2.0
-
```
-

-
Alice can then fetch and checkout the new tag, create one on her side, and push
-
it:
-

-
``` ~alice (stderr)
-
$ git fetch bob
-
From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
-
 * [new tag]         v1.0-hotfix -> bob/tags/v1.0-hotfix
-
 * [new tag]         v2.0        -> bob/tags/v2.0
-
```
-

-
``` ~alice
-
$ git checkout bob/tags/v2.0
-
$ git tag v2.0
-
```
-

-
``` ~alice (stderr)
-
$ git push rad --tags
-
✓ Canonical reference refs/tags/v2.0 updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
-
✓ Synced with 1 seed(s)
-
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
 * [new tag]         v2.0 -> v2.0
-
```
-

-
Now that Bob has also pushed this tag, we can see that the tag was made
-
canonical.
-

-
For the final portion of the example, we will show that both delegates aren't
-
required for pushing tags that match the rule `refs/tags/qa/*`. To show this,
-
Bob will create a tag and push it, and we should see that the canonical
-
reference is created:
-

-
``` ~bob (stderr)
-
$ git tag qa/v2.1
-
$ git push rad --tags
-
✓ Canonical reference refs/tags/qa/v2.1 updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
-
✓ Synced with 1 seed(s)
-
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
-
 * [new tag]         qa/v2.1 -> qa/v2.1
-
```
modified crates/radicle-cli/examples/git/git-push-converge.md
@@ -91,7 +91,7 @@ commit:

``` ~alice (stderr)
$ git push rad -f
-
warn: could not determine commit for canonical reference 'refs/heads/master', no commit with at least 3 vote(s) found (threshold not met)
+
warn: could not determine target for canonical reference 'refs/heads/master', no object with at least 3 vote(s) found (threshold not met)
warn: it is recommended to find a commit to agree upon
✓ Synced with 2 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
@@ -116,7 +116,7 @@ become the canonical `master`.

``` ~bob (stderr)
$ git push rad
-
✓ Canonical reference refs/heads/master updated to 3a75f66dd0020c9a0355cc6ec21f15de989e2001
+
✓ Canonical reference refs/heads/master updated to target commit 3a75f66dd0020c9a0355cc6ec21f15de989e2001
✓ Synced with 2 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
   2a37862..0f9bd80  master -> master
@@ -137,7 +137,7 @@ HEAD is now at 0f9bd80 Merge remote-tracking branch 'eve/master'

``` ~eve (stderr)
$ git push rad
-
✓ Canonical reference refs/heads/master updated to 0f9bd8035c04b3f73f5408e73e8454879b20800b
+
✓ Canonical reference refs/heads/master updated to target commit 0f9bd8035c04b3f73f5408e73e8454879b20800b
✓ Synced with 2 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkux1aUQD2voWWukVb5nNUR7thrHveQG4pDQua8nVhib7Z
   3a75f66..0f9bd80  master -> master
modified crates/radicle-cli/examples/git/git-push-diverge.md
@@ -61,7 +61,7 @@ f2de534 Second commit
```
``` ~alice RAD_SOCKET=/dev/null (stderr)
$ git push rad
-
✓ Canonical reference refs/heads/master updated to f6cff86594495e9beccfeda7c20173e55c1dd9fc
+
✓ Canonical reference refs/heads/master updated to target commit f6cff86594495e9beccfeda7c20173e55c1dd9fc
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..f6cff86  master -> master
```
@@ -74,7 +74,7 @@ $ git reset --hard HEAD^ -q
```
``` ~alice RAD_SOCKET=/dev/null (stderr)
$ git push -f
-
✓ Canonical reference refs/heads/master updated to 319a7dc3b195368ded4b099f8c90bbb80addccd3
+
✓ Canonical reference refs/heads/master updated to target commit 319a7dc3b195368ded4b099f8c90bbb80addccd3
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 + f6cff86...319a7dc master -> master (forced update)
```
modified crates/radicle-cli/examples/git/git-push-rollback.md
@@ -35,7 +35,7 @@ Fast-forward

``` ~alice (stderr)
$ git push rad
-
✓ Canonical reference refs/heads/master updated to 319a7dc3b195368ded4b099f8c90bbb80addccd3
+
✓ Canonical reference refs/heads/master updated to target commit 319a7dc3b195368ded4b099f8c90bbb80addccd3
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..319a7dc  master -> master
@@ -54,7 +54,7 @@ push and the new canonical head becomes the previous commit again:

``` ~alice (stderr)
$ git push rad -f
-
✓ Canonical reference refs/heads/master updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
+
✓ Canonical reference refs/heads/master updated to target commit f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 + 319a7dc...f2de534 master -> master (forced update)
modified crates/radicle-cli/examples/rad-id-threshold.md
@@ -64,7 +64,7 @@ $ git commit -v -m "Define power requirements"

``` ~alice (stderr) RAD_SOCKET=/dev/null
$ git push rad master
-
✓ Canonical reference refs/heads/master updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
+
✓ Canonical reference refs/heads/master updated to target commit 3e674d1a1df90807e934f9ae5da2591dd6848a33
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..3e674d1  master -> master
```
modified crates/radicle-cli/examples/rad-merge-after-update.md
@@ -16,7 +16,7 @@ $ git commit --amend --allow-empty -q -m "Amended change"
$ git checkout master -q
$ git merge feature/1 -q
$ git push rad master
-
✓ Canonical reference refs/heads/master updated to 954bcdb5008447ce294a61a21d7eb87afbe7f4a6
+
✓ Canonical reference refs/heads/master updated to target commit 954bcdb5008447ce294a61a21d7eb87afbe7f4a6
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..954bcdb  master -> master
```
modified crates/radicle-cli/examples/rad-merge-no-ff.md
@@ -37,7 +37,7 @@ Finally, we push master and expect the patch to be merged.
``` (stderr) RAD_SOCKET=/dev/null
$ git push rad master
✓ Patch 696ec5508494692899337afe6713fe1796d0315c merged
-
✓ Canonical reference refs/heads/master updated to 737a10cfa29111afeb0d43cf3545cee386b939ec
+
✓ Canonical reference refs/heads/master updated to target commit 737a10cfa29111afeb0d43cf3545cee386b939ec
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..737a10c  master -> master
```
modified crates/radicle-cli/examples/rad-merge-via-push.md
@@ -70,7 +70,7 @@ When we push to `rad/master`, we automatically merge the patches:
$ git push rad master
✓ Patch 356f73863a8920455ff6e77cd9c805d68910551b merged
✓ Patch 696ec5508494692899337afe6713fe1796d0315c merged
-
✓ Canonical reference refs/heads/master updated to d6399c71702b40bae00825b3c444478d06b4e91c
+
✓ Canonical reference refs/heads/master updated to target commit d6399c71702b40bae00825b3c444478d06b4e91c
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..d6399c7  master -> master
```
@@ -148,7 +148,7 @@ the first patch, even though they were pushed together.
$ git reset --hard HEAD^
$ git push -f rad
! Patch 356f73863a8920455ff6e77cd9c805d68910551b reverted at revision 356f738
-
✓ Canonical reference refs/heads/master updated to 20aa5dde6210796c3a2f04079b42316a31d02689
+
✓ Canonical reference refs/heads/master updated to target commit 20aa5dde6210796c3a2f04079b42316a31d02689
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 + d6399c7...20aa5dd master -> master (forced update)
```
modified crates/radicle-cli/examples/rad-patch-merge-draft.md
@@ -14,7 +14,7 @@ $ git checkout master -q
$ git merge feature/1
$ git push rad master
✓ Patch 8dfb4dcafc4346158c8160410dd3f2b0616ad4fe merged
-
✓ Canonical reference refs/heads/master updated to 20aa5dde6210796c3a2f04079b42316a31d02689
+
✓ Canonical reference refs/heads/master updated to target commit 20aa5dde6210796c3a2f04079b42316a31d02689
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..20aa5dd  master -> master
```
modified crates/radicle-cli/examples/rad-patch-open-explore.md
@@ -38,7 +38,7 @@ $ git checkout master -q
$ git merge changes -q
$ git push rad master
✓ Patch acab0ec777a97d013f30be5d5d1aec32562ecb02 merged
-
✓ Canonical reference refs/heads/master updated to b2b6432af93f8fe188e32d400263021b602cfec8
+
✓ Canonical reference refs/heads/master updated to target commit b2b6432af93f8fe188e32d400263021b602cfec8
✓ Synced with 1 seed(s)

  https://app.radicle.xyz/nodes/[..]/rad:z3yXbb1sR6UG6ixxV2YF9jUP7ABra/tree/b2b6432af93f8fe188e32d400263021b602cfec8
modified crates/radicle-cli/examples/rad-patch-revert-merge.md
@@ -12,7 +12,7 @@ Switched to branch 'master'
$ git merge feature/1
$ git push rad master
✓ Patch 696ec5508494692899337afe6713fe1796d0315c merged
-
✓ Canonical reference refs/heads/master updated to 20aa5dde6210796c3a2f04079b42316a31d02689
+
✓ Canonical reference refs/heads/master updated to target commit 20aa5dde6210796c3a2f04079b42316a31d02689
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..20aa5dd  master -> master
```
@@ -50,7 +50,7 @@ When pushing, notice that we're told our patch is reverted.
``` (stderr) RAD_SOCKET=/dev/null
$ git push rad master --force
! Patch 696ec5508494692899337afe6713fe1796d0315c reverted at revision 696ec55
-
✓ Canonical reference refs/heads/master updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
+
✓ Canonical reference refs/heads/master updated to target commit f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 + 20aa5dd...f2de534 master -> master (forced update)
```
modified crates/radicle-cli/examples/workflow/5-patching-maintainer.md
@@ -92,7 +92,7 @@ Fast-forward
``` (stderr)
$ git push rad master
✓ Patch e4934b6d9dbe01ce3c7fbb5b77a80d5f1dacdc46 merged at revision 9d62420
-
✓ Canonical reference refs/heads/master updated to f567f695d25b4e8fb63b5f5ad2a584529826e908
+
✓ Canonical reference refs/heads/master updated to target commit f567f695d25b4e8fb63b5f5ad2a584529826e908
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..f567f69  master -> master
modified crates/radicle-cli/tests/commands.rs
@@ -2571,7 +2571,7 @@ fn git_tag() {
}

#[test]
-
fn git_push_canonical_tags() {
+
fn git_push_canonical_lightweight_tags() {
    let mut environment = Environment::new();
    let alice = environment.node("alice");
    let bob = environment.node("bob");
@@ -2595,7 +2595,49 @@ fn git_push_canonical_tags() {
    bob.clone(rid, environment.work(&bob)).unwrap();
    formula(
        &environment.tempdir(),
-
        "examples/git/git-push-canonical-tags.md",
+
        "examples/git/git-push-canonical-lightweight-tags.md",
+
    )
+
    .unwrap()
+
    .home(
+
        "alice",
+
        environment.work(&alice),
+
        [("RAD_HOME", alice.home.path().display())],
+
    )
+
    .home(
+
        "bob",
+
        environment.work(&bob),
+
        [("RAD_HOME", bob.home.path().display())],
+
    )
+
    .run()
+
    .unwrap();
+
}
+

+
#[test]
+
fn git_push_canonical_annotated_tags() {
+
    let mut environment = Environment::new();
+
    let alice = environment.node("alice");
+
    let bob = environment.node("bob");
+

+
    let rid = RepoId::from_str("z42hL2jL4XNk6K8oHQaSWfMgCL7ji").unwrap();
+

+
    fixtures::repository(environment.work(&alice));
+

+
    test(
+
        "examples/rad-init.md",
+
        environment.work(&alice),
+
        Some(&alice.home),
+
        [],
+
    )
+
    .unwrap();
+

+
    let alice = alice.spawn();
+
    let mut bob = bob.spawn();
+

+
    bob.connect(&alice).converge([&alice]);
+
    bob.clone(rid, environment.work(&bob)).unwrap();
+
    formula(
+
        &environment.tempdir(),
+
        "examples/git/git-push-canonical-annotated-tags.md",
    )
    .unwrap()
    .home(
modified crates/radicle-node/src/runtime.rs
@@ -73,7 +73,7 @@ pub enum Error {
    Address(#[from] address::Error),
    /// A service error.
    #[error("service error: {0}")]
-
    Service(#[from] service::Error),
+
    Service(Box<service::Error>),
    /// An I/O error.
    #[error("i/o error: {0}")]
    Io(#[from] io::Error),
@@ -92,6 +92,12 @@ pub enum Error {
    GitVersion(#[from] git::VersionError),
}

+
impl From<service::Error> for Error {
+
    fn from(e: service::Error) -> Self {
+
        Self::Service(Box::new(e))
+
    }
+
}
+

/// Wraps a [`UnixListener`] but tracks its origin.
pub enum ControlSocket {
    /// The listener was created by binding to it.
modified crates/radicle-node/src/worker/fetch.rs
@@ -122,9 +122,9 @@ impl Handle {
                            log::trace!(target: "worker", "Set HEAD to {}", head.new);
                        }
                    }
-
                    Err(RepositoryError::Quorum(radicle::git::canonical::QuorumError::Git(e))) => {
-
                        return Err(e.into())
-
                    }
+
                    Err(RepositoryError::Quorum(
+
                        radicle::git::canonical::error::QuorumError::Git(e),
+
                    )) => return Err(e.into()),
                    Err(RepositoryError::Quorum(e)) => {
                        log::warn!(target: "worker", "Fetch could not set HEAD: {e}")
                    }
@@ -406,7 +406,7 @@ fn set_canonical_refs(repo: &Repository, applied: &Applied) -> Result<(), error:
                );
                continue;
            }
-
            Ok((refname, oid)) => {
+
            Ok((refname, _, oid)) => {
                if let Err(e) = repo.backend.reference(
                    refname.clone().as_str(),
                    *oid,
modified crates/radicle-protocol/src/service.rs
@@ -206,7 +206,13 @@ pub enum Error {
    #[error(transparent)]
    Repository(#[from] radicle::storage::RepositoryError),
    #[error("namespaces error: {0}")]
-
    Namespaces(#[from] NamespacesError),
+
    Namespaces(Box<NamespacesError>),
+
}
+

+
impl From<NamespacesError> for Error {
+
    fn from(e: NamespacesError) -> Self {
+
        Self::Namespaces(Box::new(e))
+
    }
}

#[derive(thiserror::Error, Debug)]
@@ -304,7 +310,13 @@ enum TryFetchError<'a> {
    #[error("peer fetch capacity reached; cannot initiate fetch")]
    SessionCapacityReached,
    #[error(transparent)]
-
    Namespaces(#[from] NamespacesError),
+
    Namespaces(Box<NamespacesError>),
+
}
+

+
impl From<NamespacesError> for TryFetchError<'_> {
+
    fn from(e: NamespacesError) -> Self {
+
        Self::Namespaces(Box::new(e))
+
    }
}

/// Fetch state for an ongoing fetch.
modified crates/radicle-remote-helper/src/push.rs
@@ -113,13 +113,17 @@ pub enum Error {
    Repository(#[from] radicle::storage::RepositoryError),
    /// Quorum error.
    #[error(transparent)]
-
    Quorum(#[from] radicle::git::canonical::QuorumError),
+
    Quorum(#[from] radicle::git::canonical::error::QuorumError),
    #[error(transparent)]
    CanonicalRefs(#[from] radicle::identity::doc::CanonicalRefsError),
    #[error(transparent)]
    PushAction(#[from] error::PushAction),
    #[error(transparent)]
    Canonical(#[from] error::CanonicalUnrecoverable),
+
    #[error(transparent)]
+
    CanonicalInit(#[from] radicle::git::canonical::error::CanonicalError),
+
    #[error("could not determine object type for {oid}")]
+
    UnknownObjectType { oid: git::Oid },
}

/// Push command.
@@ -280,7 +284,8 @@ pub fn run(
    let identity = stored.identity()?;
    let project = identity.project()?;
    let canonical_ref = git::refs::branch(project.default_branch());
-
    let mut set_canonical_refs: Vec<(git::Qualified, git::Oid)> = Vec::with_capacity(specs.len());
+
    let mut set_canonical_refs: Vec<(git::Qualified, git::raw::ObjectType, git::Oid)> =
+
        Vec::with_capacity(specs.len());
    let working = git::raw::Repository::open(working)?;

    // For each refspec, push a ref or delete a ref.
@@ -348,7 +353,13 @@ pub fn run(
                        // Note that we *do* allow rolling back to a previous commit on the
                        // canonical branch.
                        if let Some(canonical) = rules.canonical(dst.clone(), stored)? {
-
                            let canonical = canonical::Canonical::new(me, *src, canonical);
+
                            let kind = working
+
                                .find_object(**src, None)?
+
                                .kind()
+
                                .and_then(git::canonical::CanonicalObject::new)
+
                                .ok_or(Error::UnknownObjectType { oid: *src })?;
+

+
                            let canonical = canonical::Canonical::new(me, *src, kind, canonical);
                            match canonical.quorum(&working) {
                                Ok(quorum) => set_canonical_refs.push(quorum),
                                Err(e) => canonical::io::handle_error(e, &dst, hints)?,
@@ -375,10 +386,10 @@ pub fn run(
    if !ok.is_empty() {
        let _ = stored.sign_refs(&signer)?;

-
        for (refname, oid) in &set_canonical_refs {
+
        for (refname, kind, oid) in &set_canonical_refs {
            let print_update = || {
                eprintln!(
-
                    "{} Canonical reference {} updated to {}",
+
                    "{} Canonical reference {} updated to target {kind} {}",
                    term::format::positive("✓"),
                    term::format::secondary(refname),
                    term::format::secondary(oid),
modified crates/radicle-remote-helper/src/push/canonical.rs
@@ -6,7 +6,8 @@ use super::error;

pub(crate) struct Vote {
    did: Did,
-
    commit: git::Oid,
+
    oid: git::Oid,
+
    kind: git::canonical::CanonicalObject,
}

/// Validates a vote to update a canonical reference during push.
@@ -16,11 +17,17 @@ pub(crate) struct Canonical<'a, 'b> {
}

impl<'a, 'b> Canonical<'a, 'b> {
-
    pub fn new(me: Did, head: git::Oid, canonical: git::canonical::Canonical<'a, 'b>) -> Self {
+
    pub fn new(
+
        me: Did,
+
        head: git::Oid,
+
        kind: git::canonical::CanonicalObject,
+
        canonical: git::canonical::Canonical<'a, 'b>,
+
    ) -> Self {
        Self {
            vote: Vote {
                did: me,
-
                commit: head,
+
                oid: head,
+
                kind,
            },
            canonical,
        }
@@ -46,31 +53,29 @@ impl<'a, 'b> Canonical<'a, 'b> {
    pub fn quorum(
        mut self,
        working: &Repository,
-
    ) -> Result<(git::Qualified<'a>, git::Oid), error::Canonical> {
+
    ) -> Result<(git::Qualified<'a>, git::raw::ObjectType, git::Oid), error::Canonical> {
        let converges = self
            .canonical
-
            .converges(working, (&self.vote.did, &self.vote.commit))?;
+
            .converges(working, (&self.vote.did, &self.vote.oid))?;
        if converges || self.canonical.has_no_tips() || self.canonical.is_only(&self.vote.did) {
-
            self.canonical.modify_vote(self.vote.did, self.vote.commit);
+
            self.canonical
+
                .modify_vote(self.vote.did, self.vote.oid, self.vote.kind);
        }

        match self.canonical.quorum(working) {
-
            Ok((cref, quorum_head)) => {
+
            Ok((cref, quorum_type, quorum_head)) => {
                // Canonical head is an ancestor of head.
-
                let is_ff = self.vote.commit == quorum_head
+
                let is_ff = self.vote.oid == quorum_head
                    || working
-
                        .graph_descendant_of(*self.vote.commit, *quorum_head)
+
                        .graph_descendant_of(*self.vote.oid, *quorum_head)
                        .map_err(|err| {
-
                            error::Canonical::graph_descendant(self.vote.commit, quorum_head, err)
+
                            error::Canonical::graph_descendant(self.vote.oid, quorum_head, err)
                        })?;

                if !is_ff && !converges {
-
                    Err(error::Canonical::heads_diverge(
-
                        self.vote.commit,
-
                        quorum_head,
-
                    ))
+
                    Err(error::Canonical::heads_diverge(self.vote.oid, quorum_head))
                } else {
-
                    Ok((cref, quorum_head))
+
                    Ok((cref, quorum_type, quorum_head))
                }
            }
            Err(err) => Err(err.into()),
@@ -80,7 +85,7 @@ impl<'a, 'b> Canonical<'a, 'b> {

pub(crate) mod io {
    use radicle::git;
-
    use radicle::git::canonical;
+
    use radicle::git::canonical::error::QuorumError;

    use crate::push::error;
    use crate::{hint, warn};
@@ -110,19 +115,27 @@ pub(crate) mod io {
                Err(e.into())
            }
            error::Canonical::Quorum(e) => match e {
-
                e @ canonical::QuorumError::Diverging { .. } => {
+
                e @ QuorumError::DivergingCommits { .. } => {
                    warn(e.to_string());
                    warn("it is recommended to find a commit to agree upon");
                    Ok(())
                }
-
                e @ canonical::QuorumError::NoCandidates { .. } => {
+
                e @ QuorumError::DivergingTags { .. } => {
                    warn(e.to_string());
-
                    warn("it is recommended to find a commit to agree upon");
+
                    warn("it is recommended to find a tag to agree upon");
+
                    Ok(())
+
                }
+
                e @ QuorumError::DifferentTypes { .. } => {
+
                    warn(e.to_string());
+
                    warn("it is recommended to find an object type (either commit or tag) to agree upon");
                    Ok(())
                }
-
                canonical::QuorumError::Git(err) => {
-
                    Err(error::CanonicalUnrecoverable::Git { source: err })
+
                e @ QuorumError::NoCandidates { .. } => {
+
                    warn(e.to_string());
+
                    warn("it is recommended to find a commit to agree upon");
+
                    Ok(())
                }
+
                QuorumError::Git(err) => Err(error::CanonicalUnrecoverable::Git { source: err }),
            },
        }
    }
modified crates/radicle-remote-helper/src/push/error.rs
@@ -7,7 +7,7 @@ pub enum CanonicalUnrecoverable {
    #[error(transparent)]
    GraphDescendant(#[from] GraphDescendant),
    #[error(transparent)]
-
    Converges(#[from] canonical::ConvergesError),
+
    Converges(#[from] canonical::error::ConvergesError),
    #[error(transparent)]
    HeadsDiverge(#[from] HeadsDiverge),
    #[error("failure while computing canonical reference: {source}")]
@@ -19,11 +19,11 @@ pub enum Canonical {
    #[error(transparent)]
    GraphDescendant(GraphDescendant),
    #[error(transparent)]
-
    Converges(#[from] canonical::ConvergesError),
+
    Converges(#[from] canonical::error::ConvergesError),
    #[error(transparent)]
    HeadsDiverge(HeadsDiverge),
    #[error(transparent)]
-
    Quorum(#[from] canonical::QuorumError),
+
    Quorum(#[from] canonical::error::QuorumError),
}

impl Canonical {
modified crates/radicle/src/git/canonical.rs
@@ -1,14 +1,19 @@
+
pub mod error;
+
use error::*;
+

pub mod rules;
+

+
use nonempty::NonEmpty;
pub use rules::{MatchedRule, RawRule, Rules, ValidRule};

use std::collections::BTreeMap;
-
use std::path::PathBuf;
+
use std::fmt;

+
use raw::ObjectType;
use raw::Repository;
-
use thiserror::Error;

use crate::prelude::Did;
-
use crate::storage::ReadRepository;
+
use crate::storage::git;

use super::raw;
use super::{Oid, Qualified};
@@ -28,115 +33,86 @@ use super::{Oid, Qualified};
pub struct Canonical<'a, 'b> {
    refname: Qualified<'a>,
    rule: &'b ValidRule,
-
    tips: BTreeMap<Did, Oid>,
-
}
-

-
/// Error that can occur when calculation the [`Canonical::quorum`].
-
#[derive(Debug, Error)]
-
pub enum QuorumError {
-
    /// Could not determine a quorum [`Oid`], due to diverging tips.
-
    #[error("could not determine commit for canonical reference '{refname}', found diverging commits {longest} and {head}, with base commit {base} and threshold {threshold}")]
-
    Diverging {
-
        refname: String,
-
        threshold: usize,
-
        base: Oid,
-
        longest: Oid,
-
        head: Oid,
-
    },
-
    /// Could not determine a base candidate from the given set of delegates.
-
    #[error("could not determine commit for canonical reference '{refname}', no commit with at least {threshold} vote(s) found (threshold not met)")]
-
    NoCandidates { refname: String, threshold: usize },
-
    /// An error occurred from [`git2`].
-
    #[error(transparent)]
-
    Git(#[from] git2::Error),
+
    tips: BTreeMap<Did, (Oid, CanonicalObject)>,
}

-
#[derive(Debug, Error)]
-
#[error("failed to check if {head} is an ancestor of {canonical} due to: {source}")]
-
pub struct GraphDescendant {
-
    head: Oid,
-
    canonical: Oid,
-
    source: raw::Error,
+
/// Support Git object types for canonical computation
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+
pub enum CanonicalObject {
+
    /// The Git object corresponds to a commit.
+
    Commit,
+
    /// The Git object corresponds to a tag.
+
    Tag,
}

-
#[derive(Debug, Error)]
-
#[error("the commit {commit} for {did} is missing from the repository {repo:?}")]
-
pub struct MissingCommit {
-
    repo: PathBuf,
-
    did: Did,
-
    commit: Oid,
-
    source: raw::Error,
-
}
-

-
#[derive(Debug, Error)]
-
#[error("could not determine whether the commit {commit} for {did} is part of the repository {repo:?} due to: {source}")]
-
pub struct InvalidCommit {
-
    repo: PathBuf,
-
    did: Did,
-
    commit: Oid,
-
    source: raw::Error,
-
}
-

-
#[derive(Debug, Error)]
-
pub enum ConvergesError {
-
    #[error(transparent)]
-
    GraphDescendant(#[from] GraphDescendant),
-
    #[error(transparent)]
-
    MissingCommit(#[from] MissingCommit),
-
    #[error(transparent)]
-
    InvalidCommit(#[from] InvalidCommit),
+
impl fmt::Display for CanonicalObject {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        match self {
+
            CanonicalObject::Commit => f.write_str("commit"),
+
            CanonicalObject::Tag => f.write_str("tag"),
+
        }
+
    }
}

-
impl ConvergesError {
-
    pub fn graph_descendant(head: Oid, canonical: Oid, source: raw::Error) -> Self {
-
        Self::GraphDescendant(GraphDescendant {
-
            head,
-
            canonical,
-
            source,
-
        })
+
impl CanonicalObject {
+
    /// Construct the [`CanonicalObject`] from a [`git2::ObjectType`].
+
    pub fn new(kind: git::raw::ObjectType) -> Option<Self> {
+
        match kind {
+
            ObjectType::Commit => Some(Self::Commit),
+
            ObjectType::Tag => Some(Self::Tag),
+
            _ => None,
+
        }
    }

-
    pub fn missing_commit(repo: PathBuf, did: Did, commit: Oid, err: raw::Error) -> Self {
-
        Self::MissingCommit(MissingCommit {
-
            repo,
-
            did,
-
            commit,
-
            source: err,
-
        })
+
    /// Returns `true` if the object is a commit.
+
    fn is_commit(&self) -> bool {
+
        match self {
+
            CanonicalObject::Commit => true,
+
            CanonicalObject::Tag => false,
+
        }
    }

-
    pub fn invalid_commit(repo: PathBuf, did: Did, commit: Oid, err: raw::Error) -> Self {
-
        Self::InvalidCommit(InvalidCommit {
-
            repo,
-
            did,
-
            commit,
-
            source: err,
-
        })
+
    /// Returns `true` if the object is a tag.
+
    fn is_tag(&self) -> bool {
+
        match self {
+
            CanonicalObject::Commit => false,
+
            CanonicalObject::Tag => true,
+
        }
    }
}

impl<'a, 'b> Canonical<'a, 'b> {
    /// Construct the set of canonical tips given for the given `rule` and
    /// the reference `refname`.
-
    pub fn new<S>(repo: &S, refname: Qualified<'a>, rule: &'b ValidRule) -> Result<Self, raw::Error>
-
    where
-
        S: ReadRepository,
-
    {
+
    pub fn new(
+
        repo: &Repository,
+
        refname: Qualified<'a>,
+
        rule: &'b ValidRule,
+
    ) -> Result<Self, CanonicalError> {
        let mut tips = BTreeMap::new();
        for delegate in rule.allowed().iter() {
-
            match repo.reference_oid(delegate, &refname) {
-
                Ok(tip) => {
-
                    tips.insert(*delegate, tip);
-
                }
+
            let name = &refname.with_namespace(delegate.as_key().into());
+

+
            let reference = match repo.find_reference(name) {
+
                Ok(reference) => reference,
                Err(e) if super::ext::is_not_found_err(&e) => {
                    log::warn!(
                        target: "radicle",
-
                        "Missing `refs/namespaces/{}/{refname}` while calculating the canonical reference",
-
                        delegate.as_key()
+
                        "Missing `{name}` while calculating the canonical reference",
                    );
+
                    continue;
                }
-
                Err(e) => return Err(e),
-
            }
+
                Err(e) => return Err(CanonicalError::find_reference(name, e)),
+
            };
+

+
            let Some(oid) = reference.target() else {
+
                log::warn!(target: "radicle", "Missing target for reference `{name}`");
+
                continue;
+
            };
+

+
            let kind = Self::find_object_for(delegate, oid.into(), repo)?;
+

+
            tips.insert(*delegate, (oid.into(), kind));
        }
        Ok(Canonical {
            refname,
@@ -145,9 +121,28 @@ impl<'a, 'b> Canonical<'a, 'b> {
        })
    }

-
    /// Return the set of [`Did`]s and their [`Oid`] tip.
-
    pub fn tips(&self) -> impl Iterator<Item = (&Did, &Oid)> {
-
        self.tips.iter()
+
    pub fn find_object_for(
+
        did: &Did,
+
        oid: Oid,
+
        repo: &raw::Repository,
+
    ) -> Result<CanonicalObject, CanonicalError> {
+
        match repo.find_object(*oid, None) {
+
            Ok(object) => object.kind().and_then(CanonicalObject::new).ok_or_else(|| {
+
                CanonicalError::invalid_object_type(
+
                    repo.path().to_path_buf(),
+
                    *did,
+
                    oid,
+
                    object.kind(),
+
                )
+
            }),
+
            Err(err) if super::ext::is_not_found_err(&err) => Err(CanonicalError::missing_object(
+
                repo.path().to_path_buf(),
+
                *did,
+
                oid,
+
                err,
+
            )),
+
            Err(err) => Err(CanonicalError::find_object(oid, err)),
+
        }
    }

    /// Returns `true` if there were no tips found for any of the DIDs for
@@ -165,8 +160,8 @@ impl<'a, 'b> Canonical<'a, 'b> {
    /// In some cases, we allow the vote to be modified. For example, when the
    /// `did` is pushing a new commit, we may want to see if the new commit will
    /// reach a quorum.
-
    pub fn modify_vote(&mut self, did: Did, new: Oid) {
-
        self.tips.insert(did, new);
+
    pub fn modify_vote(&mut self, did: Did, oid: Oid, kind: CanonicalObject) {
+
        self.tips.insert(did, (oid, kind));
    }

    /// Check that the provided `did` is part of the set of allowed
@@ -191,28 +186,113 @@ impl<'a, 'b> Canonical<'a, 'b> {
        repo: &Repository,
        (candidate, commit): (&Did, &Oid),
    ) -> Result<bool, ConvergesError> {
+
        /// Ensures [`Oid`]s are of the same object type
+
        enum Objects {
+
            Commits(NonEmpty<Oid>),
+
            Tags(NonEmpty<Oid>),
+
        }
+

+
        impl Objects {
+
            fn new(oid: Oid, kind: CanonicalObject) -> Self {
+
                match kind {
+
                    CanonicalObject::Commit => Self::Commits(NonEmpty::new(oid)),
+
                    CanonicalObject::Tag => Self::Tags(NonEmpty::new(oid)),
+
                }
+
            }
+

+
            fn insert(mut self, oid: Oid, kind: CanonicalObject) -> Result<Self, CanonicalObject> {
+
                match self {
+
                    Objects::Commits(ref mut commits) => match kind {
+
                        CanonicalObject::Commit => {
+
                            commits.push(oid);
+
                            Ok(self)
+
                        }
+
                        CanonicalObject::Tag => Err(CanonicalObject::Tag),
+
                    },
+
                    Objects::Tags(ref mut tags) => match kind {
+
                        CanonicalObject::Commit => {
+
                            tags.push(oid);
+
                            Ok(self)
+
                        }
+
                        CanonicalObject::Tag => Err(CanonicalObject::Commit),
+
                    },
+
                }
+
            }
+
        }
+

        let heads = {
-
            let mut heads = self
+
            let heads = self
                .tips
                .iter()
                .filter_map(|(did, tip)| (did != candidate).then_some((did, tip)));
-
            heads.try_fold(
-
                Vec::with_capacity(heads.size_hint().0),
-
                |mut heads, (did, head)| {
-
                    heads.push(Self::ensure_commit(*did, *head, repo)?);
-
                    Ok::<_, ConvergesError>(heads)
-
                },
-
            )?
+

+
            let mut objects = None;
+

+
            for (did, (oid, _)) in heads {
+
                let kind = find_object_for(did, *oid, repo)?;
+
                let oid = *oid;
+
                match objects {
+
                    None => objects = Some(Objects::new(oid, kind)),
+
                    Some(objs) => {
+
                        objects = Some(objs.insert(oid, kind).map_err(|expected| {
+
                            ConvergesError::mismatched_object(
+
                                repo.path().to_path_buf(),
+
                                oid,
+
                                kind,
+
                                expected,
+
                            )
+
                        })?)
+
                    }
+
                }
+
            }
+

+
            objects
        };
-
        for head in heads {
-
            let (ahead, behind) = repo
-
                .graph_ahead_behind(**commit, *head)
-
                .map_err(|err| ConvergesError::graph_descendant(*commit, head, err))?;
-
            if ahead * behind == 0 {
-
                return Ok(true);
+

+
        match heads {
+
            None => Ok(true),
+
            Some(Objects::Tags(_)) => Ok(true),
+
            Some(Objects::Commits(heads)) => {
+
                for head in heads {
+
                    let (ahead, behind) = repo
+
                        .graph_ahead_behind(**commit, *head)
+
                        .map_err(|err| ConvergesError::graph_descendant(*commit, head, err))?;
+
                    if ahead * behind == 0 {
+
                        return Ok(true);
+
                    }
+
                }
+
                Ok(false)
            }
        }
-
        Ok(false)
+
    }
+

+
    fn quorum_tag(&self) -> Result<Oid, QuorumError> {
+
        let voting = TagVoting::from_targets(
+
            self.tips
+
                .values()
+
                .filter_map(|(commit, kind)| kind.is_tag().then_some(*commit)),
+
        );
+
        let mut votes = voting.votes();
+

+
        // Keep tags which pass the threshold.
+
        votes.votes_past_threshold(self.threshold());
+

+
        if votes.number_of_candidates() > 1 {
+
            return Err(QuorumError::DivergingTags {
+
                refname: self.refname.to_string(),
+
                threshold: self.threshold(),
+
                candidates: votes.candidates().cloned().collect(),
+
            });
+
        }
+

+
        let tag = votes
+
            .pop_first_candidate()
+
            .ok_or(QuorumError::NoCandidates {
+
                refname: self.refname.to_string(),
+
                threshold: self.threshold(),
+
            })?;
+

+
        Ok((*tag).into())
    }

    /// Computes the quorum or "canonical" tip based on the tips, of `Canonical`,
@@ -222,41 +302,37 @@ impl<'a, 'b> Canonical<'a, 'b> {
    ///
    /// Also returns an error if `heads` is empty or `threshold` cannot be
    /// satisified with the number of heads given.
-
    pub fn quorum(self, repo: &raw::Repository) -> Result<(Qualified<'a>, Oid), QuorumError> {
-
        let mut candidates = BTreeMap::<_, usize>::new();
-

-
        // Build a list of candidate commits and count how many "votes" each of them has.
-
        // Commits get a point for each direct vote, as well as for being part of the ancestry
-
        // of a commit given to this function. Only commits given to the function are considered.
-
        for (i, head) in self.tips.values().enumerate() {
-
            // Add a direct vote for this head.
-
            *candidates.entry(*head).or_default() += 1;
-

-
            // Compare this head to all other heads ahead of it in the list.
-
            for other in self.tips.values().skip(i + 1) {
-
                // N.b. if heads are equal then skip it, otherwise it will end up as
-
                // a double vote.
-
                if *head == *other {
-
                    continue;
-
                }
-
                let base = Oid::from(repo.merge_base(**head, **other)?);
-

-
                if base == *other || base == *head {
-
                    *candidates.entry(base).or_default() += 1;
-
                }
+
    fn quorum_commit(&self, repo: &raw::Repository) -> Result<Oid, QuorumError> {
+
        let mut voting = CommitVoting::from_targets(
+
            self.tips
+
                .values()
+
                .filter_map(|(commit, kind)| kind.is_commit().then_some(*commit)),
+
        );
+
        while let Some(targets) = voting.next_candidate() {
+
            for (candidate, other) in targets {
+
                let base = Oid::from(repo.merge_base(*candidate, *other)?);
+
                voting.found_merge_base(MergeBase {
+
                    candidate,
+
                    other,
+
                    base,
+
                });
            }
        }
+
        let mut votes = voting.votes();
+

        // Keep commits which pass the threshold.
-
        candidates.retain(|_, votes| *votes >= self.threshold());
+
        votes.votes_past_threshold(self.threshold());

-
        let (mut longest, _) = candidates.pop_first().ok_or(QuorumError::NoCandidates {
-
            refname: self.refname.to_string(),
-
            threshold: self.threshold(),
-
        })?;
+
        let mut longest = votes
+
            .pop_first_candidate()
+
            .ok_or(QuorumError::NoCandidates {
+
                refname: self.refname.to_string(),
+
                threshold: self.threshold(),
+
            })?;

        // Now that all scores are calculated, figure out what is the longest branch
        // that passes the threshold. In case of divergence, return an error.
-
        for head in candidates.keys() {
+
        for head in votes.candidates() {
            let base = repo.merge_base(**head, *longest)?;

            if base == *longest {
@@ -286,7 +362,7 @@ impl<'a, 'b> Canonical<'a, 'b> {
                //            o (base)
                //            |
                //
-
                return Err(QuorumError::Diverging {
+
                return Err(QuorumError::DivergingCommits {
                    refname: self.refname.to_string(),
                    threshold: self.threshold(),
                    base: base.into(),
@@ -295,27 +371,203 @@ impl<'a, 'b> Canonical<'a, 'b> {
                });
            }
        }
-
        Ok((self.refname, (*longest).into()))
+

+
        Ok((*longest).into())
+
    }
+

+
    /// Computes the quorum or "canonical" tip based on the tips, of `Canonical`,
+
    /// and the threshold. This can be described as the latest commit that is
+
    /// included in at least `threshold` histories. In case there are multiple tips
+
    /// passing the threshold, and they are divergent, an error is returned.
+
    ///
+
    /// Also returns an error if `heads` is empty or `threshold` cannot be
+
    /// satisified with the number of heads given.
+
    pub fn quorum(
+
        self,
+
        repo: &raw::Repository,
+
    ) -> Result<(Qualified<'a>, ObjectType, Oid), QuorumError> {
+
        let (oid, kind) = match (self.quorum_commit(repo), self.quorum_tag()) {
+
            (Ok(commit), Err(_)) => Ok((commit, ObjectType::Commit)),
+
            (Err(_), Ok(tag)) => Ok((tag, ObjectType::Tag)),
+
            (Ok(_), Ok(_)) => Err(QuorumError::DifferentTypes {
+
                refname: self.refname.clone().to_string(),
+
            }),
+
            (Err(commit), Err(QuorumError::NoCandidates { .. })) => Err(commit),
+
            (Err(QuorumError::NoCandidates { .. }), Err(tag)) => Err(tag),
+
            (Err(err), _) => Err(err),
+
        }?;
+

+
        Ok((self.refname, kind, oid))
    }

    fn threshold(&self) -> usize {
        (*self.rule.threshold()).into()
    }
+
}

-
    fn ensure_commit(from: Did, commit: Oid, working: &Repository) -> Result<Oid, ConvergesError> {
-
        match working.find_commit(*commit).map(|_| commit) {
-
            Ok(oid) => Ok(oid),
-
            Err(err) if err.code() == raw::ErrorCode::NotFound => Err(
-
                ConvergesError::missing_commit(working.path().to_path_buf(), from, commit, err),
-
            ),
-
            Err(err) => Err(ConvergesError::invalid_commit(
-
                working.path().to_path_buf(),
-
                from,
-
                commit,
-
                err,
-
            )),
+
/// Keep track of [`Votes`] for quorums involving tag objects.
+
struct TagVoting {
+
    votes: Votes,
+
}
+

+
impl TagVoting {
+
    fn from_targets(targets: impl Iterator<Item = Oid>) -> Self {
+
        let votes = targets.fold(Votes::default(), |mut votes, oid| {
+
            votes.vote(oid);
+
            votes
+
        });
+
        Self { votes }
+
    }
+

+
    fn votes(self) -> Votes {
+
        self.votes
+
    }
+
}
+

+
/// Keep track of [`Votes`] for quorums involving commit objects.
+
///
+
/// Build a list of candidate commits and count how many "votes" each of them
+
/// has. Commits get a point for each direct vote, as well as for being part of
+
/// the ancestry of a commit given to this function.
+
#[derive(Debug)]
+
struct CommitVoting {
+
    candidates: Vec<(Oid, Vec<Oid>)>,
+
    votes: Votes,
+
}
+

+
impl CommitVoting {
+
    /// Build the initial set voting.
+
    fn from_targets(targets: impl Iterator<Item = Oid> + Clone) -> Self {
+
        let ts = targets.clone();
+
        let (candidates, votes) = targets.enumerate().fold(
+
            (Vec::new(), Votes::default()),
+
            |(mut candidates, mut votes), (i, oid)| {
+
                candidates.push((oid, ts.clone().skip(i + 1).collect()));
+
                votes.vote(oid);
+
                (candidates, votes)
+
            },
+
        );
+
        Self { candidates, votes }
+
    }
+

+
    /// Get the next candidate to be considered for ancestry votes.
+
    ///
+
    /// The first of each pair will be the candidate commit, which should be
+
    /// compared to the other commit to see what their common merge base is. The
+
    /// merge base is then recorded using [`MergeBase`] and is recorded using
+
    /// [`CommitVoting::found_merge_base`].
+
    fn next_candidate(&mut self) -> Option<impl Iterator<Item = (Oid, Oid)>> {
+
        self.candidates
+
            .pop()
+
            .map(|(oid, others)| others.into_iter().map(move |other| (oid, other)))
+
    }
+

+
    /// Record a merge base, and add to the vote if necessary.
+
    fn found_merge_base(
+
        &mut self,
+
        MergeBase {
+
            candidate,
+
            other,
+
            base,
+
        }: MergeBase,
+
    ) {
+
        // Avoid double counting the same commits
+
        let is_same = candidate == other;
+
        if !is_same && (base == candidate || base == other) {
+
            self.votes.vote(base);
        }
    }
+

+
    /// Finish the voting process and get the [`Votes`] from the
+
    /// [`CommitVoting`].
+
    fn votes(self) -> Votes {
+
        self.votes
+
    }
+
}
+

+
/// Record a merge base between `candidate` and `other`.
+
struct MergeBase {
+
    /// The candidate commit for the merge base.
+
    candidate: Oid,
+
    /// The commit that is being compared against for the merge base.
+
    other: Oid,
+
    /// The computed merge base commit.
+
    base: Oid,
+
}
+

+
/// Count the number of votes per [`Oid`].
+
///
+
/// Note that the count cannot exceed 255, since that is the maximum number the
+
/// `threshold` value can be.
+
#[derive(Debug, Default, PartialEq, Eq)]
+
struct Votes {
+
    inner: BTreeMap<Oid, u8>,
+
}
+

+
impl Votes {
+
    /// Increase the vote count for `oid`.
+
    ///
+
    /// If `oid` does not exist in the set of [`Votes`] yet, then no vote will
+
    /// be added.
+
    #[inline]
+
    fn vote(&mut self, oid: Oid) {
+
        self.safe_inc(oid, 1);
+
    }
+

+
    /// Filter the candidates by the ones that have a number of votes that pass
+
    /// the `threshold`.
+
    #[inline]
+
    fn votes_past_threshold(&mut self, threshold: usize) {
+
        self.inner.retain(|_, votes| *votes as usize >= threshold);
+
    }
+

+
    /// Get the number of candidates this set of votes has.
+
    #[inline]
+
    fn number_of_candidates(&self) -> usize {
+
        self.inner.len()
+
    }
+

+
    /// Get the set candidates.
+
    #[inline]
+
    fn candidates(&self) -> impl Iterator<Item = &Oid> {
+
        self.inner.keys()
+
    }
+

+
    /// Pop off the first candidate from the set of votes.
+
    #[inline]
+
    fn pop_first_candidate(&mut self) -> Option<Oid> {
+
        self.inner.pop_first().map(|(oid, _)| oid)
+
    }
+

+
    #[inline]
+
    fn safe_inc(&mut self, oid: Oid, n: u8) {
+
        let votes = self.inner.entry(oid).or_default();
+
        *votes = votes.saturating_add(n);
+
    }
+
}
+

+
fn find_object_for(
+
    did: &Did,
+
    oid: Oid,
+
    repo: &raw::Repository,
+
) -> Result<CanonicalObject, FindObjectError> {
+
    match repo.find_object(*oid, None) {
+
        Ok(object) => object.kind().and_then(CanonicalObject::new).ok_or_else(|| {
+
            FindObjectError::invalid_object_type(
+
                repo.path().to_path_buf(),
+
                *did,
+
                oid,
+
                object.kind(),
+
            )
+
        }),
+
        Err(err) if super::ext::is_not_found_err(&err) => Err(FindObjectError::missing_object(
+
            repo.path().to_path_buf(),
+
            *did,
+
            oid,
+
            err,
+
        )),
+
        Err(err) => Err(FindObjectError::find_object(oid, err)),
+
    }
}

#[cfg(test)]
@@ -334,13 +586,19 @@ mod tests {
        threshold: usize,
        repo: &git::raw::Repository,
    ) -> Result<Oid, QuorumError> {
-
        let tips: BTreeMap<Did, Oid> = heads
+
        let tips: BTreeMap<Did, (Oid, CanonicalObject)> = heads
            .iter()
            .enumerate()
            .map(|(i, head)| {
                let signer = Device::mock_from_seed([(i + 1) as u8; 32]);
                let did = Did::from(signer.public_key());
-
                (did, (*head).into())
+
                let kind = repo
+
                    .find_object(*head, None)
+
                    .unwrap()
+
                    .kind()
+
                    .and_then(CanonicalObject::new)
+
                    .unwrap();
+
                (did, ((*head).into(), kind))
            })
            .collect();

@@ -360,7 +618,7 @@ mod tests {
            rule: &rule,
        }
        .quorum(repo)
-
        .map(|(_, oid)| oid)
+
        .map(|(_, _, oid)| oid)
    }

    #[test]
@@ -398,6 +656,62 @@ mod tests {
    }

    #[test]
+
    fn test_quorum_groups() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let (repo, c0) = fixtures::repository(tmp.path());
+
        let c0: git::Oid = c0.into();
+
        let c1 = fixtures::commit("C1", &[*c0], &repo);
+
        let c2 = fixtures::commit("C2", &[*c0], &repo);
+

+
        eprintln!("C0: {c0}");
+
        eprintln!("C1: {c1}");
+
        eprintln!("C2: {c2}");
+

+
        assert_matches!(
+
            quorum(&[*c1, *c2, *c1, *c2], 2, &repo),
+
            Err(QuorumError::DivergingCommits { .. })
+
        );
+

+
        assert_matches!(
+
            quorum(&[*c1, *c2], 1, &repo),
+
            Err(QuorumError::DivergingCommits { .. })
+
        );
+
    }
+

+
    #[test]
+
    fn test_quorum_tag() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let (repo, c0) = fixtures::repository(tmp.path());
+
        let c0: git::Oid = c0.into();
+
        let c1 = fixtures::commit("C1", &[*c0], &repo);
+
        let t1 = fixtures::tag("v1", "T1", *c1, &repo);
+
        let t2 = fixtures::tag("v2", "T2", *c1, &repo);
+

+
        eprintln!("C0: {c0}");
+
        eprintln!("C1: {c1}");
+
        eprintln!("T1: {t1}");
+
        eprintln!("T2: {t2}");
+

+
        assert_eq!(quorum(&[*t1], 1, &repo).unwrap(), t1);
+
        assert_eq!(quorum(&[*t1, *t1], 2, &repo).unwrap(), t1);
+

+
        assert_matches!(
+
            quorum(&[*t1, *t2], 2, &repo),
+
            Err(QuorumError::NoCandidates { .. })
+
        );
+

+
        assert_matches!(
+
            quorum(&[*t1, *c1], 1, &repo),
+
            Err(QuorumError::DifferentTypes { .. })
+
        );
+

+
        assert_matches!(
+
            quorum(&[*t1, *t2], 1, &repo),
+
            Err(QuorumError::DivergingTags { .. })
+
        );
+
    }
+

+
    #[test]
    fn test_quorum() {
        let tmp = tempfile::tempdir().unwrap();
        let (repo, c0) = fixtures::repository(tmp.path());
@@ -447,15 +761,15 @@ mod tests {
        //  C0
        assert_matches!(
            quorum(&[*c1, *c2, *b2], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
+
            Err(QuorumError::DivergingCommits { .. })
        );
        assert_matches!(
            quorum(&[*c2, *b2], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
+
            Err(QuorumError::DivergingCommits { .. })
        );
        assert_matches!(
            quorum(&[*b2, *c2], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
+
            Err(QuorumError::DivergingCommits { .. })
        );
        assert_matches!(
            quorum(&[*c2, *b2], 2, &repo),
@@ -471,7 +785,7 @@ mod tests {
        assert_eq!(quorum(&[*b2, *c2, *c2], 2, &repo).unwrap(), c2);
        assert_matches!(
            quorum(&[*b2, *b2, *c2, *c2], 2, &repo),
-
            Err(QuorumError::Diverging { .. })
+
            Err(QuorumError::DivergingCommits { .. })
        );

        // B2 C2 C3
@@ -500,7 +814,7 @@ mod tests {
        //   C0
        assert_matches!(
            quorum(&[*c2, *b2, *a1], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
+
            Err(QuorumError::DivergingCommits { .. })
        );
        assert_matches!(
            quorum(&[*c2, *b2, *a1], 2, &repo),
@@ -520,23 +834,23 @@ mod tests {
        assert_eq!(quorum(&[*c0, *c1, *c2, *b2, *a1], 4, &repo).unwrap(), c0,);
        assert_matches!(
            quorum(&[*a1, *a1, *c2, *c2, *c1], 2, &repo),
-
            Err(QuorumError::Diverging { .. })
+
            Err(QuorumError::DivergingCommits { .. })
        );
        assert_matches!(
            quorum(&[*a1, *a1, *c2, *c2, *c1], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
+
            Err(QuorumError::DivergingCommits { .. })
        );
        assert_matches!(
            quorum(&[*a1, *a1, *c2], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
+
            Err(QuorumError::DivergingCommits { .. })
        );
        assert_matches!(
            quorum(&[*b2, *b2, *c2, *c2], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
+
            Err(QuorumError::DivergingCommits { .. })
        );
        assert_matches!(
            quorum(&[*b2, *b2, *c2, *c2, *a1], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
+
            Err(QuorumError::DivergingCommits { .. })
        );

        //    M2  M1
@@ -546,31 +860,31 @@ mod tests {
        //     A1 C1
        //       \|
        //       C0
-
        assert_eq!(quorum(&[*m1], 1, &repo).unwrap(), m1);
-
        assert_matches!(
-
            quorum(&[*m1, *m2], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
-
        );
-
        assert_matches!(
-
            quorum(&[*m2, *m1], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
-
        );
-
        assert_matches!(
-
            quorum(&[*m1, *m2], 2, &repo),
-
            Err(QuorumError::NoCandidates { .. })
-
        );
-
        assert_matches!(
-
            quorum(&[*m1, *m2, *c2], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
-
        );
-
        assert_matches!(
-
            quorum(&[*m1, *a1], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
-
        );
-
        assert_matches!(
-
            quorum(&[*m1, *a1], 2, &repo),
-
            Err(QuorumError::NoCandidates { .. })
-
        );
+
        // assert_eq!(quorum(&[*m1], 1, &repo).unwrap(), m1);
+
        // assert_matches!(
+
        //     quorum(&[*m1, *m2], 1, &repo),
+
        //     Err(QuorumError::DivergingCommits { .. })
+
        // );
+
        // assert_matches!(
+
        //     quorum(&[*m2, *m1], 1, &repo),
+
        //     Err(QuorumError::DivergingCommits { .. })
+
        // );
+
        // assert_matches!(
+
        //     quorum(&[*m1, *m2], 2, &repo),
+
        //     Err(QuorumError::NoCandidates { .. })
+
        // );
+
        // assert_matches!(
+
        //     quorum(&[*m1, *m2, *c2], 1, &repo),
+
        //     Err(QuorumError::DivergingCommits { .. })
+
        // );
+
        // assert_matches!(
+
        //     quorum(&[*m1, *a1], 1, &repo),
+
        //     Err(QuorumError::DivergingCommits { .. })
+
        // );
+
        // assert_matches!(
+
        //     quorum(&[*m1, *a1], 2, &repo),
+
        //     Err(QuorumError::NoCandidates { .. })
+
        // );
        assert_eq!(quorum(&[*m1, *m2, *b2, *c1], 4, &repo).unwrap(), c1);
        assert_eq!(quorum(&[*m1, *m1, *b2], 2, &repo).unwrap(), m1);
        assert_eq!(quorum(&[*m1, *m1, *c2], 2, &repo).unwrap(), m1);
@@ -608,7 +922,7 @@ mod tests {
        //      C0
        assert_matches!(
            quorum(&[*m1, *m2], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
+
            Err(QuorumError::DivergingCommits { .. })
        );
        assert_matches!(
            quorum(&[*m1, *m2], 2, &repo),
@@ -624,7 +938,7 @@ mod tests {
        //      C0
        assert_matches!(
            quorum(&[*m1, *m3], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
+
            Err(QuorumError::DivergingCommits { .. })
        );
        assert_matches!(
            quorum(&[*m1, *m3], 2, &repo),
@@ -632,7 +946,7 @@ mod tests {
        );
        assert_matches!(
            quorum(&[*m3, *m1], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
+
            Err(QuorumError::DivergingCommits { .. })
        );
        assert_matches!(
            quorum(&[*m3, *m1], 2, &repo),
@@ -640,7 +954,7 @@ mod tests {
        );
        assert_matches!(
            quorum(&[*m3, *m2], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
+
            Err(QuorumError::DivergingCommits { .. })
        );
        assert_matches!(
            quorum(&[*m3, *m2], 2, &repo),
added crates/radicle/src/git/canonical/error.rs
@@ -0,0 +1,204 @@
+
use std::path::PathBuf;
+

+
use thiserror::Error;
+

+
use crate::{git::raw, git::Oid, prelude::Did};
+

+
use super::CanonicalObject;
+

+
/// Error that can occur when calculation the [`Canonical::quorum`].
+
#[derive(Debug, Error)]
+
pub enum QuorumError {
+
    /// Could not determine a quorum [`Oid`], due to diverging tips.
+
    #[error("could not determine target commit for canonical reference '{refname}', found diverging commits {longest} and {head}, with base commit {base} and threshold {threshold}")]
+
    DivergingCommits {
+
        refname: String,
+
        threshold: usize,
+
        base: Oid,
+
        longest: Oid,
+
        head: Oid,
+
    },
+
    #[error("could not determine target tag for canonical reference '{refname}', found multiple candidates with threshold {threshold}")]
+
    DivergingTags {
+
        refname: String,
+
        threshold: usize,
+
        candidates: Vec<Oid>,
+
    },
+
    #[error("could not determine target for canonical reference '{refname}', found objects of different types")]
+
    DifferentTypes { refname: String },
+
    /// Could not determine a base candidate from the given set of delegates.
+
    #[error("could not determine target for canonical reference '{refname}', no object with at least {threshold} vote(s) found (threshold not met)")]
+
    NoCandidates { refname: String, threshold: usize },
+
    /// An error occurred from [`git2`].
+
    #[error(transparent)]
+
    Git(#[from] git2::Error),
+
}
+

+
#[derive(Debug, Error)]
+
#[error("failed to check if {head} is an ancestor of {canonical} due to: {source}")]
+
pub struct GraphDescendant {
+
    head: Oid,
+
    canonical: Oid,
+
    source: raw::Error,
+
}
+

+
#[derive(Debug, Error)]
+
#[error("the commit {commit} for {did} is missing from the repository {repo:?}")]
+
pub struct MissingObject {
+
    repo: PathBuf,
+
    did: Did,
+
    commit: Oid,
+
    source: raw::Error,
+
}
+

+
#[derive(Debug, Error)]
+
#[error("could not determine whether the commit {commit} for {did} is part of the repository {repo:?} due to: {source}")]
+
pub struct InvalidObject {
+
    repo: PathBuf,
+
    did: Did,
+
    commit: Oid,
+
    source: raw::Error,
+
}
+

+
#[derive(Debug, Error)]
+
#[error("the object {oid} for {did} in the repository {repo:?} is of unexpected type {kind:?}")]
+
pub struct InvalidObjectType {
+
    repo: PathBuf,
+
    did: Did,
+
    oid: Oid,
+
    kind: Option<git2::ObjectType>,
+
}
+

+
#[derive(Debug, Error)]
+
#[error("the object {oid} in the repository {repo:?} is of unexpected type {found} and was expected to be {expected}")]
+
pub struct MismatchedObject {
+
    repo: PathBuf,
+
    oid: Oid,
+
    found: CanonicalObject,
+
    expected: CanonicalObject,
+
}
+

+
#[derive(Debug, Error)]
+
pub enum CanonicalError {
+
    #[error(transparent)]
+
    InvalidObjectType(#[from] InvalidObjectType),
+
    #[error(transparent)]
+
    MissingObject(#[from] MissingObject),
+
    #[error("failed to find object {oid} due to: {source}")]
+
    FindObject { oid: Oid, source: git2::Error },
+
    #[error("failed to find reference {name} due to: {source}")]
+
    FindReference { name: String, source: git2::Error },
+
}
+

+
impl CanonicalError {
+
    pub(super) fn invalid_object_type(
+
        repo: PathBuf,
+
        did: Did,
+
        oid: Oid,
+
        kind: Option<git2::ObjectType>,
+
    ) -> Self {
+
        InvalidObjectType {
+
            repo,
+
            did,
+
            oid,
+
            kind,
+
        }
+
        .into()
+
    }
+

+
    pub(super) fn missing_object(repo: PathBuf, did: Did, oid: Oid, err: git2::Error) -> Self {
+
        MissingObject {
+
            repo,
+
            did,
+
            commit: oid,
+
            source: err,
+
        }
+
        .into()
+
    }
+

+
    pub(super) fn find_object(oid: Oid, err: git2::Error) -> Self {
+
        Self::FindObject { oid, source: err }
+
    }
+

+
    pub(crate) fn find_reference(name: &str, e: git2::Error) -> CanonicalError {
+
        Self::FindReference {
+
            name: name.to_string(),
+
            source: e,
+
        }
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
pub enum FindObjectError {
+
    #[error(transparent)]
+
    InvalidObjectType(#[from] InvalidObjectType),
+
    #[error(transparent)]
+
    MissingObject(#[from] MissingObject),
+
    #[error("failed to find object {oid} due to: {source}")]
+
    FindObject { oid: Oid, source: git2::Error },
+
}
+

+
impl FindObjectError {
+
    pub(super) fn find_object(oid: Oid, err: git2::Error) -> Self {
+
        Self::FindObject { oid, source: err }
+
    }
+

+
    pub(super) fn missing_object(repo: PathBuf, did: Did, oid: Oid, err: git2::Error) -> Self {
+
        MissingObject {
+
            repo,
+
            did,
+
            commit: oid,
+
            source: err,
+
        }
+
        .into()
+
    }
+

+
    pub(super) fn invalid_object_type(
+
        repo: PathBuf,
+
        did: Did,
+
        oid: Oid,
+
        kind: Option<git2::ObjectType>,
+
    ) -> Self {
+
        InvalidObjectType {
+
            repo,
+
            did,
+
            oid,
+
            kind,
+
        }
+
        .into()
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
pub enum ConvergesError {
+
    #[error(transparent)]
+
    GraphDescendant(#[from] GraphDescendant),
+
    #[error(transparent)]
+
    MismatchedObject(#[from] MismatchedObject),
+
    #[error(transparent)]
+
    FindObjectError(#[from] FindObjectError),
+
}
+

+
impl ConvergesError {
+
    pub(super) fn graph_descendant(head: Oid, canonical: Oid, source: raw::Error) -> Self {
+
        Self::GraphDescendant(GraphDescendant {
+
            head,
+
            canonical,
+
            source,
+
        })
+
    }
+

+
    pub(super) fn mismatched_object(
+
        repo: PathBuf,
+
        oid: Oid,
+
        found: CanonicalObject,
+
        expected: CanonicalObject,
+
    ) -> Self {
+
        Self::MismatchedObject(MismatchedObject {
+
            repo,
+
            oid,
+
            found,
+
            expected,
+
        })
+
    }
+
}
modified crates/radicle/src/git/canonical/rules.rs
@@ -20,6 +20,7 @@ use serde_json as json;
use thiserror::Error;

use crate::git;
+
use crate::git::canonical;
use crate::git::canonical::Canonical;
use crate::git::fmt::{refname, RefString};
use crate::git::refspec::QualifiedPattern;
@@ -634,9 +635,9 @@ impl Rules {
        &'a self,
        refname: Qualified<'b>,
        repo: &Repository,
-
    ) -> Result<Option<Canonical<'b, 'a>>, git::raw::Error> {
+
    ) -> Result<Option<Canonical<'b, 'a>>, canonical::error::CanonicalError> {
        if let Some((_, rule)) = self.matches(&refname).next() {
-
            Ok(Some(Canonical::new(repo, refname, rule)?))
+
            Ok(Some(Canonical::new(&repo.backend, refname, rule)?))
        } else {
            Ok(None)
        }
@@ -1206,7 +1207,7 @@ mod tests {
                    canonical
                        .quorum(&repo)
                        .unwrap_or_else(|e| panic!("quorum error for {refname}: {e}")),
-
                    (refname, oid),
+
                    (refname, git::raw::ObjectType::Tag, oid),
                )
            }
        }
modified crates/radicle/src/storage.rs
@@ -117,7 +117,7 @@ pub enum RepositoryError {
    #[error(transparent)]
    GitExt(#[from] git_ext::Error),
    #[error(transparent)]
-
    Quorum(#[from] canonical::QuorumError),
+
    Quorum(#[from] canonical::error::QuorumError),
    #[error(transparent)]
    Refs(#[from] refs::Error),
    #[error("missing canonical reference rule for default branch")]
@@ -126,6 +126,8 @@ pub enum RepositoryError {
    DefaultBranchRule(#[from] doc::DefaultBranchRuleError),
    #[error("failed to get canonical reference rules: {0}")]
    CanonicalRefs(#[from] doc::CanonicalRefsError),
+
    #[error(transparent)]
+
    Canonical(#[from] canonical::error::CanonicalError),
}

impl RepositoryError {
modified crates/radicle/src/storage/git.rs
@@ -720,14 +720,17 @@ impl ReadRepository for Repository {

        for r in self.backend.references_glob(pattern)? {
            let r = r?;
-
            let c = r.peel_to_commit()?;
+

+
            let Some(oid) = r.resolve()?.target() else {
+
                continue;
+
            };

            if let Some(name) = r
                .name()
                .and_then(|n| git::RefStr::try_from_str(n).ok())
                .and_then(git::Qualified::from_refstr)
            {
-
                refs.push((name.to_owned(), c.id().into()));
+
                refs.push((name.to_owned(), oid.into()));
            }
        }
        Ok(refs)
@@ -761,6 +764,7 @@ impl ReadRepository for Repository {
            .canonical(refname, self)?
            .ok_or(RepositoryError::MissingBranchRule)?
            .quorum(self.raw())?)
+
        .map(|(refname, _, oid)| (refname, oid))
    }

    fn identity_head(&self) -> Result<Oid, RepositoryError> {
modified crates/radicle/src/test/fixtures.rs
@@ -148,6 +148,22 @@ pub fn commit(msg: &str, parents: &[git2::Oid], repo: &git2::Repository) -> git:
        .into()
}

+
/// Create an (annotated) tag of the given commit.
+
pub fn tag(name: &str, message: &str, commit: git2::Oid, repo: &git2::Repository) -> git::Oid {
+
    let target = repo
+
        .find_object(commit, Some(git2::ObjectType::Commit))
+
        .unwrap();
+
    let tagger = git2::Signature::new(
+
        "anonymous",
+
        "anonymous@radicle.xyz",
+
        &git2::Time::new(RADICLE_EPOCH, 0),
+
    )
+
    .unwrap();
+
    repo.tag(name, &target, &tagger, message, false)
+
        .unwrap()
+
        .into()
+
}
+

/// Populate a repository with commits, branches and blobs.
pub fn populate(repo: &git2::Repository, scale: usize) -> Vec<git::Qualified> {
    assert!(