diff --git a/crates/radicle-cli/examples/git/git-push-amend.md b/crates/radicle-cli/examples/git/git-push-amend.md index 1dd146f5..4fe8d313 100644 --- a/crates/radicle-cli/examples/git/git-push-amend.md +++ b/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) diff --git a/crates/radicle-cli/examples/git/git-push-canonical-annotated-tags.md b/crates/radicle-cli/examples/git/git-push-canonical-annotated-tags.md new file mode 100644 index 00000000..6b01e9ef --- /dev/null +++ b/crates/radicle-cli/examples/git/git-push-canonical-annotated-tags.md @@ -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 +``` diff --git a/crates/radicle-cli/examples/git/git-push-canonical-tags.md b/crates/radicle-cli/examples/git/git-push-canonical-lightweight-tags.md similarity index 87% rename from crates/radicle-cli/examples/git/git-push-canonical-tags.md rename to crates/radicle-cli/examples/git/git-push-canonical-lightweight-tags.md index 7a4d4418..7fd323eb 100644 --- a/crates/radicle-cli/examples/git/git-push-canonical-tags.md +++ b/crates/radicle-cli/examples/git/git-push-canonical-lightweight-tags.md @@ -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 diff --git a/crates/radicle-cli/examples/git/git-push-converge.md b/crates/radicle-cli/examples/git/git-push-converge.md index a73a3776..0696c280 100644 --- a/crates/radicle-cli/examples/git/git-push-converge.md +++ b/crates/radicle-cli/examples/git/git-push-converge.md @@ -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 diff --git a/crates/radicle-cli/examples/git/git-push-diverge.md b/crates/radicle-cli/examples/git/git-push-diverge.md index 39543d7d..424eab75 100644 --- a/crates/radicle-cli/examples/git/git-push-diverge.md +++ b/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) ``` diff --git a/crates/radicle-cli/examples/git/git-push-rollback.md b/crates/radicle-cli/examples/git/git-push-rollback.md index 8cc297b8..7b23a0a7 100644 --- a/crates/radicle-cli/examples/git/git-push-rollback.md +++ b/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) diff --git a/crates/radicle-cli/examples/rad-id-threshold.md b/crates/radicle-cli/examples/rad-id-threshold.md index fb53f26d..8860b53e 100644 --- a/crates/radicle-cli/examples/rad-id-threshold.md +++ b/crates/radicle-cli/examples/rad-id-threshold.md @@ -64,7 +64,7 @@ $ git commit -v -m "Define power requirements" ``` ~alice (stderr) RAD_SOCKET=/dev/null $ git push rad master -✓ Canonical reference refs/heads/master updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 +✓ Canonical reference refs/heads/master updated to target commit 3e674d1a1df90807e934f9ae5da2591dd6848a33 To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi f2de534..3e674d1 master -> master ``` diff --git a/crates/radicle-cli/examples/rad-merge-after-update.md b/crates/radicle-cli/examples/rad-merge-after-update.md index 929cf569..945dd57f 100644 --- a/crates/radicle-cli/examples/rad-merge-after-update.md +++ b/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 ``` diff --git a/crates/radicle-cli/examples/rad-merge-no-ff.md b/crates/radicle-cli/examples/rad-merge-no-ff.md index 58cca5ff..39999cea 100644 --- a/crates/radicle-cli/examples/rad-merge-no-ff.md +++ b/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 ``` diff --git a/crates/radicle-cli/examples/rad-merge-via-push.md b/crates/radicle-cli/examples/rad-merge-via-push.md index 28ca61ea..b365f107 100644 --- a/crates/radicle-cli/examples/rad-merge-via-push.md +++ b/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) ``` diff --git a/crates/radicle-cli/examples/rad-patch-merge-draft.md b/crates/radicle-cli/examples/rad-patch-merge-draft.md index 922bab23..d36e6714 100644 --- a/crates/radicle-cli/examples/rad-patch-merge-draft.md +++ b/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 ``` diff --git a/crates/radicle-cli/examples/rad-patch-open-explore.md b/crates/radicle-cli/examples/rad-patch-open-explore.md index f1c98013..f26e9b3d 100644 --- a/crates/radicle-cli/examples/rad-patch-open-explore.md +++ b/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 diff --git a/crates/radicle-cli/examples/rad-patch-revert-merge.md b/crates/radicle-cli/examples/rad-patch-revert-merge.md index a17ad836..85499566 100644 --- a/crates/radicle-cli/examples/rad-patch-revert-merge.md +++ b/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) ``` diff --git a/crates/radicle-cli/examples/workflow/5-patching-maintainer.md b/crates/radicle-cli/examples/workflow/5-patching-maintainer.md index b5eb6db6..61c771da 100644 --- a/crates/radicle-cli/examples/workflow/5-patching-maintainer.md +++ b/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 diff --git a/crates/radicle-cli/tests/commands.rs b/crates/radicle-cli/tests/commands.rs index be47ec65..2eef6a9f 100644 --- a/crates/radicle-cli/tests/commands.rs +++ b/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( diff --git a/crates/radicle-node/src/runtime.rs b/crates/radicle-node/src/runtime.rs index 86954152..8f0e5a40 100644 --- a/crates/radicle-node/src/runtime.rs +++ b/crates/radicle-node/src/runtime.rs @@ -73,7 +73,7 @@ pub enum Error { Address(#[from] address::Error), /// A service error. #[error("service error: {0}")] - Service(#[from] service::Error), + Service(Box), /// 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 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. diff --git a/crates/radicle-node/src/worker/fetch.rs b/crates/radicle-node/src/worker/fetch.rs index ab9cf96d..40eebfd8 100644 --- a/crates/radicle-node/src/worker/fetch.rs +++ b/crates/radicle-node/src/worker/fetch.rs @@ -122,9 +122,9 @@ impl Handle { log::trace!(target: "worker", "Set HEAD to {}", head.new); } } - Err(RepositoryError::Quorum(radicle::git::canonical::QuorumError::Git(e))) => { - return Err(e.into()) - } + Err(RepositoryError::Quorum( + radicle::git::canonical::error::QuorumError::Git(e), + )) => return Err(e.into()), Err(RepositoryError::Quorum(e)) => { log::warn!(target: "worker", "Fetch could not set HEAD: {e}") } @@ -406,7 +406,7 @@ fn set_canonical_refs(repo: &Repository, applied: &Applied) -> Result<(), error: ); continue; } - Ok((refname, oid)) => { + Ok((refname, _, oid)) => { if let Err(e) = repo.backend.reference( refname.clone().as_str(), *oid, diff --git a/crates/radicle-protocol/src/service.rs b/crates/radicle-protocol/src/service.rs index 8901a011..75c0f5d0 100644 --- a/crates/radicle-protocol/src/service.rs +++ b/crates/radicle-protocol/src/service.rs @@ -206,7 +206,13 @@ pub enum Error { #[error(transparent)] Repository(#[from] radicle::storage::RepositoryError), #[error("namespaces error: {0}")] - Namespaces(#[from] NamespacesError), + Namespaces(Box), +} + +impl From 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), +} + +impl From for TryFetchError<'_> { + fn from(e: NamespacesError) -> Self { + Self::Namespaces(Box::new(e)) + } } /// Fetch state for an ongoing fetch. diff --git a/crates/radicle-remote-helper/src/push.rs b/crates/radicle-remote-helper/src/push.rs index dfef5696..f085926a 100644 --- a/crates/radicle-remote-helper/src/push.rs +++ b/crates/radicle-remote-helper/src/push.rs @@ -113,13 +113,17 @@ pub enum Error { Repository(#[from] radicle::storage::RepositoryError), /// Quorum error. #[error(transparent)] - Quorum(#[from] radicle::git::canonical::QuorumError), + Quorum(#[from] radicle::git::canonical::error::QuorumError), #[error(transparent)] CanonicalRefs(#[from] radicle::identity::doc::CanonicalRefsError), #[error(transparent)] PushAction(#[from] error::PushAction), #[error(transparent)] Canonical(#[from] error::CanonicalUnrecoverable), + #[error(transparent)] + CanonicalInit(#[from] radicle::git::canonical::error::CanonicalError), + #[error("could not determine object type for {oid}")] + UnknownObjectType { oid: git::Oid }, } /// Push command. @@ -280,7 +284,8 @@ pub fn run( let identity = stored.identity()?; let project = identity.project()?; let canonical_ref = git::refs::branch(project.default_branch()); - let mut set_canonical_refs: Vec<(git::Qualified, git::Oid)> = Vec::with_capacity(specs.len()); + let mut set_canonical_refs: Vec<(git::Qualified, git::raw::ObjectType, git::Oid)> = + Vec::with_capacity(specs.len()); let working = git::raw::Repository::open(working)?; // For each refspec, push a ref or delete a ref. @@ -348,7 +353,13 @@ pub fn run( // Note that we *do* allow rolling back to a previous commit on the // canonical branch. if let Some(canonical) = rules.canonical(dst.clone(), stored)? { - let canonical = canonical::Canonical::new(me, *src, canonical); + let kind = working + .find_object(**src, None)? + .kind() + .and_then(git::canonical::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), diff --git a/crates/radicle-remote-helper/src/push/canonical.rs b/crates/radicle-remote-helper/src/push/canonical.rs index 0a509011..524402e4 100644 --- a/crates/radicle-remote-helper/src/push/canonical.rs +++ b/crates/radicle-remote-helper/src/push/canonical.rs @@ -6,7 +6,8 @@ use super::error; pub(crate) struct Vote { did: Did, - commit: git::Oid, + oid: git::Oid, + kind: git::canonical::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 }), }, } } diff --git a/crates/radicle-remote-helper/src/push/error.rs b/crates/radicle-remote-helper/src/push/error.rs index add16353..6ce8d145 100644 --- a/crates/radicle-remote-helper/src/push/error.rs +++ b/crates/radicle-remote-helper/src/push/error.rs @@ -7,7 +7,7 @@ pub enum CanonicalUnrecoverable { #[error(transparent)] GraphDescendant(#[from] GraphDescendant), #[error(transparent)] - Converges(#[from] canonical::ConvergesError), + Converges(#[from] canonical::error::ConvergesError), #[error(transparent)] HeadsDiverge(#[from] HeadsDiverge), #[error("failure while computing canonical reference: {source}")] @@ -19,11 +19,11 @@ pub enum Canonical { #[error(transparent)] GraphDescendant(GraphDescendant), #[error(transparent)] - Converges(#[from] canonical::ConvergesError), + Converges(#[from] canonical::error::ConvergesError), #[error(transparent)] HeadsDiverge(HeadsDiverge), #[error(transparent)] - Quorum(#[from] canonical::QuorumError), + Quorum(#[from] canonical::error::QuorumError), } impl Canonical { diff --git a/crates/radicle/src/git/canonical.rs b/crates/radicle/src/git/canonical.rs index 11a4ae3b..3786e856 100644 --- a/crates/radicle/src/git/canonical.rs +++ b/crates/radicle/src/git/canonical.rs @@ -1,14 +1,19 @@ +pub mod error; +use error::*; + pub mod rules; + +use nonempty::NonEmpty; pub use rules::{MatchedRule, RawRule, Rules, ValidRule}; use std::collections::BTreeMap; -use std::path::PathBuf; +use std::fmt; +use raw::ObjectType; use raw::Repository; -use thiserror::Error; use crate::prelude::Did; -use crate::storage::ReadRepository; +use crate::storage::git; use super::raw; use super::{Oid, Qualified}; @@ -28,115 +33,70 @@ use super::{Oid, Qualified}; pub struct Canonical<'a, 'b> { refname: Qualified<'a>, rule: &'b ValidRule, - tips: BTreeMap, + tips: BTreeMap, } -/// 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 { + 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(repo: &S, refname: Qualified<'a>, rule: &'b ValidRule) -> Result - where - S: ReadRepository, - { + pub fn new( + repo: &Repository, + refname: Qualified<'a>, + rule: &'b ValidRule, + ) -> Result { 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 { - self.tips.iter() + pub fn find_object_for( + did: &Did, + oid: Oid, + repo: &raw::Repository, + ) -> Result { + 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,28 +173,116 @@ impl<'a, 'b> Canonical<'a, 'b> { repo: &Repository, (candidate, commit): (&Did, &Oid), ) -> Result { + /// Ensures [`Oid`]s are of the same object type + enum Objects { + Commits(NonEmpty), + Tags(NonEmpty), + } + + 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 { + 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 }; - for head in heads { - let (ahead, behind) = repo - .graph_ahead_behind(**commit, *head) - .map_err(|err| ConvergesError::graph_descendant(*commit, head, err))?; - if ahead * behind == 0 { - return Ok(true); + + match heads { + None => Ok(true), + Some(Objects::Tags(_)) => Ok(true), + Some(Objects::Commits(heads)) => { + for head in heads { + let (ahead, behind) = repo + .graph_ahead_behind(**commit, *head) + .map_err(|err| ConvergesError::graph_descendant(*commit, head, err))?; + if ahead * behind == 0 { + return Ok(true); + } + } + Ok(false) } } - Ok(false) + } + + fn quorum_tag(&self) -> Result { + 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`, @@ -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(); - - // Build a list of candidate commits and count how many "votes" each of them has. - // Commits get a point for each direct vote, as well as for being part of the ancestry - // of a commit given to this function. Only commits given to the function are considered. - for (i, head) in self.tips.values().enumerate() { - // Add a direct vote for this head. - *candidates.entry(*head).or_default() += 1; - - // Compare this head to all other heads ahead of it in the list. - for other in self.tips.values().skip(i + 1) { - // N.b. if heads are equal then skip it, otherwise it will end up as - // a double vote. - if *head == *other { - continue; - } - let base = Oid::from(repo.merge_base(**head, **other)?); - - if base == *other || base == *head { - *candidates.entry(base).or_default() += 1; - } + fn quorum_commit(&self, repo: &raw::Repository) -> Result { + 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, + }); } } - // Keep commits which pass the threshold. - candidates.retain(|_, votes| *votes >= self.threshold()); + let mut votes = voting.votes(); - let (mut longest, _) = candidates.pop_first().ok_or(QuorumError::NoCandidates { - refname: self.refname.to_string(), - threshold: self.threshold(), - })?; + // Keep commits which pass the threshold. + votes.votes_past_threshold(self.threshold()); + + let mut longest = votes + .pop_first_candidate() + .ok_or(QuorumError::NoCandidates { + refname: self.refname.to_string(), + threshold: self.threshold(), + })?; // Now that all scores are calculated, figure out what is the longest branch // that passes the threshold. In case of divergence, return an error. - for head in candidates.keys() { + for head in votes.candidates() { let base = repo.merge_base(**head, *longest)?; if base == *longest { @@ -286,7 +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,27 +360,206 @@ 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 { - match working.find_commit(*commit).map(|_| commit) { - Ok(oid) => Ok(oid), - Err(err) if err.code() == raw::ErrorCode::NotFound => Err( - ConvergesError::missing_commit(working.path().to_path_buf(), from, commit, err), - ), - Err(err) => Err(ConvergesError::invalid_commit( - working.path().to_path_buf(), - from, - commit, - err, - )), +/// Keep track of [`Votes`] for quorums involving tag objects. +struct TagVoting { + votes: Votes, +} + +impl TagVoting { + fn from_targets(targets: impl Iterator) -> 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)>, + votes: Votes, +} + +impl CommitVoting { + /// Build the initial set voting. + fn from_targets(targets: impl Iterator + 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> { + 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, +} + +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 { + self.inner.keys() + } + + /// Pop off the first candidate from the set of votes. + #[inline] + fn pop_first_candidate(&mut self) -> Option { + 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 { + 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)), + } } #[cfg(test)] @@ -334,13 +578,19 @@ mod tests { threshold: usize, repo: &git::raw::Repository, ) -> Result { - let tips: BTreeMap = heads + let tips: BTreeMap = 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), diff --git a/crates/radicle/src/git/canonical/error.rs b/crates/radicle/src/git/canonical/error.rs new file mode 100644 index 00000000..cd97819f --- /dev/null +++ b/crates/radicle/src/git/canonical/error.rs @@ -0,0 +1,204 @@ +use std::path::PathBuf; + +use thiserror::Error; + +use crate::{git::raw, git::Oid, prelude::Did}; + +use super::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, + }, + #[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, +} + +#[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, + ) -> 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, + ) -> 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, + }) + } +} diff --git a/crates/radicle/src/git/canonical/rules.rs b/crates/radicle/src/git/canonical/rules.rs index da9bd7fd..59233456 100644 --- a/crates/radicle/src/git/canonical/rules.rs +++ b/crates/radicle/src/git/canonical/rules.rs @@ -20,6 +20,7 @@ use serde_json as json; use thiserror::Error; use crate::git; +use crate::git::canonical; use crate::git::canonical::Canonical; use crate::git::fmt::{refname, RefString}; use crate::git::refspec::QualifiedPattern; @@ -634,9 +635,9 @@ impl Rules { &'a self, refname: Qualified<'b>, repo: &Repository, - ) -> Result>, git::raw::Error> { + ) -> Result>, 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), ) } } diff --git a/crates/radicle/src/storage.rs b/crates/radicle/src/storage.rs index 9f1d1768..7f487df9 100644 --- a/crates/radicle/src/storage.rs +++ b/crates/radicle/src/storage.rs @@ -117,7 +117,7 @@ pub enum RepositoryError { #[error(transparent)] GitExt(#[from] git_ext::Error), #[error(transparent)] - Quorum(#[from] canonical::QuorumError), + Quorum(#[from] canonical::error::QuorumError), #[error(transparent)] Refs(#[from] refs::Error), #[error("missing canonical reference rule for default branch")] @@ -126,6 +126,8 @@ pub enum RepositoryError { DefaultBranchRule(#[from] doc::DefaultBranchRuleError), #[error("failed to get canonical reference rules: {0}")] CanonicalRefs(#[from] doc::CanonicalRefsError), + #[error(transparent)] + Canonical(#[from] canonical::error::CanonicalError), } impl RepositoryError { diff --git a/crates/radicle/src/storage/git.rs b/crates/radicle/src/storage/git.rs index 46a022df..be025611 100644 --- a/crates/radicle/src/storage/git.rs +++ b/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 { diff --git a/crates/radicle/src/test/fixtures.rs b/crates/radicle/src/test/fixtures.rs index 6b046d1b..cd9f92ab 100644 --- a/crates/radicle/src/test/fixtures.rs +++ b/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 { assert!(