Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
Patches: Add support for custom merge destination
ade wants to merge 8 commits into master · opened 7 days ago

This patch introduces the ability to specify a target branch for patches using the patch.destination push option. Previously patches implicitly targeted the repository's default branch. Furthermore this patch introduces strict isolation for merges and reverts: a patch will now only be marked as merged or reverted if the commits are pushed to its explicitly intended destination branch.

15 files changed +1402 -51 caee776c 75d35fa4
modified crates/radicle-cli/examples/rad-cob-show.md
@@ -72,7 +72,7 @@ We can show the patch COB too.

```
$ rad cob show --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.patch --object d1f7f869fde9fac19c1779c4c2e77e8361333f91
-
{"title":"Start drafting peace treaty","author":{"id":"did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"},"state":{"status":"open"},"target":"delegates","labels":[],"merges":{},"revisions":{"d1f7f869fde9fac19c1779c4c2e77e8361333f91":{"id":"d1f7f869fde9fac19c1779c4c2e77e8361333f91","author":{"id":"did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"},"description":[{"author":"z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi","timestamp":1671125284000,"body":"See details.","embeds":[]}],"base":"f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354","oid":"575ed68c716d6aae81ea6b718fd9ac66a8eae532","discussion":{"comments":{},"timeline":[]},"reviews":{},"timestamp":1671125284000,"resolves":[],"reactions":[]}},"assignees":[],"timeline":["d1f7f869fde9fac19c1779c4c2e77e8361333f91"],"reviews":{}}
+
{"title":"Start drafting peace treaty","author":{"id":"did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"},"state":{"status":"open"},"target":"delegates","destination":null,"labels":[],"merges":{},"revisions":{"d1f7f869fde9fac19c1779c4c2e77e8361333f91":{"id":"d1f7f869fde9fac19c1779c4c2e77e8361333f91","author":{"id":"did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"},"description":[{"author":"z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi","timestamp":1671125284000,"body":"See details.","embeds":[]}],"base":"f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354","oid":"575ed68c716d6aae81ea6b718fd9ac66a8eae532","discussion":{"comments":{},"timeline":[]},"reviews":{},"timestamp":1671125284000,"resolves":[],"reactions":[]}},"assignees":[],"timeline":["d1f7f869fde9fac19c1779c4c2e77e8361333f91"],"reviews":{}}
```

Finally let's update the issue and see the output of `rad cob show` also changes.
added crates/radicle-cli/examples/rad-patch-merge-default-branch.md
@@ -0,0 +1,55 @@
+
# Merging patches into the default branch
+

+
We create a feature branch and open a patch without specifying a target branch.
+

+
``` (stderr)
+
$ git checkout -b feature/1
+
Switched to a new branch 'feature/1'
+
$ touch FEATURE.md
+
$ git add FEATURE.md
+
```
+
```
+
$ git commit -m "Add new feature"
+
[feature/1 [..]] Add new feature
+
 1 file changed, 0 insertions(+), 0 deletions(-)
+
 create mode 100644 FEATURE.md
+
```
+
``` (stderr)
+
$ git push -o patch.message="Add new feature" rad HEAD:refs/patches
+
✓ Patch [..] opened
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new reference]   HEAD -> refs/patches
+
```
+

+
Now, Alice merges the feature into the `master` branch and pushes it.
+

+
``` (stderr)
+
$ git checkout master
+
Switched to branch 'master'
+
```
+
```
+
$ git merge feature/1
+
Updating [..]
+
Fast-forward
+
 FEATURE.md | 0
+
 1 file changed, 0 insertions(+), 0 deletions(-)
+
 create mode 100644 FEATURE.md
+
```
+
``` (stderr)
+
$ git push rad master
+
✓ Patch [..] merged
+
✓ Canonical reference refs/heads/master updated to target commit [..]
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
   [..]..[..]  master -> master
+
```
+

+
Finally, we verify that the patch has been successfully marked as merged.
+

+
```
+
$ rad patch list --merged
+
╭───────────────────────────────────────────────────────────────────────────────╮
+
│ ●  ID       Title            Author         Reviews  Head     +   -   Updated │
+
├───────────────────────────────────────────────────────────────────────────────┤
+
│ ✓  [..]  Add new feature  alice   (you)  -        [..]  +0  -0  now     │
+
╰───────────────────────────────────────────────────────────────────────────────╯
+
```
added crates/radicle-cli/examples/rad-patch-merge-into-canonical-ref-branch.md
@@ -0,0 +1,96 @@
+
# Merging patches into non-default canonical branches
+

+
First, we update the identity document to add a canonical reference rule for a new `accepted` branch, allowing delegates to merge into it.
+

+
```
+
$ rad id update --title "Add accepted branch" --payload xyz.radicle.crefs rules '{ "refs/heads/accepted": { "threshold": 1, "allow": "delegates" } }' -q
+
[..]
+
```
+

+
Now, let's create the `accepted` branch and push it to the repository so it becomes a tracked canonical reference:
+

+
``` (stderr)
+
$ git checkout -b accepted
+
Switched to a new branch 'accepted'
+
```
+

+
```
+
$ git commit --allow-empty -m "Initialize accepted branch"
+
[accepted [..]] Initialize accepted branch
+
```
+

+
``` (stderr)
+
$ git push rad accepted
+
✓ Canonical reference refs/heads/accepted updated to target commit [..]
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new branch]      accepted -> accepted
+
```
+

+
Next, we create a feature branch and open a patch. We use the `patch.destination` push option to explicitly state that this patch is intended for `refs/heads/accepted` rather than the default branch (`master`).
+

+
``` (stderr)
+
$ git checkout -b feature/1
+
Switched to a new branch 'feature/1'
+
$ touch FEATURE.md
+
$ git add FEATURE.md
+
```
+

+
```
+
$ git commit -m "Add new feature"
+
[feature/1 [..]] Add new feature
+
 1 file changed, 0 insertions(+), 0 deletions(-)
+
 create mode 100644 FEATURE.md
+
```
+

+
``` (stderr)
+
$ git push -o patch.message="Add new feature" -o patch.destination="refs/heads/accepted" rad HEAD:refs/patches
+
✓ Patch [..] opened
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new reference]   HEAD -> refs/patches
+
```
+

+
We can verify the patch is open:
+

+
```
+
$ rad patch
+
╭───────────────────────────────────────────────────────────────────────────────╮
+
│ ●  ID       Title            Author         Reviews  Head     +   -   Updated │
+
├───────────────────────────────────────────────────────────────────────────────┤
+
│ ●  [..]  Add new feature  alice   (you)  -        [..]  +0  -0  now     │
+
╰───────────────────────────────────────────────────────────────────────────────╯
+
```
+

+
Now, Alice merges the feature into the `accepted` branch and pushes it. Because `accepted` is a valid canonical reference and Alice is a delegate, the remote helper should detect the merge and update the patch status.
+

+
``` (stderr)
+
$ git checkout accepted
+
Switched to branch 'accepted'
+
```
+

+
```
+
$ git merge feature/1
+
Updating [..]
+
Fast-forward
+
 FEATURE.md | 0
+
 1 file changed, 0 insertions(+), 0 deletions(-)
+
 create mode 100644 FEATURE.md
+
```
+

+
``` (stderr)
+
$ git push rad accepted
+
✓ Patch [..] merged
+
✓ Canonical reference refs/heads/accepted updated to target commit [..]
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
   [..]..[..]  accepted -> accepted
+
```
+

+
Finally, we verify that the patch has been successfully marked as merged, even though it wasn't merged into the default branch.
+

+
```
+
$ rad patch list --merged
+
╭───────────────────────────────────────────────────────────────────────────────╮
+
│ ●  ID       Title            Author         Reviews  Head     +   -   Updated │
+
├───────────────────────────────────────────────────────────────────────────────┤
+
│ ✓  [..]  Add new feature  alice   (you)  -        [..]  +0  -0  now     │
+
╰───────────────────────────────────────────────────────────────────────────────╯
+
```
added crates/radicle-cli/examples/rad-patch-merge-strict-destination.md
@@ -0,0 +1,82 @@
+
# Merging patches has a strict destination
+

+
First, we update the identity document to add a canonical reference rule for a new `accepted` branch.
+

+
```
+
$ rad id update --title "Add accepted branch" --payload xyz.radicle.crefs rules '{ "refs/heads/accepted": { "threshold": 1, "allow": "delegates" } }' -q
+
[..]
+
```
+

+
Now, let's create the `accepted` branch and push it:
+

+
``` (stderr)
+
$ git checkout -b accepted
+
Switched to a new branch 'accepted'
+
```
+
```
+
$ git commit --allow-empty -m "Initialize accepted branch"
+
[accepted [..]] Initialize accepted branch
+
```
+
``` (stderr)
+
$ git push rad accepted
+
✓ Canonical reference refs/heads/accepted updated to target commit [..]
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new branch]      accepted -> accepted
+
```
+

