Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
crefs: Support annotated tags
Lorenz Leutgeb committed 10 months ago
commit 4cf768a7d7e073699a7dd430d00d30ac8811d741
parent fdb1ac4e3a01030ab4eb4415db68d5bc1c719718
23 files changed +860 -338
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
@@ -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 f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
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/worker/fetch.rs
@@ -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-remote-helper/src/push.rs
@@ -280,7 +280,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 +349,9 @@ 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();
+
                            let canonical =
+
                                canonical::Canonical::new(me, *src, kind.unwrap(), canonical);
                            match canonical.quorum(&working) {
                                Ok(quorum) => set_canonical_refs.push(quorum),
                                Err(e) => canonical::io::handle_error(e, &dst, hints)?,
@@ -375,10 +378,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::raw::ObjectType,
}

/// 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::raw::ObjectType,
+
        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()),
@@ -110,11 +115,21 @@ pub(crate) mod io {
                Err(e.into())
            }
            error::Canonical::Quorum(e) => match e {
-
                e @ canonical::QuorumError::Diverging { .. } => {
+
                e @ canonical::QuorumError::DivergingCommits { .. } => {
                    warn(e.to_string());
                    warn("it is recommended to find a commit to agree upon");
                    Ok(())
                }
+
                e @ canonical::QuorumError::DivergingTags { .. } => {
+
                    warn(e.to_string());
+
                    warn("it is recommended to find a tag to agree upon");
+
                    Ok(())
+
                }
+
                e @ canonical::QuorumError::DifferentTypes { .. } => {
+
                    warn(e.to_string());
+
                    warn("it is recommended to find an object type (either commit or tag) to agree upon");
+
                    Ok(())
+
                }
                e @ canonical::QuorumError::NoCandidates { .. } => {
                    warn(e.to_string());
                    warn("it is recommended to find a commit to agree upon");
modified crates/radicle/src/git/canonical.rs
@@ -1,14 +1,15 @@
pub mod rules;
pub use rules::{MatchedRule, RawRule, Rules, ValidRule};

+
use std::cell::Cell;
use std::collections::BTreeMap;
use std::path::PathBuf;

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

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

use super::raw;
use super::{Oid, Qualified};
@@ -28,23 +29,31 @@ use super::{Oid, Qualified};
pub struct Canonical<'a, 'b> {
    refname: Qualified<'a>,
    rule: &'b ValidRule,
-
    tips: BTreeMap<Did, Oid>,
+
    tips: BTreeMap<Did, (Oid, git2::ObjectType)>,
}

/// 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 {
+
    #[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 commit for canonical reference '{refname}', no commit with at least {threshold} vote(s) found (threshold not met)")]
+
    #[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)]
@@ -61,7 +70,7 @@ pub struct GraphDescendant {

#[derive(Debug, Error)]
#[error("the commit {commit} for {did} is missing from the repository {repo:?}")]
-
pub struct MissingCommit {
+
pub struct MissingObject {
    repo: PathBuf,
    did: Did,
    commit: Oid,
@@ -70,7 +79,7 @@ pub struct MissingCommit {

#[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 {
+
pub struct InvalidObject {
    repo: PathBuf,
    did: Did,
    commit: Oid,
@@ -78,13 +87,24 @@ pub struct InvalidCommit {
}

#[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)]
pub enum ConvergesError {
    #[error(transparent)]
    GraphDescendant(#[from] GraphDescendant),
    #[error(transparent)]
-
    MissingCommit(#[from] MissingCommit),
+
    MissingObject(#[from] MissingObject),
+
    #[error(transparent)]
+
    InvalidObject(#[from] InvalidObject),
    #[error(transparent)]
-
    InvalidCommit(#[from] InvalidCommit),
+
    InvalidObjectType(#[from] InvalidObjectType),
}

impl ConvergesError {
@@ -96,8 +116,8 @@ impl ConvergesError {
        })
    }

-
    pub fn missing_commit(repo: PathBuf, did: Did, commit: Oid, err: raw::Error) -> Self {
-
        Self::MissingCommit(MissingCommit {
+
    pub fn missing_object(repo: PathBuf, did: Did, commit: Oid, err: raw::Error) -> Self {
+
        Self::MissingObject(MissingObject {
            repo,
            did,
            commit,
@@ -105,38 +125,64 @@ impl ConvergesError {
        })
    }

-
    pub fn invalid_commit(repo: PathBuf, did: Did, commit: Oid, err: raw::Error) -> Self {
-
        Self::InvalidCommit(InvalidCommit {
+
    pub fn invalid_object(repo: PathBuf, did: Did, commit: Oid, err: raw::Error) -> Self {
+
        Self::InvalidObject(InvalidObject {
            repo,
            did,
            commit,
            source: err,
        })
    }
+

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

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, raw::Error> {
        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()
                    );
+
                    continue;
                }
                Err(e) => return Err(e),
-
            }
+
            };
+

+
            let Some(oid) = reference.target() else {
+
                continue;
+
            };
+

+
            let Some(kind) = repo.find_object(oid, None)?.kind() else {
+
                continue;
+
            };
+

+
            tips.insert(*delegate, (oid.into(), kind));
        }
        Ok(Canonical {
            refname,
@@ -146,7 +192,7 @@ 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)> {
+
    pub fn tips(&self) -> impl Iterator<Item = (&Did, &(Oid, git2::ObjectType))> {
        self.tips.iter()
    }

@@ -165,7 +211,7 @@ 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) {
+
    pub fn modify_vote(&mut self, did: Did, new: (Oid, git2::ObjectType)) {
        self.tips.insert(did, new);
    }

@@ -191,30 +237,85 @@ impl<'a, 'b> Canonical<'a, 'b> {
        repo: &Repository,
        (candidate, commit): (&Did, &Oid),
    ) -> Result<bool, ConvergesError> {
+
        let mut common_kind = ObjectType::Any;
+

        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 result = Vec::with_capacity(heads.size_hint().0);
+

+
            for (did, (oid, kind)) in heads {
+
                if common_kind == ObjectType::Any {
+
                    common_kind = *kind;
+
                } else if common_kind != *kind {
+
                    return Err(ConvergesError::invalid_object_kind(
+
                        repo.path().to_path_buf(),
+
                        *did,
+
                        *oid,
+
                        Some(*kind),
+
                    ));
+
                }
+
                result.push(Self::ensure_commit_or_tag(*did, *oid, repo)?);
+
            }
+

+
            result
        };
-
        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);
+

+
        if common_kind == ObjectType::Commit {
+
            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);
+
                }
            }
+
        } else {
+
            return Ok(true);
        }
        Ok(false)
    }

+
    fn filter_candidates(&self, kind: raw::ObjectType) -> BTreeMap<Oid, Cell<usize>> {
+
        let filtered = self
+
            .tips
+
            .values()
+
            .filter_map(|(tip_oid, tip_kind)| (kind == *tip_kind).then_some(tip_oid));
+

+
        let mut candidates = BTreeMap::<_, Cell<usize>>::new();
+
        candidates = filtered.fold(candidates, |mut candidates, oid| {
+
            candidates.entry(*oid).or_default().update(|x| x + 1);
+
            candidates
+
        });
+

+
        candidates
+
    }
+

+
    fn quorum_tag(&self) -> Result<Oid, QuorumError> {
+
        let mut candidates = self.filter_candidates(raw::ObjectType::Tag);
+

+
        // Keep tags which pass the threshold.
+
        candidates.retain(|_, votes| votes.get() >= self.threshold());
+

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

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

+
        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
@@ -222,32 +323,29 @@ 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();
+
    fn quorum_commit(&self, repo: &raw::Repository) -> Result<Oid, QuorumError> {
+
        let mut candidates = self.filter_candidates(raw::ObjectType::Commit);

        // 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;
-

+
        for (i, head) in candidates.keys().enumerate() {
            // Compare this head to all other heads ahead of it in the list.
-
            for other in self.tips.values().skip(i + 1) {
+
            for other in candidates.keys().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;
-
                }
+
                debug_assert!(*head != *other);
+

                let base = Oid::from(repo.merge_base(**head, **other)?);

                if base == *other || base == *head {
-
                    *candidates.entry(base).or_default() += 1;
+
                    candidates.get(&base).unwrap().update(|votes| votes + 1);
                }
            }
        }
+

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

        let (mut longest, _) = candidates.pop_first().ok_or(QuorumError::NoCandidates {
            refname: self.refname.to_string(),
@@ -286,7 +384,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,23 +393,68 @@ 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(
+
    fn ensure_commit_or_tag(
+
        from: Did,
+
        commit_or_tag: Oid,
+
        working: &Repository,
+
    ) -> Result<(Oid, ObjectType), ConvergesError> {
+
        match working.find_object(*commit_or_tag, None) {
+
            Ok(object) => match object.kind() {
+
                Some(ObjectType::Commit) | Some(ObjectType::Tag) => {
+
                    Ok((object.id().into(), object.kind().unwrap()))
+
                }
+
                kind => Err(ConvergesError::invalid_object_kind(
+
                    working.path().to_path_buf(),
+
                    from,
+
                    commit_or_tag,
+
                    kind,
+
                )),
+
            },
+
            Err(err) if err.code() == raw::ErrorCode::NotFound => {
+
                Err(ConvergesError::missing_object(
+
                    working.path().to_path_buf(),
+
                    from,
+
                    commit_or_tag,
+
                    err,
+
                ))
+
            }
+
            Err(err) => Err(ConvergesError::invalid_object(
                working.path().to_path_buf(),
                from,
-
                commit,
+
                commit_or_tag,
                err,
            )),
        }
@@ -334,13 +477,14 @@ mod tests {
        threshold: usize,
        repo: &git::raw::Repository,
    ) -> Result<Oid, QuorumError> {
-
        let tips: BTreeMap<Did, Oid> = heads
+
        let tips: BTreeMap<Did, (Oid, git2::ObjectType)> = 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().unwrap();
+
                (did, ((*head).into(), kind))
            })
            .collect();

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

    #[test]
@@ -398,6 +542,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 +647,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 +671,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 +700,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 +720,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
@@ -549,11 +749,11 @@ mod tests {
        assert_eq!(quorum(&[*m1], 1, &repo).unwrap(), m1);
        assert_matches!(
            quorum(&[*m1, *m2], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
+
            Err(QuorumError::DivergingCommits { .. })
        );
        assert_matches!(
            quorum(&[*m2, *m1], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
+
            Err(QuorumError::DivergingCommits { .. })
        );
        assert_matches!(
            quorum(&[*m1, *m2], 2, &repo),
@@ -561,11 +761,11 @@ mod tests {
        );
        assert_matches!(
            quorum(&[*m1, *m2, *c2], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
+
            Err(QuorumError::DivergingCommits { .. })
        );
        assert_matches!(
            quorum(&[*m1, *a1], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
+
            Err(QuorumError::DivergingCommits { .. })
        );
        assert_matches!(
            quorum(&[*m1, *a1], 2, &repo),
@@ -608,7 +808,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 +824,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 +832,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 +840,7 @@ mod tests {
        );
        assert_matches!(
            quorum(&[*m3, *m2], 1, &repo),
-
            Err(QuorumError::Diverging { .. })
+
            Err(QuorumError::DivergingCommits { .. })
        );
        assert_matches!(
            quorum(&[*m3, *m2], 2, &repo),
modified crates/radicle/src/git/canonical/rules.rs
@@ -636,7 +636,7 @@ impl Rules {
        repo: &Repository,
    ) -> Result<Option<Canonical<'b, 'a>>, git::raw::Error> {
        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 +1206,7 @@ mod tests {
                    canonical
                        .quorum(&repo)
                        .unwrap_or_else(|e| panic!("quorum error for {refname}: {e}")),
-
                    (refname, oid),
+
                    (refname, git::raw::ObjectType::Commit, oid),
                )
            }
        }
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!(