canonical: Support Annotated Tags

The logic for computing canonical references conflated the semantics of
annotated and lighweight tags, yielding confusing/wrong results. The
main culprit was the call to `peel_to_commit` in
`impl ReadRepository for Repository`, peeling tag objects away.

To resolve this, we separate out quroum computation per object type: one
implementation for commits, and one for tags.

Also move  move canonical error types and methods into their own
module to have a cleaner file structure for the main logic.

Capture the `BTreeMap<Oid, u8>` type into a `Votes` struct, with its own API.

We were getting the objects from the repository twice – once in `Canonical::new`
and once again in `ensure_commit_or_tag` – so the latter was removed.

Use an `enum CanonicalObject` to specify which object types the canonical
process supports.

During `converges`, separate commits and tags, and ensure that we are only
looking at objects of one type. Note, that Fintan think this is actually the
*wrong* place to do it because we already filter the objects – so we should
keep track of that information higher up the callstack.

Co-authored-by: Fintan Halpenny <fintan.halpenny@gmail.com>
This commit is contained in:
Lorenz Leutgeb 2025-07-23 22:04:40 +02:00
parent 54fd8c40a0
commit 3d352f23e6
27 changed files with 1108 additions and 249 deletions

View File

@ -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)

View File

@ -0,0 +1,231 @@
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 an annotated tag and push it:
``` ~alice
$ git tag -a -m "Hotfix for release 1" v1.0-hotfix
$ git cat-file -t v1.0-hotfix
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 an object (either commit or tag) 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
```

View File

@ -1,5 +1,5 @@
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
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:
~~~
@ -95,7 +95,7 @@ $ git tag v1.0-hotfix
``` ~alice (stderr)
$ git push rad --tags
✓ Canonical reference refs/tags/v1.0-hotfix updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
✓ 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
@ -105,7 +105,7 @@ Notice that the output included a message about a canonical reference being
updated:
~~~
✓ Canonical reference refs/tags/v1.0-hotfix updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
✓ 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:
@ -118,6 +118,13 @@ From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji
* [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
@ -164,8 +171,8 @@ 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
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 an object (either commit or tag) to agree upon
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
* [new tag] v1.0-hotfix -> v1.0-hotfix
@ -182,14 +189,16 @@ From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WR
* [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 checkout bob/tags/v2.0
$ git tag v2.0
$ git tag v2.0 bob/tags/v2.0
```
``` ~alice (stderr)
$ git push rad --tags
✓ Canonical reference refs/tags/v2.0 updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
✓ 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
@ -206,7 +215,7 @@ reference is created:
``` ~bob (stderr)
$ git tag qa/v2.1
$ git push rad --tags
✓ Canonical reference refs/tags/qa/v2.1 updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
✓ 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

View File

@ -91,8 +91,8 @@ 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: it is recommended to find a commit to agree upon
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 an object (either commit or tag) to agree upon
✓ Synced with 2 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
d09e634..0f9bd80 master -> master
@ -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

View File

@ -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)
```

View File

@ -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)

View File

@ -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
```

View File

@ -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
```

View File

@ -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
```

View File

@ -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)
```

View File

@ -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
```

View File

@ -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

View File