+
Next, we create a feature branch and open a patch. We do *not* specify a destination, so it defaults to `master`.
+

+
``` (stderr)
+
$ git checkout -b feature/1
+
Switched to a new branch 'feature/1'
+
$ touch FEATURE.md
+
$ git add FEATURE.md
+
```
+
```
+
$ git commit -m "Add new feature"
+
[feature/1 [..]] Add new feature
+
 1 file changed, 0 insertions(+), 0 deletions(-)
+
 create mode 100644 FEATURE.md
+
```
+
``` (stderr)
+
$ git push -o patch.message="Add new feature" rad HEAD:refs/patches
+
✓ Patch [..] opened
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new reference]   HEAD -> refs/patches
+
```
+

+
Now, Alice merges the feature into the `accepted` branch instead of `master`.
+

+
``` (stderr)
+
$ git checkout accepted
+
Switched to branch 'accepted'
+
```
+
```
+
$ git merge feature/1
+
Updating [..]
+
Fast-forward
+
 FEATURE.md | 0
+
 1 file changed, 0 insertions(+), 0 deletions(-)
+
 create mode 100644 FEATURE.md
+
```
+
``` (stderr)
+
$ git push rad accepted
+
✓ Canonical reference refs/heads/accepted updated to target commit [..]
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
   [..]..[..]  accepted -> accepted
+
```
+

+
Because the patch was implicitly targeted for `master`, pushing the commit to `accepted` should not mark it as merged. It should remain open.
+

+
```
+
$ rad patch list --merged
+
Nothing to show.
+
```
+
```
+
$ rad patch list --open
+
╭───────────────────────────────────────────────────────────────────────────────╮
+
│ ●  ID       Title            Author         Reviews  Head     +   -   Updated │
+
├───────────────────────────────────────────────────────────────────────────────┤
+
│ ●  [..]  Add new feature  alice   (you)  -        [..]  +0  -0  now     │
+
╰───────────────────────────────────────────────────────────────────────────────╯
+
```
added crates/radicle-cli/examples/rad-patch-merge-unauthorized-branch.md
@@ -0,0 +1,97 @@
+
# Merging patches into an unauthorized branch
+

+
First, we update the identity document to add a canonical reference rule for a new `accepted` branch, but we only allow Alice to merge into it.
+

+
``` ~alice
+
$ rad id update --title "Add accepted branch" --payload xyz.radicle.crefs rules '{ "refs/heads/accepted": { "threshold": 1, "allow": ["did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"] } }' -q
+
[..]
+
```
+

+
Now, let's create the `accepted` branch and push it:
+

+
``` ~alice (stderr)
+
$ git checkout -b accepted
+
Switched to a new branch 'accepted'
+
```
+
``` ~alice
+
$ git commit --allow-empty -m "Initialize accepted branch"
+
[accepted [..]] Initialize accepted branch
+
```
+
``` ~alice (stderr)
+
$ git push rad accepted
+
✓ Canonical reference refs/heads/accepted updated to target commit [..]
+
✓ Synced with 1 seed(s)
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new branch]      accepted -> accepted
+
```
+

+
Next, Bob clones the repository and opens a patch targeting `accepted`.
+

+
``` ~bob
+
$ rad clone rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
✓ Creating checkout in ./heartwood..
+
✓ Remote alice@z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi added
+
✓ Remote-tracking branch alice@z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master created for z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
✓ Repository successfully cloned under [..]
+
╭────────────────────────────────────╮
+
│ heartwood                          │
+
│ Radicle Heartwood Protocol & Stack │
+
│ 0 issues · 0 patches               │
+
╰────────────────────────────────────╯
+
Run `cd ./heartwood` to go to the repository directory.
+
```
+
``` ~bob (stderr)
+
$ cd heartwood
+
$ git checkout -b feature/1
+
Switched to a new branch 'feature/1'
+
$ touch FEATURE.md
+
$ git add FEATURE.md
+
```
+
``` ~bob
+
$ git commit -m "Add new feature"
+
[feature/1 [..]] Add new feature
+
 1 file changed, 0 insertions(+), 0 deletions(-)
+
 create mode 100644 FEATURE.md
+
```
+
``` ~bob (stderr)
+
$ git push -o patch.message="Add new feature" -o patch.destination="refs/heads/accepted" rad HEAD:refs/patches
+
✓ Patch [..] opened
+
✓ Synced with 1 seed(s)
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+
 * [new reference]   HEAD -> refs/patches
+
```
+

+
Now, Bob tries to merge the feature into the `accepted` branch and push it.
+

+
``` ~bob (stderr)
+
$ git checkout -t rad/accepted
+
Switched to a new branch 'accepted'
+
```
+
``` ~bob
+
$ git merge feature/1
+
Merge made by the 'ort' strategy.
+
 FEATURE.md | 0
+
 1 file changed, 0 insertions(+), 0 deletions(-)
+
 create mode 100644 FEATURE.md
+
```
+
``` ~bob (stderr)
+
$ git push rad accepted
+
✓ Synced with 1 seed(s)
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+
 * [new branch]      accepted -> accepted
+
```
+

+
Notice that the canonical reference was *not* updated because Bob is not authorized. The patch should remain open.
+

+
``` ~bob
+
$ rad patch list --merged
+
Nothing to show.
+
```
+
``` ~bob
+
$ rad patch list --open
+
╭───────────────────────────────────────────────────────────────────────────────╮
+
│ ●  ID       Title            Author         Reviews  Head     +   -   Updated │
+
├───────────────────────────────────────────────────────────────────────────────┤
+
│ ●  [..]  Add new feature  bob     (you)  -        [..]  +0  -0  now     │
+
╰───────────────────────────────────────────────────────────────────────────────╯
+
```
added crates/radicle-cli/examples/rad-patch-merge-wrong-branch.md
@@ -0,0 +1,82 @@
+
# Merging patches into the wrong branch
+

+
First, we update the identity document to add a canonical reference rule for a new `accepted` branch.
+

+
```
+
$ rad id update --title "Add accepted branch" --payload xyz.radicle.crefs rules '{ "refs/heads/accepted": { "threshold": 1, "allow": "delegates" } }' -q
+
[..]
+
```
+

+
Now, let's create the `accepted` branch and push it:
+

+
``` (stderr)
+
$ git checkout -b accepted
+
Switched to a new branch 'accepted'
+
```
+
```
+
$ git commit --allow-empty -m "Initialize accepted branch"
+
[accepted [..]] Initialize accepted branch
+
```
+
``` (stderr)
+
$ git push rad accepted
+
✓ Canonical reference refs/heads/accepted updated to target commit [..]
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new branch]      accepted -> accepted
+
```
+

+
Next, we create a feature branch and open a patch targeting `accepted`.
+

+
``` (stderr)
+
$ git checkout -b feature/1
+
Switched to a new branch 'feature/1'
+
$ touch FEATURE.md
+
$ git add FEATURE.md
+
```
+
```
+
$ git commit -m "Add new feature"
+
[feature/1 [..]] Add new feature
+
 1 file changed, 0 insertions(+), 0 deletions(-)
+
 create mode 100644 FEATURE.md
+
```
+
``` (stderr)
+
$ git push -o patch.message="Add new feature" -o patch.destination="refs/heads/accepted" rad HEAD:refs/patches
+
✓ Patch [..] opened
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new reference]   HEAD -> refs/patches
+
```
+

+
Now, Alice accidentally merges the feature into the `master` branch instead of `accepted`.
+

+
``` (stderr)
+
$ git checkout master
+
Switched to branch 'master'
+
```
+
```
+
$ git merge feature/1
+
Updating [..]
+
Fast-forward
+
 FEATURE.md | 0
+
 1 file changed, 0 insertions(+), 0 deletions(-)
+
 create mode 100644 FEATURE.md
+
```
+
``` (stderr)
+
$ git push rad master
+
✓ Canonical reference refs/heads/master updated to target commit [..]
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
   [..]..[..]  master -> master
+
```
+

+
Because the patch was explicitly targeted for `accepted`, it should remain open.
+

+
```
+
$ rad patch list --merged
+
Nothing to show.
+
```
+
```
+
$ rad patch list --open
+
╭───────────────────────────────────────────────────────────────────────────────╮
+
│ ●  ID       Title            Author         Reviews  Head     +   -   Updated │
+
├───────────────────────────────────────────────────────────────────────────────┤
+
│ ●  [..]  Add new feature  alice   (you)  -        [..]  +0  -0  now     │
+
╰───────────────────────────────────────────────────────────────────────────────╯
+
```
added crates/radicle-cli/examples/rad-patch-revert-custom-branch.md
@@ -0,0 +1,108 @@
+
# Reverting a patch on a custom canonical branch
+

+
First, we update the identity document to add a canonical reference rule for a new `accepted` branch.
+

+
```
+
$ rad id update --title "Add accepted branch" --payload xyz.radicle.crefs rules '{ "refs/heads/accepted": { "threshold": 1, "allow": "delegates" } }' -q
+
[..]
+
```
+

+
Now, let's create the `accepted` branch and push it:
+

+
``` (stderr)
+
$ git checkout -b accepted
+
Switched to a new branch 'accepted'
+
```
+
```
+
$ git commit --allow-empty -m "Initialize accepted branch"
+
[accepted [..]] Initialize accepted branch
+
```
+
``` (stderr)
+
$ git push rad accepted
+
✓ Canonical reference refs/heads/accepted updated to target commit [..]
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new branch]      accepted -> accepted
+
```
+

+
Next, we create a feature branch and open a patch targeting `accepted`.
+

+
``` (stderr)
+
$ git checkout -b feature/1
+
Switched to a new branch 'feature/1'
+
$ touch FEATURE.md
+
$ git add FEATURE.md
+
```
+
```
+
$ git commit -m "Add new feature"
+
[feature/1 [..]] Add new feature
+
 1 file changed, 0 insertions(+), 0 deletions(-)
+
 create mode 100644 FEATURE.md
+
```
+
``` (stderr)
+
$ git push -o patch.message="Add new feature" -o patch.destination="refs/heads/accepted" rad HEAD:refs/patches
+
✓ Patch [..] opened
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new reference]   HEAD -> refs/patches
+
```
+

+
Alice merges the feature into the `accepted` branch and pushes it.
+

+
``` (stderr)
+
$ git checkout accepted
+
Switched to branch 'accepted'
+
```
+
```
+
$ git merge feature/1
+
Updating [..]
+
Fast-forward
+
 FEATURE.md | 0
+
 1 file changed, 0 insertions(+), 0 deletions(-)
+
 create mode 100644 FEATURE.md
+
```
+
``` (stderr)
+
$ git push rad accepted
+
✓ Patch [..] merged
+
✓ Canonical reference refs/heads/accepted updated to target commit [..]
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
   [..]..[..]  accepted -> accepted
+
```
+

+
We verify the patch is merged.
+

+
```
+
$ rad patch list --merged
+
╭───────────────────────────────────────────────────────────────────────────────╮
+
│ ●  ID       Title            Author         Reviews  Head     +   -   Updated │
+
├───────────────────────────────────────────────────────────────────────────────┤
+
│ ✓  [..]  Add new feature  alice   (you)  -        [..]  +0  -0  now     │
+
╰───────────────────────────────────────────────────────────────────────────────╯
+
```
+

+
Now, Alice realizes she made a mistake and resets the `accepted` branch, dropping the merge commit, and force pushes.
+

+
```
+
$ git reset --hard HEAD~1
+
HEAD is now at [..] Initialize accepted branch
+
```
+
``` (stderr)
+
$ git push rad accepted --force
+
! Patch [..] reverted at revision [..]
+
✓ Canonical reference refs/heads/accepted updated to target commit [..]
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 + [..]...[..] accepted -> accepted (forced update)
+
```
+

+
The patch should now be open again.
+

+
```
+
$ rad patch list --merged
+
Nothing to show.
+
```
+
```
+
$ rad patch list --open
+
╭───────────────────────────────────────────────────────────────────────────────╮
+
│ ●  ID       Title            Author         Reviews  Head     +   -   Updated │
+
├───────────────────────────────────────────────────────────────────────────────┤
+
│ ●  [..]  Add new feature  alice   (you)  -        [..]  +0  -0  now     │
+
╰───────────────────────────────────────────────────────────────────────────────╯
+
```
added crates/radicle-cli/examples/rad-patch-revert-isolation.md
@@ -0,0 +1,107 @@
+
# Revert isolation (Force-pushing the wrong branch)
+

+
First, we update the identity document to add a canonical reference rule for a new `accepted` branch.
+

+
```
+
$ rad id update --title "Add accepted branch" --payload xyz.radicle.crefs rules '{ "refs/heads/accepted": { "threshold": 1, "allow": "delegates" } }' -q
+
[..]
+
```
+

+
Now, let's create the `accepted` branch and push it:
+

+
``` (stderr)
+
$ git checkout -b accepted
+
Switched to a new branch 'accepted'
+
```
+
```
+
$ git commit --allow-empty -m "Initialize accepted branch"
+
[accepted [..]] Initialize accepted branch
+
```
+
``` (stderr)
+
$ git push rad accepted
+
✓ Canonical reference refs/heads/accepted updated to target commit [..]
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new branch]      accepted -> accepted
+
```
+

+
Next, we create a feature branch and open a patch targeting `accepted`.
+

+
``` (stderr)
+
$ git checkout -b feature/1
+
Switched to a new branch 'feature/1'
+
$ touch FEATURE.md
+
$ git add FEATURE.md
+
```
+
```
+
$ git commit -m "Add new feature"
+
[feature/1 [..]] Add new feature
+
 1 file changed, 0 insertions(+), 0 deletions(-)
+
 create mode 100644 FEATURE.md
+
```
+
``` (stderr)
+
$ git push -o patch.message="Add new feature" -o patch.destination="refs/heads/accepted" rad HEAD:refs/patches
+
✓ Patch [..] opened
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new reference]   HEAD -> refs/patches
+
```
+

+
Alice merges the feature into the `accepted` branch and pushes it.
+

+
``` (stderr)
+
$ git checkout accepted
+
Switched to branch 'accepted'
+
```
+
```
+
$ git merge feature/1
+
Updating [..]
+
Fast-forward
+
 FEATURE.md | 0
+
 1 file changed, 0 insertions(+), 0 deletions(-)
+
 create mode 100644 FEATURE.md
+
```
+
``` (stderr)
+
$ git push rad accepted
+
✓ Patch [..] merged
+
✓ Canonical reference refs/heads/accepted updated to target commit [..]
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
   [..]..[..]  accepted -> accepted
+
```
+

+
We verify the patch is merged.
+

+
```
+
$ rad patch list --merged
+
╭───────────────────────────────────────────────────────────────────────────────╮
+
│ ●  ID       Title            Author         Reviews  Head     +   -   Updated │
+
├───────────────────────────────────────────────────────────────────────────────┤
+
│ ✓  [..]  Add new feature  alice   (you)  -        [..]  +0  -0  now     │
+
╰───────────────────────────────────────────────────────────────────────────────╯
+
```
+

+
Now, Alice switches to `master`, resets it to drop a commit, and force pushes. This simulates a scenario where commits are dropped from a branch *other* than the patch's destination.
+

+
``` (stderr)
+
$ git checkout master
+
Switched to branch 'master'
+
```
+
```
+
$ git reset --hard HEAD~1
+
HEAD is now at [..] Initial commit
+
```
+
``` (stderr)
+
$ git push rad master --force
+
✓ Canonical reference refs/heads/master updated to target commit [..]
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 + [..]...[..] master -> master (forced update)
+
```
+

+
Because the patch was merged into `accepted`, dropping commits on `master` should not revert the patch. It should remain merged.
+

+
```
+
$ rad patch list --merged
+
╭───────────────────────────────────────────────────────────────────────────────╮
+
│ ●  ID       Title            Author         Reviews  Head     +   -   Updated │
+
├───────────────────────────────────────────────────────────────────────────────┤
+
│ ✓  [..]  Add new feature  alice   (you)  -        [..]  +0  -0  now     │
+
╰───────────────────────────────────────────────────────────────────────────────╯
+
```
modified crates/radicle-cli/src/commands/patch/edit.rs
@@ -58,11 +58,12 @@ where

    let (root, _) = patch.root();
    let target = patch.target();