@ -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)
```

View File

@ -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

View File

@ -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(

View File

@ -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.

View File

@ -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,

View File

@ -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.

View File

@ -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::CanonicalObjectType::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),

View File

@ -6,7 +6,8 @@ use super::error;
pub(crate) struct Vote {
did: Did,
commit: git::Oid,
oid: git::Oid,
kind: git::canonical::CanonicalObjectType,
}
/// 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::CanonicalObjectType,
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,29 @@ 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(())
}
canonical::QuorumError::Git(err) => {
Err(error::CanonicalUnrecoverable::Git { source: err })
e @ QuorumError::DifferentTypes { .. } => {
warn(e.to_string());
warn("it is recommended to find an object type (either commit or tag) to agree upon");
Ok(())
}
e @ QuorumError::NoCandidates { .. } => {
warn(e.to_string());
warn(
"it is recommended to find an object (either commit or tag) to agree upon",
);
Ok(())
}
QuorumError::Git(err) => Err(error::CanonicalUnrecoverable::Git { source: err }),
},
}
}

View File

@ -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 {

View File

@ -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,70 @@ use super::{Oid, Qualified};
pub struct Canonical<'a, 'b> {
refname: Qualified<'a>,
rule: &'b ValidRule,
tips: BTreeMap<Did, Oid>,
tips: BTreeMap<Did, (Oid, CanonicalObjectType)>,
}
/// 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),
/// Supported Git object types for canonical computation.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CanonicalObjectType {
/// The Git object is a commit.
Commit,
/// The Git object is a tag.
Tag,
}
#[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 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 ConvergesError {
pub fn graph_descendant(head: Oid, canonical: Oid, source: raw::Error) -> Self {
Self::GraphDescendant(GraphDescendant {
head,
canonical,
source,
})
impl fmt::Display for CanonicalObjectType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CanonicalObjectType::Commit => f.write_str("commit"),
CanonicalObjectType::Tag => f.write_str("tag"),
}
pub fn missing_commit(repo: PathBuf, did: Did, commit: Oid, err: raw::Error) -> Self {
Self::MissingCommit(MissingCommit {
repo,
did,
commit,
source: err,
})
}
}
pub fn invalid_commit(repo: PathBuf, did: Did, commit: Oid, err: raw::Error) -> Self {
Self::InvalidCommit(InvalidCommit {
repo,
did,
commit,
source: err,
})
impl CanonicalObjectType {
/// Construct the [`CanonicalObjectType`] 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,
}
}
}
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 +105,31 @@ 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<CanonicalObjectType, CanonicalError> {
match repo.find_object(*oid, None) {
Ok(object) => object
.kind()
.and_then(CanonicalObjectType::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 +147,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: CanonicalObjectType) {
self.tips.insert(did, (oid, kind));
}
/// Check that the provided `did` is part of the set of allowed
@ -191,19 +173,77 @@ 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: CanonicalObjectType) -> Self {
match kind {
CanonicalObjectType::Commit => Self::Commits(NonEmpty::new(oid)),
CanonicalObjectType::Tag => Self::Tags(NonEmpty::new(oid)),
}
}
fn insert(
mut self,
oid: Oid,
kind: CanonicalObjectType,
) -> Result<Self, CanonicalObjectType> {
match self {
Objects::Commits(ref mut commits) => match kind {
CanonicalObjectType::Commit => {
commits.push(oid);
Ok(self)
}
CanonicalObjectType::Tag => Err(CanonicalObjectType::Tag),
},
Objects::Tags(ref mut tags) => match kind {
CanonicalObjectType::Commit => {
tags.push(oid);
Ok(self)
}
CanonicalObjectType::Tag => Err(CanonicalObjectType::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
};
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)
@ -214,6 +254,36 @@ impl<'a, 'b> Canonical<'a, 'b> {
}
Ok(false)
}
}
}
fn quorum_tag(&self) -> Result<Oid, QuorumError> {
let voting =
TagVoting::from_targets(self.tips.values().filter_map(|(commit, kind)| {
(*kind == CanonicalObjectType::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`,
/// and the threshold. This can be described as the latest commit that is
@ -222,41 +292,36 @@ 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 voting =
CommitVoting::from_targets(self.tips.values().filter_map(|(commit, kind)| {
(*kind == CanonicalObjectType::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();
// 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;
}
}
}
// 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 {
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 +351,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,26 +360,205 @@ 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,
/// 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<CanonicalObjectType, FindObjectError> {
match repo.find_object(*oid, None) {
Ok(object) => object
.kind()
.and_then(CanonicalObjectType::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)),
}
}
@ -334,13 +578,19 @@ mod tests {
threshold: usize,
repo: &git::raw::Repository,
) -> Result<Oid, QuorumError> {
let tips: BTreeMap<Did, Oid> = heads
let tips: BTreeMap<Did, (Oid, CanonicalObjectType)> = 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(CanonicalObjectType::new)
.unwrap();
(did, ((*head).into(), kind))
})
.collect();
@ -360,7 +610,7 @@ mod tests {
rule: &rule,
}
.quorum(repo)
.map(|(_, oid)| oid)
.map(|(_, _, oid)| oid)
}
#[test]
@ -397,6 +647,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();
@ -447,15 +753,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 +777,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 +806,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 +826,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 +855,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 +867,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 +914,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 +930,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 +938,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 +946,7 @@ mod tests {
);
assert_matches!(
quorum(&[*m3, *m2], 1, &repo),
Err(QuorumError::Diverging { .. })
Err(QuorumError::DivergingCommits { .. })
);
assert_matches!(
quorum(&[*m3, *m2], 2, &repo),

View File

@ -0,0 +1,204 @@
use std::path::PathBuf;
use thiserror::Error;
use crate::{git::raw, git::Oid, prelude::Did};
use super::CanonicalObjectType;
/// 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: CanonicalObjectType,
expected: CanonicalObjectType,
}
#[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: CanonicalObjectType,
expected: CanonicalObjectType,
) -> Self {
Self::MismatchedObject(MismatchedObject {
repo,
oid,
found,
expected,
})
}
}

View File

@ -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),
)
}
}

View File

@ -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 {

View File

@ -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> {

View File

@ -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!(