+
    let destination = patch.destination().cloned();
    let embeds = patch.embeds().to_owned();

    patch.transaction("Edit root", |tx| {
        if let Some(t) = title {
-
            tx.edit(t, target)?;
+
            tx.edit(t, target, destination)?;
        }
        if let Some(d) = description {
            tx.edit_revision(root, d, embeds)?;
modified crates/radicle-cli/tests/commands/patch.rs
@@ -386,3 +386,75 @@ fn rad_merge_no_ff() {
        .tests(["rad-init", "rad-merge-no-ff"], &alice)
        .unwrap();
}
+

+
#[test]
+
fn rad_patch_merge_into_canonical_ref_branch() {
+
    Environment::alice(["rad-init", "rad-patch-merge-into-canonical-ref-branch"]);
+
}
+

+
#[test]
+
fn rad_patch_merge_default_branch() {
+
    Environment::alice(["rad-init", "rad-patch-merge-default-branch"]);
+
}
+

+
#[test]
+
fn rad_patch_merge_wrong_branch() {
+
    Environment::alice(["rad-init", "rad-patch-merge-wrong-branch"]);
+
}
+

+
#[test]
+
fn rad_patch_merge_unauthorized_branch() {
+
    let mut environment = Environment::new();
+
    let alice = environment.node("alice");
+
    let bob = environment.node("bob");
+
    let acme = RepoId::from_str("z42hL2jL4XNk6K8oHQaSWfMgCL7ji").unwrap();
+

+
    environment.repository(&alice);
+

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

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

+
    bob.handle.seed(acme, Scope::All).unwrap();
+
    alice.connect(&bob).converge([&bob]);
+

+
    formula(
+
        &environment.tempdir(),
+
        "examples/rad-patch-merge-unauthorized-branch.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 rad_patch_revert_custom_branch() {
+
    Environment::alice(["rad-init", "rad-patch-revert-custom-branch"]);
+
}
+

+
#[test]
+
fn rad_patch_revert_isolation() {
+
    Environment::alice(["rad-init", "rad-patch-revert-isolation"]);
+
}
+

+
#[test]
+
fn rad_patch_merge_strict_destination() {
+
    Environment::alice(["rad-init", "rad-patch-merge-strict-destination"]);
+
}
modified crates/radicle-remote-helper/src/main.rs
@@ -207,6 +207,8 @@ struct Options {
    message: cli::patch::Message,
    /// Create a branch and set its upstream when opening a patch.
    branch: Branch,
+
    /// Patch destination branch to use, when opening or updating a patch.
+
    destination: Option<git::fmt::RefString>,
    verbosity: Verbosity,
}

@@ -439,6 +441,9 @@ fn push_option(args: &[&str], opts: &mut Options) -> Result<(), Error> {
                "patch.branch" => {
                    opts.branch = Branch::Provided(git::fmt::RefString::try_from(val)?)
                }
+
                "patch.destination" => {
+
                    opts.destination = Some(git::fmt::RefString::try_from(val)?);
+
                }
                other => {
                    return Err(Error::UnsupportedPushOption(other.to_owned()));
                }
modified crates/radicle-remote-helper/src/push.rs
@@ -114,6 +114,9 @@ pub(super) enum Error {
    UnknownObjectType { oid: git::Oid },
    #[error(transparent)]
    FindObjects(#[from] git::canonical::error::FindObjectsError),
+
    /// Default branch error.
+
    #[error(transparent)]
+
    DefaultBranch(#[from] radicle::identity::doc::DefaultBranchError),

    /// Error sending pack from the working copy to storage.
    #[error(
@@ -583,6 +586,7 @@ where
            title,
            &description,
            patch::MergeTarget::default(),
+
            opts.destination.clone(),
            base,
            *head,
            &[],
@@ -592,6 +596,7 @@ where
            title,
            &description,
            patch::MergeTarget::default(),
+
            opts.destination.clone(),
            base,
            *head,
            &[],
@@ -759,7 +764,15 @@ where
    // and pushed, but the patch hasn't yet been updated. On push to the patch branch,
    // it'll seem like the patch is "empty", because the changes are already in the base branch.
    if base == *head && patch_mut.is_open() {
-
        patch_merge(patch_mut, revision.id(), *head, working, signer)?;
+
        let destination = patch_mut.destination().cloned();
+
        patch_merge(
+
            patch_mut,
+
            revision.id(),
+
            *head,
+
            destination,
+
            working,
+
            signer,
+
        )?;
    } else {
        eprintln!(
            "To compare against your previous revision {}, run:\n\n   {}\n",
@@ -814,18 +827,38 @@ where
    )?;

    if let Some(old) = old {
-
        let proj = stored.project()?;
-
        let master = &*git::fmt::Qualified::from(git::fmt::lit::refs_heads(proj.default_branch()));
+
        let identity = stored.identity()?;
+
        let crefs = identity.doc().canonical_refs()?;
+
        let rules = crefs.rules();
+
        let me = Did::from(nid);

-
        // If we're pushing to the project's default branch, we want to see if any patches got
+
        let stripped_dst = dst.strip_namespace();
+

+
        // If we're pushing to a valid canonical branch, we want to see if any patches got
        // merged or reverted, and if so, update the patch COB.
-
        if &*dst.strip_namespace() == master {
+
        if let Some((_, rule)) = rules.matches(&stripped_dst).next()
+
            && rule.allowed().contains(&me)
+
        {
            let old = old.peel_to_commit()?.id();
-
            // Only delegates affect the merge state of the COB.
-
            if stored.delegates()?.contains(&nid.into()) {
-
                patch_revert_all(old.into(), head, &stored.backend, &mut patches)?;
-
                patch_merge_all(old.into(), head, working, &mut patches, signer)?;
-
            }
+
            let destination = Some(stripped_dst.to_ref_string());
+

+
            patch_revert_all(
+
                old.into(),
+
                head,
+
                destination.clone(),
+
                &stored.backend,
+
                &mut patches,
+
                &identity,
+
            )?;
+
            patch_merge_all(
+
                old.into(),
+
                head,
+
                destination,
+
                working,
+
                &mut patches,
+
                signer,
+
                &identity,
+
            )?;
        }
    }
    Ok(Some(ExplorerResource::Tree { oid: head }))
@@ -835,6 +868,7 @@ where
fn patch_revert_all<Signer>(
    old: git::Oid,
    new: git::Oid,
+
    destination: Option<git::fmt::RefString>,
    stored: &git::raw::Repository,
    patches: &mut patch::Cache<
        '_,
@@ -842,6 +876,7 @@ fn patch_revert_all<Signer>(
        WriteAs<'_, Signer>,
        cob::cache::StoreWriter,
    >,
+
    identity: &radicle::identity::Identity,
) -> Result<(), Error>
where
    Signer: crypto::signature::Keypair<VerifyingKey = crypto::PublicKey>,
@@ -868,6 +903,10 @@ where
        .collect::<Vec<_>>();

    for (id, patch) in merged {
+
        if !merge_destinations_match(&destination, identity, id, &patch) {
+
            continue;
+
        }
+

        let revisions = patch
            .revisions()
            .map(|(id, r)| (id, r.head()))
@@ -898,10 +937,29 @@ where
    Ok(())
}

+
fn merge_destinations_match(
+
    destination: &Option<git::fmt::RefString>,
+
    identity: &cob::identity::Identity,
+
    id: cob::ObjectId,
+
    patch: &patch::Patch,
+
) -> bool {
+
    let expected_dst = match patch.merge_destination(identity.doc()) {
+
        Ok(dst) => dst,
+
        Err(e) => {
+
            log::warn!(target: "push", "Failed to resolve merge destination for patch {}: {}", id, e);
+
            return false;
+
        }
+
    };
+
    let pushed_dst = git::fmt::Qualified::from_refstr(destination.as_ref().unwrap());
+

+
    pushed_dst.is_some() && Some(expected_dst) == pushed_dst
+
}
+

/// Merge all patches that have been included in the base branch.
fn patch_merge_all<Signer>(
    old: git::Oid,
    new: git::Oid,
+
    destination: Option<git::fmt::RefString>,
    working: &git::raw::Repository,
    patches: &mut patch::Cache<
        '_,
@@ -910,6 +968,7 @@ fn patch_merge_all<Signer>(
        cob::cache::StoreWriter,
    >,
    signer: &Signer,
+
    identity: &radicle::identity::Identity,
) -> Result<(), Error>
where
    Signer: crypto::signature::Keypair<VerifyingKey = crypto::PublicKey>,
@@ -935,6 +994,10 @@ where
        .filter_map(|patch| patch.ok())
        .collect::<Vec<_>>();
    for (id, patch) in open {
+
        if !merge_destinations_match(&destination, identity, id, &patch) {
+
            continue;
+
        }
+

        // Later revisions are more likely to be merged, so we build the list backwards.
        let revisions = patch
            .revisions()
@@ -948,7 +1011,14 @@ where
        for commit in &commits {
            if let Some((revision_id, head)) = revisions.iter().find(|(_, head)| commit == head) {
                let patch = patch::PatchMut::new(id, patch, patches);
-
                patch_merge(patch, *revision_id, *head, working, signer)?;
+
                patch_merge(
+
                    patch,
+
                    *revision_id,
+
                    *head,
+
                    destination.clone(),
+
                    working,
+
                    signer,
+
                )?;

                break;
            }
@@ -961,6 +1031,7 @@ fn patch_merge<Signer, C>(
    mut patch: patch::PatchMut<'_, '_, '_, storage::git::Repository, Signer, C>,
    revision: patch::RevisionId,
    commit: git::Oid,
+
    destination: Option<git::fmt::RefString>,
    working: &git::raw::Repository,
    signer: &Signer,
) -> Result<(), Error>
@@ -972,7 +1043,7 @@ where
    C: cob::cache::Update<patch::Patch>,
{
    let (latest, _) = patch.latest();
-
    let merged = patch.merge(revision, commit)?;
+
    let merged = patch.merge(revision, commit, destination)?;

    if revision == latest {
        eprintln!(
modified crates/radicle/src/cob/patch.rs
@@ -30,7 +30,7 @@ use crate::cob::{ActorId, Embed, EntryId, ObjectId, TypeName, Uri, op, store};
use crate::crypto::PublicKey;
use crate::git;
use crate::identity::PayloadError;
-
use crate::identity::doc::{DocAt, DocError};
+
use crate::identity::doc::{DefaultBranchError, DocAt, DocError};
use crate::prelude::*;
use crate::storage;

@@ -119,6 +119,8 @@ pub enum Error {
    /// Identity document is missing.
    #[error("missing identity document")]
    MissingIdentity,
+
    #[error(transparent)]
+
    DefaultBranch(#[from] DefaultBranchError),
    /// Review is empty.
    #[error("empty review; verdict or summary not provided")]
    EmptyReview,
@@ -178,6 +180,8 @@ pub enum Action {
    Edit {
        title: cob::Title,
        target: MergeTarget,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        destination: Option<git::fmt::RefString>,
    },
    #[serde(rename = "label")]
    Label { labels: BTreeSet<Label> },
@@ -189,6 +193,8 @@ pub enum Action {
    Merge {
        revision: RevisionId,
        commit: git::Oid,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        destination: Option<git::fmt::RefString>,
    },

    //
@@ -434,6 +440,8 @@ pub struct Patch {
    pub(super) state: State,
    /// Target this patch is meant to be merged in.
    pub(super) target: MergeTarget,
+
    /// The specific branch this patch targets, if not the default branch.
+
    pub(super) destination: Option<git::fmt::RefString>,
    /// Associated labels.
    /// Labels can be added and removed at will.
    pub(super) labels: BTreeSet<Label>,
@@ -463,6 +471,7 @@ impl Patch {
    pub fn new(
        title: cob::Title,
        target: MergeTarget,
+
        destination: Option<git::fmt::RefString>,
        (id, revision): (RevisionId, Revision),
    ) -> Self {
        Self {
@@ -470,6 +479,7 @@ impl Patch {
            author: revision.author.clone(),
            state: State::default(),
            target,
+
            destination,
            labels: BTreeSet::default(),
            merges: BTreeMap::default(),
            revisions: BTreeMap::from_iter([(id, Some(revision))]),
@@ -494,6 +504,39 @@ impl Patch {
        self.target
    }

+
    /// The specific branch this patch targets, if not the default branch.
+
    pub fn destination(&self) -> Option<&git::fmt::RefString> {
+
        self.destination.as_ref()
+
    }
+

+
    /// Resolves a target branch from an optional reference string.
+
    /// If the reference is omitted, it falls back to the project's default branch.
+
    fn resolve_target<'a>(
+
        target: Option<&'a git::fmt::RefString>,
+
        doc: &'a Doc,
+
    ) -> Result<git::fmt::Qualified<'a>, DefaultBranchError> {
+
        if let Some(dest) = target {
+
            if let Some(q) = git::fmt::Qualified::from_refstr(dest) {
+
                Ok(q.to_owned())
+
            } else {
+
                Ok(git::fmt::lit::refs_heads(dest).into())
+
            }
+
        } else {
+
            doc.default_branch()
+
        }
+
    }
+

+
    /// Resolves the intended destination branch for this patch.
+
    ///
+
    /// If a custom destination was specified, it returns that branch.
+
    /// Otherwise, it falls back to the project's default branch.
+
    pub fn merge_destination<'a>(
+
        &'a self,
+
        doc: &'a Doc,
+
    ) -> Result<git::fmt::Qualified<'a>, DefaultBranchError> {
+
        Self::resolve_target(self.destination.as_ref(), doc)
+
    }
+

    /// Timestamp of the first revision of the patch.
    pub fn timestamp(&self) -> Timestamp {
        self.updates()
@@ -698,9 +741,23 @@ impl Patch {
                }
            }
            Action::Assign { .. } => Authorization::Deny,
-
            Action::Merge { .. } => match self.target() {
-
                MergeTarget::Delegates => Authorization::Deny,
-
            },
+
            Action::Merge { destination, .. } => {
+
                let dest = Self::resolve_target(destination.as_ref(), doc)?;
+
                let expected_dest = self.merge_destination(doc)?;
+

+
                // Ensure the merge action matches the patch's intended destination.
+
                if dest != expected_dest {
+
                    return Ok(Authorization::Deny);
+
                }
+

+
                if let Ok(crefs) = doc.canonical_refs()
+
                    && let Some((_, rule)) = crefs.rules().matches(&dest).next()
+
                {
+
                    return Ok(Authorization::from(rule.allowed().contains(&actor.into())));
+
                }
+

+
                Authorization::Deny
+
            }
            // Anyone can submit a review.
            Action::Review { .. } => Authorization::Allow,
            Action::ReviewRedact { review, .. } => {
@@ -827,9 +884,14 @@ impl Patch {
        repo: &R,
    ) -> Result<(), Error> {
        match action {
-
            Action::Edit { title, target } => {
+
            Action::Edit {
+
                title,
+
                target,
+
                destination,
+
            } => {
                self.title = title;
                self.target = target;
+
                self.destination = destination;
            }
            Action::Lifecycle { state } => {
                let valid = self.state == State::Draft
@@ -1079,30 +1141,36 @@ impl Patch {
                    }
                }
            }
-
            Action::Merge { revision, commit } => {
+
            Action::Merge {
+
                revision,
+
                commit,
+
                destination,
+
            } => {
                // If the revision was redacted before the merge, ignore the merge.
                if lookup::revision_mut(self, &revision)?.is_none() {
                    return Ok(());
                };
-
                match self.target() {
-
                    MergeTarget::Delegates => {
-
                        let proj = identity.project()?;
-
                        let branch = git::refs::branch(proj.default_branch());
-

-
                        // Nb. We don't return an error in case the merge commit is not an
-
                        // ancestor of the default branch. The default branch can change
-
                        // *after* the merge action is created, which is out of the control
-
                        // of the merge author. We simply skip it, which allows archiving in
-
                        // case of a rebase off the master branch, or a redaction of the
-
                        // merge.
-
                        let Ok(head) = repo.reference_oid(&author, &branch) else {
-
                            return Ok(());
-
                        };
-
                        if commit != head && !repo.is_ancestor_of(commit, head)? {
-
                            return Ok(());
-
                        }
-
                    }
+

+
                let branch = Self::resolve_target(destination.as_ref(), identity)?;
+
                let expected_branch = self.merge_destination(identity)?;
+

+
                if branch != expected_branch {
+
                    return Ok(());
+
                }
+

+
                // Nb. We don't return an error in case the merge commit is not an
+
                // ancestor of the default branch. The default branch can change
+
                // *after* the merge action is created, which is out of the control
+
                // of the merge author. We simply skip it, which allows archiving in
+
                // case of a rebase off the master branch, or a redaction of the
+
                // merge.
+
                let Ok(head) = repo.reference_oid(&author, &branch) else {
+
                    return Ok(());
+
                };
+
                if commit != head && !repo.is_ancestor_of(commit, head)? {
+
                    return Ok(());
                }
+

                self.merges.insert(
                    author,
                    Merge {
@@ -1229,7 +1297,12 @@ impl store::Cob for Patch {
        else {
            return Err(Error::Init("the first action must be of type `revision`"));
        };
-
        let Some(Action::Edit { title, target }) = actions.next() else {
+
        let Some(Action::Edit {
+
            title,
+
            target,
+
            destination,
+
        }) = actions.next()
+
        else {
            return Err(Error::Init("the second action must be of type `edit`"));
        };
        let revision = Revision::new(
@@ -1241,7 +1314,7 @@ impl store::Cob for Patch {
            op.timestamp,
            resolves,
        );
-
        let mut patch = Patch::new(title, target, (RevisionId(op.id), revision));
+
        let mut patch = Patch::new(title, target, destination, (RevisionId(op.id), revision));

        for action in actions {
            match patch.authorization(&action, &op.author, &doc)? {
@@ -1754,8 +1827,17 @@ impl Review {
}

impl<R: ReadRepository> store::Transaction<Patch, R> {
-
    pub fn edit(&mut self, title: cob::Title, target: MergeTarget) -> Result<(), store::Error> {
-
        self.push(Action::Edit { title, target })
+
    pub fn edit(
+
        &mut self,
+
        title: cob::Title,
+
        target: MergeTarget,
+
        destination: Option<git::fmt::RefString>,
+
    ) -> Result<(), store::Error> {
+
        self.push(Action::Edit {
+
            title,
+
            target,
+
            destination,
+
        })
    }

    pub fn edit_revision(
@@ -2003,8 +2085,17 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
    }

    /// Merge a patch revision.
-
    pub fn merge(&mut self, revision: RevisionId, commit: git::Oid) -> Result<(), store::Error> {
-
        self.push(Action::Merge { revision, commit })
+
    pub fn merge(
+
        &mut self,
+
        revision: RevisionId,
+
        commit: git::Oid,
+
        destination: Option<git::fmt::RefString>,
+
    ) -> Result<(), store::Error> {
+
        self.push(Action::Merge {
+
            revision,
+
            commit,
+
            destination,
+
        })
    }

    /// Update a patch with a new revision.
@@ -2104,8 +2195,13 @@ where
    }

    /// Edit patch metadata.
-
    pub fn edit(&mut self, title: cob::Title, target: MergeTarget) -> Result<EntryId, Error> {
-
        self.transaction("Edit", |tx| tx.edit(title, target))
+
    pub fn edit(
+
        &mut self,
+
        title: cob::Title,
+
        target: MergeTarget,
+
        destination: Option<git::fmt::RefString>,
+
    ) -> Result<EntryId, Error> {
+
        self.transaction("Edit", |tx| tx.edit(title, target, destination))
    }

    /// Edit revision metadata.
@@ -2330,9 +2426,12 @@ where
        &mut self,
        revision: RevisionId,
        commit: git::Oid,
+
        destination: Option<git::fmt::RefString>,
    ) -> Result<Merged<'_, Repo>, Error> {
        // TODO: Don't allow merging the same revision twice?
-
        let entry = self.transaction("Merge revision", |tx| tx.merge(revision, commit))?;
+
        let entry = self.transaction("Merge revision", |tx| {
+
            tx.merge(revision, commit, destination)
+
        })?;

        Ok(Merged {
            entry,
@@ -2567,6 +2666,7 @@ where
        title: cob::Title,
        description: impl ToString,
        target: MergeTarget,
+
        destination: Option<git::fmt::RefString>,
        base: impl Into<git::Oid>,
        oid: impl Into<git::Oid>,
        labels: &[Label],
@@ -2582,6 +2682,7 @@ where
            title,
            description,
            target,
+
            destination,
            base,
            oid,
            labels,
@@ -2596,6 +2697,7 @@ where
        title: cob::Title,
        description: impl ToString,
        target: MergeTarget,
+
        destination: Option<git::fmt::RefString>,
        base: impl Into<git::Oid>,
        oid: impl Into<git::Oid>,
        labels: &[Label],
@@ -2610,6 +2712,7 @@ where
            title,
            description,
            target,
+
            destination,
            base,
            oid,
            labels,
@@ -2643,6 +2746,7 @@ where
        title: cob::Title,
        description: impl ToString,
        target: MergeTarget,
+
        destination: Option<git::fmt::RefString>,
        base: impl Into<git::Oid>,
        oid: impl Into<git::Oid>,
        labels: &[Label],
@@ -2658,7 +2762,7 @@ where
    {
        let (id, patch) = Transaction::initial("Create patch", &mut self.raw, |tx, _| {
            tx.revision(description, base, oid)?;
-
            tx.edit(title, target)?;
+
            tx.edit(title, target, destination)?;

            if !labels.is_empty() {
                tx.label(labels.to_owned())?;
@@ -2874,7 +2978,10 @@ mod test {
    use crate::cob::common::CodeRange;
    use crate::cob::test::Actor;
    use crate::crypto::test::signer::MockSigner;
+
    use crate::git::BranchName;
    use crate::identity;
+
    use crate::identity::doc::RawDoc;
+
    use crate::identity::project::{Project, ProjectName};
    use crate::patch::cache::Patches as _;
    use crate::profile::env;
    use crate::test;
@@ -2884,6 +2991,442 @@ mod test {

    use cob::migrate;

+
    fn revision() -> (RevisionId, Revision) {
+
        let author = arbitrary::r#gen::<Did>(1);
+
        let description = arbitrary::r#gen::<String>(1);
+
        let base = arbitrary::oid();
+
        let oid = arbitrary::oid();
+
        let timestamp = env::local_time();
+
        let resolves = BTreeSet::new();
+
        let id = RevisionId::from(arbitrary::oid());
+
        let mut revision = Revision::new(
+
            id,
+
            Author { id: author },
+
            description,
+
            base,
+
            oid,
+
            timestamp.into(),
+
            resolves,
+
        );
+
        let comment = Comment::new(
+
            *author,
+
            "#1 comment".to_string(),
+
            None,
+
            None,
+
            vec![],
+
            timestamp.into(),
+
        );
+
        let thread = Thread::new(arbitrary::oid(), comment);
+
        revision.discussion = thread;
+
        (id, revision)
+
    }
+

+
    #[test]
+
    fn test_destination_forwards_compatibility() {
+
        #[allow(dead_code)]
+
        #[derive(Debug, Deserialize)]
+
        #[serde(tag = "type", rename_all = "camelCase")]
+
        enum OldAction {
+
            #[serde(rename = "edit")]
+
            Edit { title: String, target: String },
+
            #[serde(rename = "merge")]
+
            Merge { revision: String, commit: String },
+
        }
+

+
        let new_edit_json = serde_json::json!({
+
            "type": "edit",
+
            "title": "My patch",
+
            "target": "delegates",
+
            "destination": "refs/heads/accepted"
+
        });
+

+
        let new_merge_json = serde_json::json!({
+
            "type": "merge",
+
            "revision": arbitrary::entry_id().to_string(),
+
            "commit": arbitrary::oid().to_string(),
+
            "destination": "refs/heads/accepted"
+
        });
+

+
        let old_edit: OldAction = serde_json::from_value(new_edit_json).expect(
+
            "Old client should successfully ignore the unknown `destination` field in Edit",
+
        );
+

+
        assert!(matches!(old_edit, OldAction::Edit { .. }));
+

+
        let old_merge: OldAction = serde_json::from_value(new_merge_json).expect(
+
            "Old client should successfully ignore the unknown `destination` field in Merge",
+
        );
+

+
        assert!(matches!(old_merge, OldAction::Merge { .. }));
+
    }
+

+
    #[test]
+
    fn test_json_serialisation_destination() {
+
        let edit_none = Action::Edit {
+
            title: cob::Title::new("My patch").unwrap(),
+
            target: MergeTarget::Delegates,
+
            destination: None,
+
        };
+
        assert_eq!(
+
            serde_json::to_string(&edit_none).unwrap(),
+
            String::from(r#"{"type":"edit","title":"My patch","target":"delegates"}"#)
+
        );
+

+
        let edit_some = Action::Edit {
+
            title: cob::Title::new("My patch").unwrap(),
+
            target: MergeTarget::Delegates,
+
            destination: Some(git::fmt::RefString::try_from("refs/heads/accepted").unwrap()),
+
        };
+
        assert_eq!(
+
            serde_json::to_string(&edit_some).unwrap(),
+
            String::from(
+
                r#"{"type":"edit","title":"My patch","target":"delegates","destination":"refs/heads/accepted"}"#
+
            )
+
        );
+
    }
+

+
    #[test]
+
    fn test_merge_destination_resolution() {
+
        let alice = Actor::<MockSigner>::default();
+
        let project = Project::new(
+
            ProjectName::from_str("test_merge_destination_resolution").unwrap(),
+
            String::from(""),
+
            BranchName::from(git::fmt::RefString::try_from("master").unwrap()),
+
        );
+

+
        let doc = RawDoc::new(
+
            project.unwrap(),
+
            vec![alice.did()],
+
            1,
+
            identity::Visibility::Public,
+
        )
+
        .verified()
+
        .unwrap();
+

+
        let patch_none = Patch::new(
+
            cob::Title::new("My patch").unwrap(),
+
            MergeTarget::Delegates,
+
            None,
+
            revision(),
+
        );
+
        assert_eq!(
+
            patch_none.merge_destination(&doc).unwrap().as_str(),
+
            "refs/heads/master"
+
        );
+

+
        let patch_unqualified = Patch::new(
+
            cob::Title::new("My patch").unwrap(),
+
            MergeTarget::Delegates,
+
            Some(git::fmt::RefString::try_from("accepted").unwrap()),
+
            revision(),
+
        );
+
        assert_eq!(
+
            patch_unqualified.merge_destination(&doc).unwrap().as_str(),
+
            "refs/heads/accepted"
+
        );
+

+
        let patch_qualified_branch = Patch::new(
+
            cob::Title::new("My patch").unwrap(),
+
            MergeTarget::Delegates,
+
            Some(git::fmt::RefString::try_from("refs/heads/accepted").unwrap()),
+
            revision(),
+
        );
+
        assert_eq!(
+
            patch_qualified_branch
+
                .merge_destination(&doc)
+
                .unwrap()
+
                .as_str(),
+
            "refs/heads/accepted"
+
        );
+

+
        let patch_qualified_tag = Patch::new(
+
            cob::Title::new("My patch").unwrap(),
+
            MergeTarget::Delegates,
+
            Some(git::fmt::RefString::try_from("refs/tags/v1.0").unwrap()),
+
            revision(),
+
        );
+
        assert_eq!(
+
            patch_qualified_tag
+
                .merge_destination(&doc)
+
                .unwrap()
+
                .as_str(),
+
            "refs/tags/v1.0"
+
        );
+
    }
+

+
    #[test]
+
    fn test_patch_merge_authorization_ref_formats() {
+
        let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
+
        let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
+
        let alice = Actor::<MockSigner>::default();
+

+
        let mut raw_doc = RawDoc::new(
+
            r#gen::<Project>(1),
+
            vec![alice.did()],
+
            1,
+
            identity::Visibility::Public,
+
        );
+

+
        let rules = serde_json::json!({
+
            "refs/heads/accepted": {
+
                "allow": "delegates",
+
                "threshold": 1
+
            },
+
            "refs/tags/v1.0": {
+
                "allow": "delegates",
+
                "threshold": 1
+
            }
+
        });
+
        let crefs = serde_json::json!({ "rules": rules });
+
        raw_doc.payload.insert(
+
            identity::doc::PayloadId::canonical_refs(),
+
            identity::doc::Payload::from(crefs),
+
        );
+

+
        let doc = raw_doc.verified().unwrap();
+
        let patch = Patch::new(
+
            cob::Title::new("My Patch").unwrap(),
+
            MergeTarget::Delegates,
+
            None,
+
            (
+
                RevisionId(arbitrary::entry_id()),
+
                Revision::new(
+
                    RevisionId(arbitrary::entry_id()),
+
                    Author::new(alice.did()),
+
                    String::new(),
+
                    base,
+
                    oid,
+
                    env::local_time().into(),
+
                    Default::default(),
+
                ),
+
            ),
+
        );
+

+
        let merge_unqualified = Action::Merge {
+
            revision: RevisionId(arbitrary::entry_id()),
+
            commit: oid,
+
            destination: Some(git::fmt::RefString::try_from("accepted").unwrap()),
+
        };
+
        assert_eq!(
+
            patch
+
                .authorization(&merge_unqualified, &alice.did().into(), &doc)
+
                .unwrap(),
+
            Authorization::Allow
+
        );
+

+
        let merge_qualified_branch = Action::Merge {
+
            revision: RevisionId(arbitrary::entry_id()),
+
            commit: oid,
+
            destination: Some(git::fmt::RefString::try_from("refs/heads/accepted").unwrap()),
+
        };
+
        assert_eq!(
+
            patch
+
                .authorization(&merge_qualified_branch, &alice.did().into(), &doc)
+
                .unwrap(),
+
            Authorization::Allow
+
        );
+

+
        let merge_qualified_tag = Action::Merge {
+
            revision: RevisionId(arbitrary::entry_id()),
+
            commit: oid,
+
            destination: Some(git::fmt::RefString::try_from("refs/tags/v1.0").unwrap()),
+
        };
+
        assert_eq!(
+
            patch
+
                .authorization(&merge_qualified_tag, &alice.did().into(), &doc)
+
                .unwrap(),
+
            Authorization::Allow
+
        );
+
    }
+

+
    #[test]
+
    fn test_patch_merge_mismatched_destination_rejected() {
+
        let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
+
        let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
+
        let alice = Actor::<MockSigner>::default();
+
        let bob = Actor::<MockSigner>::default();
+

+
        let mut raw_doc = RawDoc::new(
+
            r#gen::<Project>(1),
+
            vec![bob.did()],
+
            1,
+
            identity::Visibility::Public,
+
        );
+

+
        let rules = serde_json::json!({
+
            "refs/heads/accepted": {
+
                "allow": "delegates",
+
                "threshold": 1
+
            },
+
            "refs/heads/master": {
+
                "allow": "delegates",
+
                "threshold": 1
+
            }
+
        });
+
        let crefs = serde_json::json!({ "rules": rules });
+
        raw_doc.payload.insert(
+
            identity::doc::PayloadId::canonical_refs(),
+
            identity::doc::Payload::from(crefs),
+
        );
+

+
        let doc = raw_doc.verified().unwrap();
+
        let patch = Patch::new(
+
            cob::Title::new("My Patch").unwrap(),
+
            MergeTarget::Delegates,
+
            Some(git::fmt::RefString::try_from("accepted").unwrap()),
+
            (
+
                RevisionId(arbitrary::entry_id()),
+
                Revision::new(
+
                    RevisionId(arbitrary::entry_id()),
+
                    Author::new(alice.did()),
+
                    String::new(),
+
                    base,
+
                    oid,
+
                    env::local_time().into(),
+
                    Default::default(),
+
                ),
+
            ),
+
        );
+

+
        // Alice is a delegate for both `accepted` and `master`.
+
        // But the patch is intended for `accepted`.
+
        // If she tries to merge it into `master`, it should be denied.
+
        let merge_action = Action::Merge {
+
            revision: RevisionId(arbitrary::entry_id()),
+
            commit: oid,
+
            destination: Some(git::fmt::RefString::try_from("master").unwrap()),
+
        };
+

+
        assert_eq!(
+
            patch
+
                .authorization(&merge_action, &alice.did().into(), &doc)
+
                .unwrap(),
+
            Authorization::Deny
+
        );
+
    }
+

+
    #[test]
+
    fn test_patch_merge_custom_destination_authorized() {
+
        let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
+
        let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
+
        let alice = Actor::<MockSigner>::default();
+
        let bob = Actor::<MockSigner>::default();
+

+
        let mut raw_doc = RawDoc::new(
+
            r#gen::<Project>(1),
+
            vec![bob.did()],
+
            1,
+
            identity::Visibility::Public,
+
        );
+

+
        let rules = serde_json::json!({
+
            "refs/heads/accepted": {
+
                "allow": [alice.did()],
+
                "threshold": 1
+
            }
+
        });
+
        let crefs = serde_json::json!({
+
            "rules": rules
+
        });
+
        raw_doc.payload.insert(
+
            identity::doc::PayloadId::canonical_refs(),
+
            identity::doc::Payload::from(crefs),
+
        );
+

+
        let doc = raw_doc.verified().unwrap();
+
        let patch = Patch::new(
+
            cob::Title::new("My Patch").unwrap(),
+
            MergeTarget::Delegates,
+
            Some(git::fmt::RefString::try_from("accepted").unwrap()),
+
            (
+
                RevisionId(arbitrary::entry_id()),
+
                Revision::new(
+
                    RevisionId(arbitrary::entry_id()),
+
                    Author::new(bob.did()),
+
                    String::new(),
+
                    base,
+
                    oid,
+
                    env::local_time().into(),
+
                    Default::default(),
+
                ),
+
            ),
+
        );
+

+
        let merge_action = Action::Merge {
+
            revision: RevisionId(arbitrary::entry_id()),
+
            commit: oid,
+
            destination: Some(git::fmt::RefString::try_from("accepted").unwrap()),
+
        };
+

+
        assert_eq!(
+
            patch
+
                .authorization(&merge_action, &alice.did().into(), &doc)
+
                .unwrap(),
+
            Authorization::Allow,
+
        );
+
    }
+

+
    #[test]
+
    fn test_patch_merge_custom_destination_unauthorized() {
+
        let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
+
        let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
+
        let alice = Actor::<MockSigner>::default();
+
        let bob = Actor::<MockSigner>::default();
+

+
        let mut raw_doc = RawDoc::new(
+
            r#gen::<Project>(1),
+
            vec![alice.did()],
+
            1,
+
            identity::Visibility::Public,
+
        );
+

+
        let rules = serde_json::json!({
+
            "refs/heads/accepted": {
+
                "allow": [alice.did()],
+
                "threshold": 1
+
            }
+
        });
+
        let crefs = serde_json::json!({
+
            "rules": rules
+
        });
+
        raw_doc.payload.insert(
+
            identity::doc::PayloadId::canonical_refs(),
+
            identity::doc::Payload::from(crefs),
+
        );
+

+
        let doc = raw_doc.verified().unwrap();
+
        let patch = Patch::new(
+
            cob::Title::new("My Patch").unwrap(),
+
            MergeTarget::Delegates,
+
            Some(git::fmt::RefString::try_from("accepted").unwrap()),
+
            (
+
                RevisionId(arbitrary::entry_id()),
+
                Revision::new(
+
                    RevisionId(arbitrary::entry_id()),
+
                    Author::new(alice.did()),
+
                    String::new(),
+
                    base,
+
                    oid,
+
                    env::local_time().into(),
+
                    Default::default(),
+
                ),
+
            ),
+
        );
+

+
        let merge_action = Action::Merge {
+
            revision: RevisionId(arbitrary::entry_id()),
+
            commit: oid,
+
            destination: Some(git::fmt::RefString::try_from("accepted").unwrap()),
+
        };
+

+
        assert_eq!(
+
            patch
+
                .authorization(&merge_action, &bob.did().into(), &doc)
+
                .unwrap(),
+
            Authorization::Deny,
+
        );
+
    }
+

    #[test]
    fn test_json_serialization() {
        let edit = Action::Label {
@@ -2950,6 +3493,7 @@ mod test {
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                target,
+
                None,
                branch.base,
                branch.oid,
                &[],
@@ -2989,6 +3533,7 @@ mod test {
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
+
                None,
                branch.base,
                branch.oid,
                &[],
@@ -3021,6 +3566,7 @@ mod test {
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
+
                None,
                branch.base,
                branch.oid,
                &[],
@@ -3029,7 +3575,7 @@ mod test {

        let id = patch.id;
        let (rid, _) = patch.revisions().next().unwrap();
-
        let _merge = patch.merge(rid, branch.base).unwrap();
+
        let _merge = patch.merge(rid, branch.base, None).unwrap();
        let patch = patches.get(&id).unwrap().unwrap();

        let merges = patch.merges.iter().collect::<Vec<_>>();
@@ -3051,6 +3597,7 @@ mod test {
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
+
                None,
                branch.base,
                branch.oid,
                &[],
@@ -3101,6 +3648,7 @@ mod test {
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
+
                None,
                branch.base,
                branch.oid,
                &[],
@@ -3146,6 +3694,7 @@ mod test {
            Action::Edit {
                title: cob::Title::new("My patch").unwrap(),
                target: MergeTarget::Delegates,
+
                destination: None,
            },
        ]);
        let a2 = alice.op::<Patch>([Action::Revision {
@@ -3166,6 +3715,7 @@ mod test {
        let a5 = alice.op::<Patch>([Action::Merge {
            revision: RevisionId(a2.id()),
            commit: oid,
+
            destination: None,
        }]);

        let mut patch = Patch::from_ops([a1, a2], &repo).unwrap();
@@ -3197,6 +3747,7 @@ mod test {
                Action::Edit {
                    title: cob::Title::new("Some patch").unwrap(),
                    target: MergeTarget::Delegates,
+
                    destination: None,
                },
            ],
            time.into(),
@@ -3257,6 +3808,7 @@ mod test {
            Action::Edit {
                title: cob::Title::new("My patch").unwrap(),
                target: MergeTarget::Delegates,
+
                destination: None,
            },
        ]);
        let a2 = alice.op::<Patch>([Action::RevisionReact {
@@ -3288,6 +3840,7 @@ mod test {
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
+
                None,
                branch.base,
                branch.oid,
                &[],
@@ -3325,6 +3878,7 @@ mod test {
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
+
                None,
                branch.base,
                branch.oid,
                &[],
@@ -3355,6 +3909,7 @@ mod test {
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
+
                None,
                branch.base,
                branch.oid,
                &[],
@@ -3404,6 +3959,7 @@ mod test {
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
+
                None,
                branch.base,
                branch.oid,
                &[],
@@ -3449,6 +4005,7 @@ mod test {
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
+
                None,
                branch.base,
                branch.oid,
                &[],
@@ -3497,6 +4054,7 @@ mod test {
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
+
                None,
                branch.base,
                branch.oid,
                &[],
@@ -3545,6 +4103,7 @@ mod test {
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
+
                None,
                branch.base,
                branch.oid,
                &[],
modified crates/radicle/src/cob/patch/cache.rs
@@ -121,6 +121,7 @@ where
        title: cob::Title,
        description: impl ToString,
        target: MergeTarget,
+
        destination: Option<git::fmt::RefString>,
        base: impl Into<git::Oid>,
        oid: impl Into<git::Oid>,
        labels: &[Label],
@@ -133,6 +134,7 @@ where
            title,
            description,
            target,
+
            destination,
            base,
            oid,
            labels,
@@ -148,6 +150,7 @@ where
        title: cob::Title,
        description: impl ToString,
        target: MergeTarget,
+
        destination: Option<git::fmt::RefString>,
        base: impl Into<git::Oid>,
        oid: impl Into<git::Oid>,
        labels: &[Label],
@@ -160,6 +163,7 @@ where
            title,
            description,
            target,
+
            destination,
            base,
            oid,
            labels,
@@ -781,6 +785,7 @@ mod tests {
        let patch = Patch::new(
            Title::new("Patch #1").unwrap(),
            MergeTarget::Delegates,
+
            None,
            revision(),
        );
        let id = ObjectId::from_str("47799cbab2eca047b6520b9fce805da42b49ecab").unwrap();
@@ -791,6 +796,7 @@ mod tests {
            ..Patch::new(
                Title::new("Patch #2").unwrap(),
                MergeTarget::Delegates,
+
                None,
                revision(),
            )
        };
@@ -825,6 +831,7 @@ mod tests {
            let patch = Patch::new(
                Title::new(&id.to_string()).unwrap(),
                MergeTarget::Delegates,
+
                None,
                revision(),
            );
            cache
@@ -838,6 +845,7 @@ mod tests {
                ..Patch::new(
                    Title::new(&id.to_string()).unwrap(),
                    MergeTarget::Delegates,
+
                    None,
                    revision(),
                )
            };
@@ -852,6 +860,7 @@ mod tests {
                ..Patch::new(
                    Title::new(&id.to_string()).unwrap(),
                    MergeTarget::Delegates,
+
                    None,
                    revision(),
                )
            };
@@ -869,6 +878,7 @@ mod tests {
                ..Patch::new(
                    Title::new(&id.to_string()).unwrap(),
                    MergeTarget::Delegates,
+
                    None,
                    revision(),
                )
            };
@@ -907,6 +917,7 @@ mod tests {
            let patch = Patch::new(
                Title::new(&id.to_string()).unwrap(),
                MergeTarget::Delegates,
+
                None,
                revision(),
            );
            cache
@@ -939,6 +950,7 @@ mod tests {
        let mut patch = Patch::new(
            Title::new(&patch_id.to_string()).unwrap(),
            MergeTarget::Delegates,
+
            None,
            (*rev_id, rev.clone()),
        );
        let timeline = revisions.keys().copied().collect::<Vec<_>>();
@@ -979,6 +991,7 @@ mod tests {
            let patch = Patch::new(
                Title::new(&id.to_string()).unwrap(),
                MergeTarget::Delegates,
+
                None,
                revision(),
            );
            cache
@@ -1010,6 +1023,7 @@ mod tests {
            let patch = Patch::new(
                Title::new(&id.to_string()).unwrap(),
                MergeTarget::Delegates,
+
                None,
                revision(),
            );
            cache
@@ -1040,6 +1054,7 @@ mod tests {
            let patch = Patch::new(
                Title::new(&id.to_string()).unwrap(),
                MergeTarget::Delegates,
+
                None,
                revision(),
            );
            cache
modified crates/radicle/src/cob/test.rs
@@ -11,7 +11,7 @@ use crate::cob::store::encoding;
use crate::cob::{Entry, History, Manifest, Timestamp, Version};
use crate::cob::{Title, patch};
use crate::crypto::Signer;
-
use crate::git::Oid;
+
use crate::git::{self, Oid};
use crate::node::device::Device;
use crate::prelude::Did;
use crate::profile::env;
@@ -237,6 +237,7 @@ impl<G: Signer> Actor<G> {
                patch::Action::Edit {
                    title,
                    target: patch::MergeTarget::default(),
+
                    destination: None,
                },
            ]),
            repo,
@@ -267,7 +268,7 @@ fn encoded<T: Cob, G: Signer>(
        email: signer.public_key().to_human(),
        time: Time::new(timestamp.as_secs() as i64, 0),
    };
-
    let commit = CommitData::<git2::Oid, git2::Oid>::new::<_, _, OwnedTrailer>(
+
    let commit = CommitData::<git::raw::Oid, git::raw::Oid>::new::<_, _, OwnedTrailer>(
        oid,
        parents,
        author.clone(),