Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli: Use `git push` to open or update patches
Alexis Sellier committed 3 years ago
commit fd1237b2dba203d54a327c245edaa0abe425520d
parent f8ee109aaaa2c82f29b8b71c8ec5f9d52658a51c
25 files changed +1198 -131
modified Cargo.lock
@@ -2093,6 +2093,7 @@ version = "0.2.0"
dependencies = [
 "radicle",
 "radicle-crypto",
+
 "radicle-git-ext",
 "thiserror",
]

added radicle-cli/examples/git/git-pull.md
@@ -0,0 +1,30 @@
+
```
+
$ cd heartwood
+
$ git branch -r
+
  rad/master
+
  z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master
+
```
+

+
```
+
$ git ls-remote z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi 'refs/heads/*'
+
145e1e69bef3ad93d14946ea212249c2fa9b9828	refs/heads/alice/1
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	refs/heads/master
+
```
+

+
``` (stderr)
+
$ git fetch z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new branch]      alice/1    -> z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/alice/1
+
```
+

+
```
+
$ git branch -r
+
  rad/master
+
  z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/alice/1
+
  z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master
+
```
+

+
```
+
$ git rev-parse z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/alice/1
+
145e1e69bef3ad93d14946ea212249c2fa9b9828
+
```
added radicle-cli/examples/git/git-push-delete.md
@@ -0,0 +1,18 @@
+
Finally, we can also delete branches with `git push`:
+

+
```
+
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi refs/heads/*
+
145e1e69bef3ad93d14946ea212249c2fa9b9828	refs/heads/alice/1
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	refs/heads/master
+
```
+

+
``` (stderr) RAD_SOCKET=/dev/null
+
$ git push rad :alice/1
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 - [deleted]         alice/1
+
```
+

+
```
+
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi refs/heads/*
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	refs/heads/master
+
```
added radicle-cli/examples/git/git-push.md
@@ -0,0 +1,61 @@
+
```
+
$ git checkout -b alice/1
+
$ git commit -m "Alice's commit" --allow-empty -s
+
[alice/1 87fa120] Alice's commit
+
```
+

+
``` (stderr) RAD_SOCKET=/dev/null
+
$ git push rad HEAD:alice/1
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new branch]      HEAD -> alice/1
+
```
+

+
Make sure we can't force-push without `+`:
+

+
``` (stderr)
+
$ git commit --amend -m "Alice's amended commit" --allow-empty -s
+
```
+
``` (stderr) (fail)
+
$ git push rad HEAD:alice/1
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 ! [rejected]        HEAD -> alice/1 (non-fast-forward)
+
error: failed to push some refs to 'rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi'
+
hint: Updates were rejected because the tip of your current branch is behind
+
hint: its remote counterpart. Integrate the remote changes (e.g.
+
hint: 'git pull ...') before pushing again.
+
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
+
```
+

+
And that we can with `+`:
+

+
``` (stderr) RAD_SOCKET=/dev/null
+
$ git push rad +HEAD:alice/1
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 + 87fa120...145e1e6 HEAD -> alice/1 (forced update)
+
```
+

+
```
+
$ git branch -r -vv
+
  rad/alice/1 145e1e6 Alice's amended commit
+
  rad/master  f2de534 Second commit
+
```
+

+
List our namespaced refs:
+

+
```
+
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi 'refs/heads/*'
+
145e1e69bef3ad93d14946ea212249c2fa9b9828	refs/heads/alice/1
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	refs/heads/master
+
```
+

+
List the canonical refs:
+

+
```
+
$ git ls-remote rad
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	refs/heads/master
+
```
+

+
```
+
$ rad sync
+
✓ Synced with 1 node(s)
+
```
added radicle-cli/examples/rad-patch-via-push.md
@@ -0,0 +1,220 @@
+
# Using `git push` to open patches
+

+
Let's checkout a branch, make a commit and push to the magic ref `refs/patches`.
+
When we push to this ref, a patch is created from our commits.
+

+
``` (stderr)
+
$ git checkout -b feature/1
+
Switched to a new branch 'feature/1'
+
$ git commit -a -m "Add things" -q --allow-empty
+
$ git push rad HEAD:refs/patches
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new reference]   HEAD -> refs/patches
+
```
+

+
We can see a patch was created:
+

+
```
+
$ rad patch show 8f0e8ec
+
╭─────────────────────────────────────────────────────────────────────────────────────────╮
+
│ Title     Add things                                                                    │
+
│ Patch     8f0e8ecb47a17e8f3219f33150a4092d645e4875                                      │
+
│ Author    did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi                      │
+
│ Head      42d894a83c9c356552a57af09ccdbd5587a99045                                      │
+
│ Branches  feature/1                                                                     │
+
│ Commits   ahead 1, behind 0                                                             │
+
│ Status    open                                                                          │
+
├─────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ● opened by did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (you) [    ..    ] │
+
╰─────────────────────────────────────────────────────────────────────────────────────────╯
+
```
+

+
If we check our local branch, we can see its upstream is set to track a remote
+
branch associated with this patch:
+

+
```
+
$ git branch -vv
+
* feature/1 42d894a [rad/patches/8f0e8ecb47a17e8f3219f33150a4092d645e4875] Add things
+
  master    f2de534 [rad/master] Second commit
+
```
+

+
Let's check that it's up to date with our local head:
+

+
```
+
$ git status --short --branch
+
## feature/1...rad/patches/8f0e8ecb47a17e8f3219f33150a4092d645e4875
+
$ git fetch
+
$ git push
+
```
+

+
And let's look at our local and remote refs:
+

+
```
+
$ git show-ref
+
42d894a83c9c356552a57af09ccdbd5587a99045 refs/heads/feature/1
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/heads/master
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/remotes/rad/master
+
42d894a83c9c356552a57af09ccdbd5587a99045 refs/remotes/rad/patches/8f0e8ecb47a17e8f3219f33150a4092d645e4875
+
```
+
```
+
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi 'refs/heads/patches/*'
+
42d894a83c9c356552a57af09ccdbd5587a99045	refs/heads/patches/8f0e8ecb47a17e8f3219f33150a4092d645e4875
+
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi 'refs/cobs/*'
+
8f0e8ecb47a17e8f3219f33150a4092d645e4875	refs/cobs/xyz.radicle.patch/8f0e8ecb47a17e8f3219f33150a4092d645e4875
+
```
+

+
We can also create patches by pushing to the `rad/patches` remote. It's a bit
+
simpler:
+

+
``` (stderr)
+
$ git checkout -b feature/2 -q
+
$ git commit -a -m "Add more things" -q --allow-empty
+
$ git push rad/patches
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new reference]   HEAD -> refs/patches
+
```
+

+
We see both branches with upstreams now:
+

+
```
+
$ git branch -vv
+
  feature/1 42d894a [rad/patches/8f0e8ecb47a17e8f3219f33150a4092d645e4875] Add things
+
* feature/2 b94a835 [rad/patches/8678aafff1d1e28430952abf431e60b87e28023c] Add more things
+
  master    f2de534 [rad/master] Second commit
+
```
+

+
And both patches:
+

+
```
+
$ rad patch
+
╭────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●  ID       Title            Author                  Head     +   -   Updated      │
+
├────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●  8678aaf  Add more things  z6MknSL…StBU8Vi  (you)  b94a835  +0  -0  [    ...   ] │
+
│ ●  8f0e8ec  Add things       z6MknSL…StBU8Vi  (you)  42d894a  +0  -0  [    ...   ] │
+
╰────────────────────────────────────────────────────────────────────────────────────╯
+
```
+

+
Note that we can't fetch from `rad/patches`:
+

+
``` (stderr) (fail)
+
$ git fetch rad/patches
+
fatal: couldn't find remote ref refs/patches
+
```
+

+
To update our patch, we simply push commits to the upstream branch:
+

+
```
+
$ git commit -a -m "Improve code" -q --allow-empty
+
```
+

+
``` (stderr)
+
$ git push
+
4b6618a6ccb0b406659364a70a00bb60e4cd7cf0
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
   b94a835..662843e  feature/2 -> patches/8678aafff1d1e28430952abf431e60b87e28023c
+
```
+

+
This last `git push` worked without specifying an upstream branch despite the
+
local branch having a different name than the remote. This is because Radicle
+
configures repositories upon `rad init` with `push.default = upstream`:
+

+
```
+
$ git config --local --get push.default
+
upstream
+
```
+

+
This allows for pushing to the remote patch branch without using the full
+
`<src>:<dst>` syntax.
+

+
We can then see that the patch head has moved:
+

+
```
+
$ rad patch show 8678aaf
+
╭─────────────────────────────────────────────────────────────────────────────────────────╮
+
│ Title     Add more things                                                               │
+
│ Patch     8678aafff1d1e28430952abf431e60b87e28023c                                      │
+
│ Author    did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi                      │
+
│ Head      662843ed81e76efa69d7901fb7bdd775043015d0                                      │
+
│ Branches  feature/2                                                                     │
+
│ Commits   ahead 3, behind 0                                                             │
+
│ Status    open                                                                          │
+
├─────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ● opened by did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (you) [   ...    ] │
+
│ ↑ updated to 4b6618a6ccb0b406659364a70a00bb60e4cd7cf0 (662843e) [              ...    ] │
+
╰─────────────────────────────────────────────────────────────────────────────────────────╯
+
```
+

+
And we can check that all the refs are properly updated in our repository:
+

+
```
+
$ git rev-parse HEAD
+
662843ed81e76efa69d7901fb7bdd775043015d0
+
```
+

+
```
+
$ git status --short --branch
+
## feature/2...rad/patches/8678aafff1d1e28430952abf431e60b87e28023c
+
```
+

+
```
+
$ git rev-parse refs/remotes/rad/patches/8678aafff1d1e28430952abf431e60b87e28023c
+
662843ed81e76efa69d7901fb7bdd775043015d0
+
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi refs/heads/patches/8678aafff1d1e28430952abf431e60b87e28023c
+
662843ed81e76efa69d7901fb7bdd775043015d0	refs/heads/patches/8678aafff1d1e28430952abf431e60b87e28023c
+
```
+

+
## Force push
+

+
Sometimes, it's necessary to force-push a patch update. For example, if we amended
+
the commit and want the updated patch to reflect that.
+

+
Let's try.
+

+
```
+
$ git commit --amend -m "Amended commit" --allow-empty
+
[feature/2 3507cd5] Amended commit
+
 Date: [..]
+
```
+

+
Now let's push to the patch head.
+

+
``` (stderr) (fail)
+
$ git push
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 ! [rejected]        feature/2 -> patches/8678aafff1d1e28430952abf431e60b87e28023c (non-fast-forward)
+
error: failed to push some refs to 'rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi'
+
hint: Updates were rejected because a pushed branch tip is behind its remote
+
hint: counterpart. Check out this branch and integrate the remote changes
+
hint: (e.g. 'git pull ...') before pushing again.
+
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
+
```
+

+
The push fails because it's not a fast-forward update. To remedy this, we can
+
use `--force` to force the update.
+

+
``` (stderr)
+
$ git push --force
+
983f2d21c9fbe91c21ddfbcd3e3d6cd13db0a944
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 + 662843e...3507cd5 feature/2 -> patches/8678aafff1d1e28430952abf431e60b87e28023c (forced update)
+
```
+

+
That worked. We can see the new revision if we call `rad patch show`:
+

+
```
+
$ rad patch show 8678aaf
+
╭─────────────────────────────────────────────────────────────────────────────────────────╮
+
│ Title     Add more things                                                               │
+
│ Patch     8678aafff1d1e28430952abf431e60b87e28023c                                      │
+
│ Author    did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi                      │
+
│ Head      3507cd57811fe5f21f6a0a610a1ac8068b3a5d9f                                      │
+
│ Branches  feature/2                                                                     │
+
│ Commits   ahead 3, behind 0                                                             │
+
│ Status    open                                                                          │
+
├─────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ● opened by did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (you) 4 months ago │
+
│ ↑ updated to 4b6618a6ccb0b406659364a70a00bb60e4cd7cf0 (662843e) 4 months ago            │
+
│ ↑ updated to 983f2d21c9fbe91c21ddfbcd3e3d6cd13db0a944 (3507cd5) 4 months ago            │
+
╰─────────────────────────────────────────────────────────────────────────────────────────╯
+
```
modified radicle-cli/examples/rad-remote.md
@@ -9,9 +9,10 @@ Now, we can see that there is a new remote in the list of remotes:

```
$ rad remote list
-
bob z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk (fetch)
-
rad the canonical upstream                           (fetch)
-
rad z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (push)
+
bob         z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk (fetch)
+
rad         (canonical upstream)                             (fetch)
+
rad         z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (push)
+
rad/patches (patches upstream)                               (push)
```

You can see both `bob` and `rad` as remotes.  The `rad` remote is our personal remote of the project.
modified radicle-cli/examples/workflow/4-patching-contributor.md
@@ -62,6 +62,13 @@ $ rad patch show 5f0a547f7a91bf002bb0542035a647fd5af134a5
╰─────────────────────────────────────────────────────────────────────────────────────────╯
```

+
We can also confirm that the patch branch is in storage:
+

+
```
+
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk refs/heads/flux-capacitor-power
+
3e674d1a1df90807e934f9ae5da2591dd6848a33	refs/heads/flux-capacitor-power
+
```
+

Wait, let's add a README too! Just for fun.

```
modified radicle-cli/examples/workflow/5-patching-maintainer.md
@@ -16,7 +16,13 @@ peer. Upcoming versions of radicle will not require this step.
```
$ rad remote add z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --name bob
✓ Remote bob added with rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+
```
+

+
``` (stderr)
$ git fetch bob
+
From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+
 * [new branch]      flux-capacitor-power -> bob/flux-capacitor-power
+
 * [new branch]      master               -> bob/master
```

The contributor's changes are now visible to us.
modified radicle-cli/src/commands/init.rs
@@ -239,9 +239,9 @@ pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()>
                // Setup eg. `master` -> `rad/master`
                radicle::git::set_upstream(
                    &repo,
-
                    &radicle::rad::REMOTE_NAME,
+
                    &*radicle::rad::REMOTE_NAME,
                    proj.default_branch(),
-
                    &radicle::git::refs::workdir::branch(proj.default_branch()),
+
                    radicle::git::refs::workdir::branch(proj.default_branch()),
                )?;
            } else {
                push_cmd = format!("git push {}", *radicle::rad::REMOTE_NAME);
modified radicle-cli/src/commands/patch/create.rs
@@ -60,11 +60,12 @@ pub fn run(
    let (target_ref, target_oid) = get_merge_target(storage, &head_branch)?;

    if head_branch.upstream().is_err() {
+
        // TODO(cloudhead): Disable this when invoked from remote helper.
        radicle::git::set_upstream(
            workdir,
-
            &radicle::rad::REMOTE_NAME,
+
            &*radicle::rad::REMOTE_NAME,
            branch_name(&head_branch)?,
-
            &head_branch_name,
+
            head_branch_name,
        )?;
    }

modified radicle-cli/src/commands/remote/list.rs
@@ -1,3 +1,4 @@
+
use radicle::rad;
use radicle_term::{Element, Table};

use crate::git;
@@ -11,13 +12,22 @@ pub fn run(repo: &git::Repository) -> anyhow::Result<()> {
            let Some(url) = url else {
                continue;
            };
-
            let nid = url.namespace.map_or(
-
                term::format::dim("the canonical upstream".to_string()).italic(),
-
                |namespace| term::format::tertiary(namespace.to_string()),
-
            );
+

+
            let description = if r.name == rad::PATCHES_REMOTE_NAME.as_str() {
+
                if dir == "fetch" {
+
                    // Fetching from the patches remote is not allowed.
+
                    continue;
+
                }
+
                term::format::dim("(patches upstream)".to_string()).italic()
+
            } else {
+
                url.namespace.map_or(
+
                    term::format::dim("(canonical upstream)".to_string()).italic(),
+
                    |namespace| term::format::tertiary(namespace.to_string()),
+
                )
+
            };
            table.push([
                term::format::bold(r.name.clone()),
-
                nid,
+
                description,
                term::format::parens(term::format::secondary(dir.to_owned())),
            ]);
        }
modified radicle-cli/src/commands/sync.rs
@@ -136,7 +136,7 @@ fn announce(rid: Id, mut node: Node, timeout: time::Duration) -> anyhow::Result<
    let mut seeds = seeds.connected().collect::<BTreeSet<_>>();

    if seeds.is_empty() {
-
        term::info!("Not connected to any seeds");
+
        term::info!("Not connected to any seeds.");
        return Ok(());
    }
    node.announce_refs(rid)?;
modified radicle-cli/src/project.rs
@@ -37,7 +37,7 @@ impl<'a> SetupRemote<'a> {
            let local_branch = radicle::git::refs::workdir::branch(
                node_ref.join(&self.default_branch).as_refstr(),
            );
-
            radicle::git::set_upstream(self.repo, &node.to_string(), &branch_name, &local_branch)?;
+
            radicle::git::set_upstream(self.repo, node.to_string(), &branch_name, local_branch)?;

            return Ok(Some((remote, branch_name)));
        }
modified radicle-cli/tests/commands.rs
@@ -293,6 +293,28 @@ fn rad_patch_draft() {
}

#[test]
+
fn rad_patch_via_push() {
+
    logger::init(log::Level::Debug);
+

+
    let mut environment = Environment::new();
+
    let profile = environment.profile("alice");
+
    let working = tempfile::tempdir().unwrap();
+
    let home = &profile.home;
+

+
    // Setup a test repository.
+
    fixtures::repository(working.path());
+

+
    test("examples/rad-init.md", working.path(), Some(home), []).unwrap();
+
    test(
+
        "examples/rad-patch-via-push.md",
+
        working.path(),
+
        Some(home),
+
        [],
+
    )
+
    .unwrap();
+
}
+

+
#[test]
fn rad_rm() {
    let mut environment = Environment::new();
    let profile = environment.profile("alice");
@@ -792,6 +814,60 @@ fn rad_remote() {
}

#[test]
+
fn git_push_and_pull() {
+
    logger::init(log::Level::Debug);
+

+
    let mut environment = Environment::new();
+
    let alice = environment.node("alice");
+
    let bob = environment.node("bob");
+
    let working = environment.tmp().join("working");
+

+
    fixtures::repository(working.join("alice"));
+

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

+
    let alice = alice.spawn(Config::default());
+
    let mut bob = bob.spawn(Config::default());
+

+
    bob.connect(&alice).converge([&alice]);
+

+
    test(
+
        "examples/rad-clone.md",
+
        &working.join("bob"),
+
        Some(&bob.home),
+
        [],
+
    )
+
    .unwrap();
+
    test(
+
        "examples/git/git-push.md",
+
        &working.join("alice"),
+
        Some(&alice.home),
+
        [],
+
    )
+
    .unwrap();
+
    test(
+
        "examples/git/git-pull.md",
+
        &working.join("bob"),
+
        Some(&bob.home),
+
        [],
+
    )
+
    .unwrap();
+
    test(
+
        "examples/git/git-push-delete.md",
+
        &working.join("alice"),
+
        Some(&alice.home),
+
        [],
+
    )
+
    .unwrap();
+
}
+

+
#[test]
fn rad_workflow() {
    let mut environment = Environment::new();
    let alice = environment.node("alice");
modified radicle-remote-helper/Cargo.toml
@@ -7,6 +7,7 @@ edition = "2021"

[dependencies]
thiserror = "1"
+
radicle-git-ext = { version = "0" }

[dependencies.radicle]
path = "../radicle"
added radicle-remote-helper/src/fetch.rs
@@ -0,0 +1,70 @@
+
use std::io;
+
use std::path::Path;
+

+
use thiserror::Error;
+

+
use radicle::git;
+
use radicle::storage::git::transport::local::Url;
+
use radicle::storage::ReadRepository;
+

+
use crate::read_line;
+

+
#[derive(Debug, Error)]
+
pub enum Error {
+
    /// Invalid command received.
+
    #[error("invalid command `{0}`")]
+
    InvalidCommand(String),
+
    /// I/O error.
+
    #[error("i/o error: {0}")]
+
    Io(#[from] io::Error),
+
    /// Invalid reference name.
+
    #[error("invalid ref: {0}")]
+
    InvalidRef(#[from] radicle::git::fmt::Error),
+
    /// Git error.
+
    #[error("git: {0}")]
+
    Git(#[from] git::raw::Error),
+
}
+

+
/// Run a git fetch command.
+
pub fn run<R: ReadRepository>(
+
    mut refs: Vec<String>,
+
    working: &Path,
+
    url: Url,
+
    stored: R,
+
    stdin: &io::Stdin,
+
) -> Result<(), Error> {
+
    // Read all the `fetch` lines.
+
    let mut line = String::new();
+
    loop {
+
        let tokens = read_line(stdin, &mut line)?;
+
        match tokens.as_slice() {
+
            ["fetch", _oid, refstr] => {
+
                refs.push(refstr.to_string());
+
            }
+
            // An empty line means end of input.
+
            [] => break,
+
            // Once the first `fetch` command is received, we don't expect anything else.
+
            _ => return Err(Error::InvalidCommand(line.trim().to_owned())),
+
        }
+
    }
+

+
    // Verify them and prepare the final refspecs.
+
    let mut refspecs = Vec::new();
+
    for refstr in refs {
+
        let refstr = git::RefString::try_from(refstr)?;
+
        if let Some(nid) = url.namespace {
+
            refspecs.push(nid.to_namespace().join(refstr).to_string());
+
        } else {
+
            refspecs.push(refstr.to_string());
+
        };
+
    }
+

+
    git::raw::Repository::open(working)?
+
        .remote_anonymous(&git::url::File::new(stored.path()).to_string())?
+
        .fetch(&refspecs, None, None)?;
+

+
    // Nb. An empty line means we're done.
+
    println!();
+

+
    Ok(())
+
}
modified radicle-remote-helper/src/lib.rs
@@ -1,34 +1,25 @@
-
#![allow(clippy::collapsible_if)]
-
use std::os::fd::{AsRawFd, FromRawFd};
+
//! The Radicle Git remote helper.
+
//!
+
//! Communication with the user is done via `stderr` (`eprintln`).
+
//! Communication with Git tooling is done via `stdout` (`println`).
+
mod fetch;
+
mod list;
+
mod push;
+

use std::path::PathBuf;
-
use std::{env, io, process};
+
use std::{env, io};

use thiserror::Error;

-
use radicle::crypto::PublicKey;
-
use radicle::node::Handle;
+
use radicle::git;
use radicle::storage::git::transport::local::{Url, UrlError};
-
use radicle::storage::{ReadRepository, WriteRepository, WriteStorage};
-

-
/// The service invoked by git on the remote repository, during a push.
-
const GIT_RECEIVE_PACK: &str = "git-receive-pack";
-
/// The service invoked by git on the remote repository, during a fetch.
-
const GIT_UPLOAD_PACK: &str = "git-upload-pack";
+
use radicle::storage::{ReadRepository, WriteStorage};

#[derive(Debug, Error)]
pub enum Error {
    /// Remote repository not found (or empty).
    #[error("remote repository `{0}` not found")]
    RepositoryNotFound(PathBuf),
-
    /// Secret key is not registered, eg. with ssh-agent.
-
    #[error("public key `{0}` is not registered with ssh-agent")]
-
    KeyNotRegistered(PublicKey),
-
    /// Public key doesn't match the remote namespace we're pushing to.
-
    #[error("public key `{0}` does not match remote namespace")]
-
    KeyMismatch(PublicKey),
-
    /// No public key is given
-
    #[error("no public key given as a remote namespace, perhaps you are attempting to push to restricted refs")]
-
    NoKey,
    /// Invalid command received.
    #[error("invalid command `{0}`")]
    InvalidCommand(String),
@@ -38,12 +29,31 @@ pub enum Error {
    /// Error with the remote url.
    #[error("invalid remote url: {0}")]
    RemoteUrl(#[from] UrlError),
+
    /// I/O error.
+
    #[error("i/o error: {0}")]
+
    Io(#[from] io::Error),
+
    /// The `GIT_DIR` env var is not set.
+
    #[error("the `GIT_DIR` environment variable is not set")]
+
    NoGitDir,
+
    /// Git error.
+
    #[error("git: {0}")]
+
    Git(#[from] git::raw::Error),
+
    /// Storage error.
+
    #[error(transparent)]
+
    Storage(#[from] radicle::storage::Error),
+
    /// Fetch error.
+
    #[error(transparent)]
+
    Fetch(#[from] fetch::Error),
+
    /// Push error.
+
    #[error(transparent)]
+
    Push(#[from] push::Error),
+
    /// List error.
+
    #[error(transparent)]
+
    List(#[from] list::Error),
}

/// Run the radicle remote helper using the given profile.
-
pub fn run(profile: radicle::Profile) -> Result<(), Box<dyn std::error::Error + 'static>> {
-
    // `GIT_DIR` is expected to be set, though we aren't using it right now.
-
    let _git_dir = env::var("GIT_DIR").map(PathBuf::from)?;
+
pub fn run(profile: radicle::Profile) -> Result<(), Error> {
    let url: Url = {
        let args = env::args().skip(1).take(2).collect::<Vec<_>>();

@@ -52,104 +62,87 @@ pub fn run(profile: radicle::Profile) -> Result<(), Box<dyn std::error::Error +
            [_, url] => url.parse(),

            _ => {
-
                return Err(Error::InvalidArguments(args).into());
+
                return Err(Error::InvalidArguments(args));
            }
        }
    }?;

-
    let proj = profile.storage.repository_mut(url.repo)?;
-
    if proj.is_empty()? {
-
        return Err(Error::RepositoryNotFound(proj.path().to_path_buf()).into());
+
    let stored = profile.storage.repository_mut(url.repo)?;
+
    if stored.is_empty()? {
+
        return Err(Error::RepositoryNotFound(stored.path().to_path_buf()));
    }

+
    // `GIT_DIR` is expected to be set by Git tooling, and points to the working copy.
+
    let working = env::var("GIT_DIR")
+
        .map(PathBuf::from)
+
        .map_err(|_| Error::NoGitDir)?;
+

    let stdin = io::stdin();
+
    let mut line = String::new();
+

    loop {
-
        let mut line = String::new();
-
        let read = stdin.read_line(&mut line)?;
-
        if read == 0 {
-
            break;
-
        }
+
        let tokens = read_line(&stdin, &mut line)?;

-
        let tokens = line.trim().split(' ').collect::<Vec<_>>();
        match tokens.as_slice() {
-
            // First we are asked about capabilities.
            ["capabilities"] => {
-
                println!("connect");
+
                println!("option");
+
                println!("push"); // Implies `list` command.
+
                println!("fetch");
                println!();
            }
-
            // Since we send a `connect` back, this is what is requested next.
-
            ["connect", service] => {
-
                // Don't allow push if either of these conditions is true:
-
                //
-
                // 1. Our key is not in ssh-agent, which means we won't be able to sign the refs.
-
                // 2. Our key is not the one loaded in the profile, which means that the signed refs
-
                //    won't match the remote we're pushing to.
-
                // 3. The URL namespace is not set, which is used for fetching canonical refs.
-
                let signer = if *service == GIT_RECEIVE_PACK {
-
                    match url.namespace {
-
                        Some(namespace) => {
-
                            if profile.public_key != namespace {
-
                                return Err(Error::KeyMismatch(profile.public_key).into());
-
                            }
-
                        }
-
                        None => return Err(Error::NoKey.into()),
-
                    }
-

-
                    let signer = profile.signer()?;
-

-
                    Some(signer)
-
                } else {
-
                    None
-
                };
-

-
                if *service == GIT_UPLOAD_PACK {
-
                    // TODO: Fetch from network.
-
                }
-
                println!(); // Empty line signifies connection is established.
-

-
                let mut child = process::Command::new(service)
-
                    .arg(proj.path())
-
                    .env("GIT_DIR", proj.path())
-
                    .env(
-
                        "GIT_NAMESPACE",
-
                        url.namespace.map(|ns| ns.to_string()).unwrap_or_default(),
-
                    )
-
                    .stdout(process::Stdio::inherit())
-
                    .stderr(process::Stdio::inherit())
-
                    .stdin(process::Stdio::inherit())
-
                    .spawn()?;
-

-
                if child.wait()?.success() && *service == GIT_RECEIVE_PACK {
-
                    if let Some(signer) = signer {
-
                        proj.sign_refs(&signer)?;
-
                        proj.set_head()?;
-
                        // Connect to local node and announce refs to the network.
-
                        // If our node is not running, we simply skip this step, as the
-
                        // refs will be announced eventually, when the node restarts.
-
                        if radicle::Node::new(profile.socket()).is_running() {
-
                            let stderr = io::stderr().as_raw_fd();
-

-
                            process::Command::new("rad")
-
                                .arg("sync")
-
                                .arg(proj.id.to_string())
-
                                .arg("--verbose")
-
                                .stdout(unsafe { process::Stdio::from_raw_fd(stderr) })
-
                                .stderr(process::Stdio::inherit())
-
                                .spawn()?
-
                                .wait()?;
-
                        }
-
                    }
-
                }
+
            ["option", "verbosity"] => {
+
                println!("ok");
+
            }
+
            ["option", "push-option", _opt] => {
+
                println!("unsupported");
+
            }
+
            ["option", "progress", ..] => {
+
                println!("unsupported");
+
            }
+
            ["option", ..] => {
+
                println!("unsupported");
+
            }
+
            ["fetch", _oid, refstr] => {
+
                return fetch::run(vec![refstr.to_string()], &working, url, stored, &stdin)
+
                    .map_err(Error::from);
+
            }
+
            ["push", refspec] => {
+
                return push::run(
+
                    vec![refspec.to_string()],
+
                    &working,
+
                    url,
+
                    stored,
+
                    &profile,
+
                    &stdin,
+
                )
+
                .map_err(Error::from);
+
            }
+
            ["list"] => {
+
                list::for_fetch(&url, &stored)?;
+
            }
+
            ["list", "for-push"] => {
+
                list::for_push(&profile, &stored)?;
            }
-
            // An empty line means end of input.
            [] => {
-
                break;
+
                return Ok(());
            }
            _ => {
-
                return Err(Error::InvalidCommand(line).into());
+
                return Err(Error::InvalidCommand(line.trim().to_owned()));
            }
        }
    }
+
}
+

+
/// Read one line from stdin, and split it into tokens.
+
pub(crate) fn read_line<'a>(stdin: &io::Stdin, line: &'a mut String) -> io::Result<Vec<&'a str>> {
+
    line.clear();
+

+
    let read = stdin.read_line(line)?;
+
    if read == 0 {
+
        return Ok(vec![]);
+
    }
+
    let line = line.trim();
+
    let tokens = line.split(' ').filter(|t| !t.is_empty()).collect();

-
    Ok(())
+
    Ok(tokens)
}
added radicle-remote-helper/src/list.rs
@@ -0,0 +1,54 @@
+
use thiserror::Error;
+

+
use radicle::git;
+
use radicle::storage::git::transport::local::Url;
+
use radicle::storage::ReadRepository;
+
use radicle::Profile;
+

+
#[derive(Debug, Error)]
+
pub enum Error {
+
    /// Storage error.
+
    #[error(transparent)]
+
    Storage(#[from] radicle::storage::Error),
+
    /// Identity error.
+
    #[error(transparent)]
+
    Identity(#[from] radicle::identity::IdentityError),
+
    /// Git error.
+
    #[error(transparent)]
+
    Git(#[from] radicle::git::ext::Error),
+
}
+

+
/// List refs for fetching (`git fetch` and `git ls-remote`).
+
pub fn for_fetch<R: ReadRepository>(url: &Url, stored: &R) -> Result<(), Error> {
+
    if let Some(namespace) = url.namespace {
+
        // Listing namespaced refs.
+
        for (name, oid) in stored.references_of(&namespace)? {
+
            println!("{oid} {name}");
+
        }
+
    } else {
+
        // Listing canonical refs.
+
        // We skip over `refs/rad/*`, since those are not meant to be fetched into a working copy.
+
        for glob in [
+
            git::refspec::pattern!("refs/heads/*"),
+
            git::refspec::pattern!("refs/tags/*"),
+
        ] {
+
            for (name, oid) in stored.references_glob(&glob)? {
+
                println!("{oid} {name}");
+
            }
+
        }
+
    }
+
    println!();
+

+
    Ok(())
+
}
+

+
/// List refs for pushing (`git push`).
+
pub fn for_push<R: ReadRepository>(profile: &Profile, stored: &R) -> Result<(), Error> {
+
    // Only our own refs can be pushed to.
+
    for (name, oid) in stored.references_of(profile.id())? {
+
        println!("{oid} {name}");
+
    }
+
    println!();
+

+
    Ok(())
+
}
added radicle-remote-helper/src/push.rs
@@ -0,0 +1,355 @@
+
use std::collections::HashSet;
+
use std::ffi::OsStr;
+
use std::os::fd::{AsRawFd, FromRawFd};
+
use std::path::Path;
+
use std::str::FromStr;
+
use std::{io, process};
+

+
use radicle::storage::git::cob::object::ParseObjectId;
+
use thiserror::Error;
+

+
use radicle::crypto::PublicKey;
+
use radicle::node::{Handle, NodeId};
+
use radicle::storage::git::transport::local::Url;
+
use radicle::storage::WriteRepository;
+
use radicle::Profile;
+
use radicle::{git, rad};
+

+
use crate::read_line;
+

+
#[derive(Debug, Error)]
+
pub enum Error {
+
    /// Public key doesn't match the remote namespace we're pushing to.
+
    #[error("public key `{0}` does not match remote namespace")]
+
    KeyMismatch(PublicKey),
+
    /// No public key is given
+
    #[error("no public key given as a remote namespace, perhaps you are attempting to push to restricted refs")]
+
    NoKey,
+
    /// Invalid command received.
+
    #[error("invalid command `{0}`")]
+
    InvalidCommand(String),
+
    /// I/O error.
+
    #[error("i/o error: {0}")]
+
    Io(#[from] io::Error),
+
    /// A command exited with an error code.
+
    #[error("command '{0}' failed with status {1}")]
+
    CommandFailed(String, i32),
+
    /// Invalid reference name.
+
    #[error("invalid ref: {0}")]
+
    InvalidRef(#[from] radicle::git::fmt::Error),
+
    /// Git error.
+
    #[error("git: {0}")]
+
    Git(#[from] git::raw::Error),
+
    /// Storage error.
+
    #[error(transparent)]
+
    Storage(#[from] radicle::storage::Error),
+
    /// Profile error.
+
    #[error(transparent)]
+
    Profile(#[from] radicle::profile::Error),
+
    /// Identity error.
+
    #[error(transparent)]
+
    Identity(#[from] radicle::identity::IdentityError),
+
    /// Parse error for object IDs.
+
    #[error(transparent)]
+
    ParseObjectId(#[from] ParseObjectId),
+
}
+

+
enum Command {
+
    Push(git::Refspec<git::RefString, git::RefString>),
+
    Delete(git::RefString),
+
}
+

+
impl Command {
+
    /// Return the destination refname.
+
    fn dst(&self) -> &git::RefStr {
+
        match self {
+
            Self::Push(rs) => rs.dst.as_refstr(),
+
            Self::Delete(rs) => rs,
+
        }
+
    }
+
}
+

+
impl FromStr for Command {
+
    type Err = git::ext::ref_format::Error;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        let Some((src, dst)) = s.split_once(':') else {
+
            return Err(git::ext::ref_format::Error::Empty);
+
        };
+
        let dst = git::RefString::try_from(dst)?;
+

+
        if src.is_empty() {
+
            Ok(Self::Delete(dst))
+
        } else {
+
            let (src, force) = if let Some(stripped) = src.strip_prefix('+') {
+
                (stripped, true)
+
            } else {
+
                (src, false)
+
            };
+
            let src = git::RefString::try_from(src)?;
+

+
            Ok(Self::Push(git::Refspec { src, dst, force }))
+
        }
+
    }
+
}
+

+
/// Run a git push command.
+
pub fn run<R: WriteRepository>(
+
    mut specs: Vec<String>,
+
    working: &Path,
+
    url: Url,
+
    stored: R,
+
    profile: &Profile,
+
    stdin: &io::Stdin,
+
) -> Result<(), Error> {
+
    // Don't allow push if either of these conditions is true:
+
    //
+
    // 1. Our key is not in ssh-agent, which means we won't be able to sign the refs.
+
    // 2. Our key is not the one loaded in the profile, which means that the signed refs
+
    //    won't match the remote we're pushing to.
+
    // 3. The URL namespace is not set.
+
    let nid = url.namespace.ok_or(Error::NoKey).and_then(|ns| {
+
        (profile.public_key == ns)
+
            .then_some(ns)
+
            .ok_or(Error::KeyMismatch(profile.public_key))
+
    })?;
+
    let signer = profile.signer()?;
+
    let mut line = String::new();
+
    let mut ok = HashSet::new();
+

+
    // Read all the `push` lines.
+
    loop {
+
        let tokens = read_line(stdin, &mut line)?;
+
        match tokens.as_slice() {
+
            ["push", spec] => {
+
                specs.push(spec.to_string());
+
            }
+
            // An empty line means end of input.
+
            [] => break,
+
            // Once the first `push` command is received, we don't expect anything else.
+
            _ => return Err(Error::InvalidCommand(line.trim().to_owned())),
+
        }
+
    }
+

+
    // For each refspec, push a ref or delete a ref.
+
    for spec in specs {
+
        let Ok(cmd) = Command::from_str(&spec) else {
+
            return Err(Error::InvalidCommand(format!("push {spec}")));
+
        };
+
        let result = match &cmd {
+
            Command::Delete(dst) => {
+
                // Delete refs.
+
                let refname = nid.to_namespace().join(dst);
+
                stored
+
                    .raw()
+
                    .find_reference(&refname)
+
                    .and_then(|mut r| r.delete())
+
                    .map_err(Error::from)
+
            }
+
            Command::Push(git::Refspec { src, dst, force }) => {
+
                let working = git::raw::Repository::open(working)?;
+
                let stored = stored.raw();
+

+
                if let Some(oid) = dst.strip_prefix(git::refname!("refs/heads/patches")) {
+
                    let oid = git::Oid::from_str(oid)?;
+

+
                    patch_update(src, dst, *force, &oid, &nid, &working, stored)
+
                } else if dst == &*rad::PATCHES_REFNAME {
+
                    patch_open(src, &nid, &working, stored)
+
                } else {
+
                    push_ref(src, dst, *force, &nid, &working, stored)
+
                }
+
            }
+
        };
+

+
        match result {
+
            // Let Git tooling know that this ref has been pushed.
+
            Ok(()) => {
+
                println!("ok {}", cmd.dst());
+
                ok.insert(spec);
+
            }
+
            // Let Git tooling know that there was an error pushing the ref.
+
            Err(e) => println!("error {} {e}", cmd.dst()),
+
        }
+
    }
+

+
    // Sign refs and sync if at least one ref pushed successfully.
+
    if !ok.is_empty() {
+
        stored.sign_refs(&signer)?;
+
        stored.set_head()?;
+

+
        // Connect to local node and announce refs to the network.
+
        // If our node is not running, we simply skip this step, as the
+
        // refs will be announced eventually, when the node restarts.
+
        if radicle::Node::new(profile.socket()).is_running() {
+
            let rid = stored.id().to_string();
+
            let stderr = io::stderr().as_raw_fd();
+
            // Nb. allow this to fail. The push to local storage was still successful.
+
            execute("rad", ["sync", &rid, "--verbose"], unsafe {
+
                process::Stdio::from_raw_fd(stderr)
+
            })
+
            .ok();
+
        }
+
    }
+

+
    // Done.
+
    println!();
+

+
    Ok(())
+
}
+

+
/// Open a new patch.
+
fn patch_open(
+
    src: &git::RefStr,
+
    nid: &NodeId,
+
    working: &git::raw::Repository,
+
    stored: &git::raw::Repository,
+
) -> Result<(), Error> {
+
    let reference = working.find_reference(src.as_str())?;
+
    let commit = reference.peel_to_commit()?;
+
    let dst = &*git::refs::storage::staging::patch(nid, commit.id());
+

+
    // Before creating the patch, we must push the associated git objects to storage.
+
    // Unfortunately, we don't have an easy way to transfer the missing objects without
+
    // creating a temporary reference on the remote. The temporary reference is deleted
+
    // once the patch is open, or in case of error.
+
    //
+
    // In case the reference is not properly deleted, the next attempt to open a patch should
+
    // not fail, since the reference will already exist with the correct OID.
+
    push_ref(src, dst, false, nid, working, stored)?;
+

+
    let result = match execute(
+
        "rad",
+
        ["patch", "open", "--quiet", "--no-push"],
+
        process::Stdio::piped(),
+
    ) {
+
        Ok(patch) => {
+
            let patch = patch.trim();
+
            let patch = radicle::cob::ObjectId::from_str(patch)?;
+

+
            // Create long-lived patch head reference, now that we know the Patch ID.
+
            //
+
            //  refs/namespaces/<nid>/refs/heads/patches/<patch-id>
+
            //
+
            let refname = git::refs::storage::patch(nid, &patch);
+
            let _ = stored.reference(
+
                refname.as_str(),
+
                commit.id(),
+
                true,
+
                "Create reference for patch head",
+
            )?;
+

+
            let head = working.head()?;
+
            if head.peel_to_commit()?.id() == commit.id() {
+
                if let Ok(r) = head.resolve() {
+
                    let branch = git::raw::Branch::wrap(r);
+
                    let name: Option<git::RefString> =
+
                        branch.name()?.and_then(|b| b.try_into().ok());
+

+
                    working.reference(
+
                        &git::refs::workdir::patch_upstream(&patch),
+
                        commit.id(),
+
                        // The patch shouldn't exist yet, and so neither should
+
                        // this ref.
+
                        false,
+
                        "Create remote tracking branch for patch",
+
                    )?;
+

+
                    if let Some(name) = name {
+
                        if branch.upstream().is_err() {
+
                            git::set_upstream(
+
                                working,
+
                                &*radicle::rad::REMOTE_NAME,
+
                                name.as_str(),
+
                                git::refs::workdir::patch(&patch),
+
                            )?;
+
                        }
+
                    }
+
                }
+
            }
+
            Ok(())
+
        }
+
        Err(e) => Err(e),
+
    };
+
    // Delete short-lived patch head reference.
+
    stored.find_reference(dst).map(|mut r| r.delete()).ok();
+

+
    result
+
}
+

+
/// Update an existing patch.
+
fn patch_update(
+
    src: &git::RefStr,
+
    dst: &git::RefStr,
+
    force: bool,
+
    oid: &git::Oid,
+
    nid: &NodeId,
+
    working: &git::raw::Repository,
+
    stored: &git::raw::Repository,
+
) -> Result<(), Error> {
+
    push_ref(src, dst, force, nid, working, stored)?;
+

+
    // Patch ID.
+
    let oid = radicle::cob::ObjectId::from(oid);
+
    let stderr = io::stderr().as_raw_fd();
+

+
    // Nb. Git tooling checks that we aren't attempting a forced update without the `--force`
+
    // option of `git push`. There's no need for us to check here.
+

+
    execute(
+
        "rad",
+
        ["patch", "update", "--quiet", "--no-push", &oid.to_string()],
+
        unsafe { process::Stdio::from_raw_fd(stderr) },
+
    )
+
    .map(|_| ())
+
    .map_err(Error::from)
+
}
+

+
/// Push a single reference to storage.
+
fn push_ref(
+
    src: &git::RefStr,
+
    dst: &git::RefStr,
+
    force: bool,
+
    nid: &NodeId,
+
    working: &git::raw::Repository,
+
    stored: &git::raw::Repository,
+
) -> Result<(), Error> {
+
    let mut remote = working.remote_anonymous(&git::url::File::new(stored.path()).to_string())?;
+
    let dst = nid.to_namespace().join(dst);
+
    let refspec = git::Refspec { src, dst, force };
+

+
    // Nb. The *force* indicator (`+`) is processed by Git tooling before we even reach this code.
+
    // This happens during the `list for-push` phase.
+
    remote.push(&[refspec.to_string().as_str()], None)?;
+

+
    Ok(())
+
}
+

+
/// Execute a command as a child process, redirecting its stdout to the given `Stdio`.
+
fn execute<S: AsRef<std::ffi::OsStr>>(
+
    name: &str,
+
    args: impl IntoIterator<Item = S>,
+
    stdout: process::Stdio,
+
) -> Result<String, Error> {
+
    let mut cmd = process::Command::new(name);
+
    cmd.args(args)
+
        .stdout(stdout)
+
        .stderr(process::Stdio::inherit());
+

+
    let child = cmd.spawn()?;
+
    let output = child.wait_with_output()?;
+
    let status = output.status;
+

+
    if !status.success() {
+
        let cmd = format!(
+
            "{} {}",
+
            cmd.get_program().to_string_lossy(),
+
            cmd.get_args()
+
                .collect::<Vec<_>>()
+
                .join(OsStr::new(" "))
+
                .to_string_lossy()
+
        );
+
        return Err(Error::CommandFailed(cmd, status.code().unwrap_or(-1)));
+
    }
+
    Ok(String::from_utf8_lossy(&output.stdout).to_string())
+
}
modified radicle-term/src/editor.rs
@@ -1,5 +1,6 @@
use std::ffi::OsString;
use std::io::Write;
+
use std::os::fd::{AsRawFd, FromRawFd};
use std::path::PathBuf;
use std::process;
use std::{env, fs, io};
@@ -66,7 +67,11 @@ impl Editor {
                )
            );
        };
-
        process::Command::new(cmd).arg(&self.path).spawn()?.wait()?;
+
        process::Command::new(cmd)
+
            .stdout(unsafe { process::Stdio::from_raw_fd(io::stderr().as_raw_fd()) })
+
            .arg(&self.path)
+
            .spawn()?
+
            .wait()?;

        let text = fs::read_to_string(&self.path)?;
        if text.trim().is_empty() {
modified radicle/src/git.rs
@@ -20,7 +20,7 @@ pub use git2 as raw;
pub use git_ext::ref_format as fmt;
pub use git_ext::ref_format::{
    component, lit, name, qualified, refname, refspec,
-
    refspec::{PatternStr, PatternString},
+
    refspec::{PatternStr, PatternString, Refspec},
    Component, Namespaced, Qualified, RefStr, RefString,
};
pub use radicle_git_ext as ext;
@@ -136,6 +136,7 @@ pub enum ListRefsError {

pub mod refs {
    use super::*;
+
    use radicle_cob as cob;

    /// Try to get a qualified reference from a generic reference.
    pub fn qualified_from<'a>(r: &'a git2::Reference) -> Result<(Qualified<'a>, Oid), RefError> {
@@ -157,8 +158,6 @@ pub mod refs {
            refspec::{self, PatternString},
        };

-
        use radicle_cob as cob;
-

        use super::*;

        /// Where the project's identity document is stored.
@@ -220,10 +219,46 @@ pub mod refs {
                .join(Component::from(typename))
                .join(Component::from(object_id))
        }
+

+
        /// A patch reference.
+
        ///
+
        /// `refs/namespaces/<remote>/refs/heads/patches/<object_id>`
+
        ///
+
        pub fn patch<'a>(remote: &RemoteId, object_id: &cob::ObjectId) -> Namespaced<'a> {
+
            Qualified::from_components(
+
                component!("heads"),
+
                component!("patches"),
+
                Some(object_id.into()),
+
            )
+
            .with_namespace(remote.into())
+
        }
+

+
        /// Staging/temporary references.
+
        pub mod staging {
+
            use super::*;
+

+
            /// Where patches are pushed initially, when they don't have an object-id yet.
+
            /// This is a short-lived reference, which is deleted after the patch has been opened.
+
            /// The `<oid>` is the commit proposed in the patch.
+
            ///
+
            /// `refs/namespaces/<remote>/refs/patch/heads/<oid>`
+
            ///
+
            pub fn patch<'a>(remote: &RemoteId, oid: impl Into<Oid>) -> Namespaced<'a> {
+
                // SAFETY: OIDs are valid reference names and valid path component.
+
                #[allow(clippy::unwrap_used)]
+
                let oid = RefString::try_from(oid.into().to_string()).unwrap();
+
                #[allow(clippy::unwrap_used)]
+
                let oid = Component::from_refstr(oid).unwrap();
+

+
                Qualified::from_components(component!("patch"), component!("heads"), Some(oid))
+
                    .with_namespace(remote.into())
+
            }
+
        }
    }

    pub mod workdir {
        use super::*;
+
        use format::name::component;

        /// Create a [`RefString`] that corresponds to `refs/heads/<branch>`.
        pub fn branch(branch: &RefStr) -> RefString {
@@ -244,6 +279,30 @@ pub mod refs {
        pub fn tag(name: &RefStr) -> RefString {
            refname!("refs/tags").join(name)
        }
+

+
        /// A patch head.
+
        ///
+
        /// `refs/heads/patches/<patch-id>`
+
        ///
+
        pub fn patch<'a>(patch_id: &cob::ObjectId) -> Qualified<'a> {
+
            Qualified::from_components(
+
                component!("heads"),
+
                component!("patches"),
+
                Some(patch_id.into()),
+
            )
+
        }
+

+
        /// A patch head.
+
        ///
+
        /// `refs/remotes/rad/patches/<patch-id>`
+
        ///
+
        pub fn patch_upstream<'a>(patch_id: &cob::ObjectId) -> Qualified<'a> {
+
            Qualified::from_components(
+
                component!("remotes"),
+
                crate::rad::REMOTE_COMPONENT.clone(),
+
                [component!("patches"), patch_id.into()],
+
            )
+
        }
    }
}

@@ -365,12 +424,23 @@ pub fn write_tree<'r>(
    Ok(tree)
}

+
/// Configure a radicle repository.
+
///
+
/// * Sets `push.default = upstream`.
+
pub fn configure_repository(repo: &git2::Repository) -> Result<(), git2::Error> {
+
    let mut cfg = repo.config()?;
+
    cfg.set_str("push.default", "upstream")?;
+

+
    Ok(())
+
}
+

/// Configure a repository's radicle remote.
///
/// The entry for this remote will be:
/// ```text
/// [remote.<name>]
-
///   url = <url>
+
///   url = <fetch>
+
///   pushurl = <push>
///   fetch +refs/heads/*:refs/remotes/<name>/*
/// ```
pub fn configure_remote<'r>(
@@ -381,7 +451,36 @@ pub fn configure_remote<'r>(
) -> Result<git2::Remote<'r>, git2::Error> {
    let fetchspec = format!("+refs/heads/*:refs/remotes/{name}/*");
    let remote = repo.remote_with_fetch(name, fetch.to_string().as_str(), &fetchspec)?;
-
    repo.remote_set_pushurl(name, Some(push.to_string().as_str()))?;
+

+
    if push != fetch {
+
        repo.remote_set_pushurl(name, Some(push.to_string().as_str()))?;
+
    }
+
    Ok(remote)
+
}
+

+
/// Configure a repository's patches remote.
+
///
+
/// The entry for this remote will be:
+
/// ```text
+
/// [remote.<name>]
+
///   url = <url>
+
///   pushurl = <url>
+
///   push HEAD:refs/patches
+
/// ```
+
pub fn configure_patches_remote<'r>(
+
    repo: &'r git2::Repository,
+
    name: &str,
+
    refspec: &Refspec<RefString, RefString>,
+
    push: &Url,
+
) -> Result<git2::Remote<'r>, git2::Error> {
+
    let push = push.to_string();
+
    let remote = repo.remote(name, &push)?;
+

+
    // This fetchspec basically prevents fetching from this remote,
+
    // as it shouldn't be used for fetching.
+
    repo.remote_add_fetch(name, "refs/patches:refs/patches")?;
+
    repo.remote_set_pushurl(name, Some(&push))?;
+
    repo.remote_add_push(name, refspec.to_string().as_str())?;

    Ok(remote)
}
@@ -428,10 +527,14 @@ pub fn push<'a>(
/// ```
pub fn set_upstream(
    repo: &git2::Repository,
-
    remote: &str,
-
    branch: &str,
-
    merge: &str,
+
    remote: impl AsRef<str>,
+
    branch: impl AsRef<str>,
+
    merge: impl AsRef<str>,
) -> Result<(), git2::Error> {
+
    let remote = remote.as_ref();
+
    let branch = branch.as_ref();
+
    let merge = merge.as_ref();
+

    let mut config = repo.config()?;
    let branch_remote = format!("branch.{branch}.remote");
    let branch_merge = format!("branch.{branch}.merge");
@@ -500,8 +603,8 @@ pub mod url {

    impl File {
        /// Create a new file URL pointing to the given path.
-
        pub fn new(path: PathBuf) -> Self {
-
            Self { path }
+
        pub fn new(path: impl Into<PathBuf>) -> Self {
+
            Self { path: path.into() }
        }
    }

@@ -515,8 +618,7 @@ pub mod url {
/// Git environment variables.
pub mod env {
    /// Set of environment vars to reset git's configuration to default.
-
    pub const GIT_DEFAULT_CONFIG: [(&str, &str); 3] = [
-
        ("GIT_CONFIG", "/dev/null"),
+
    pub const GIT_DEFAULT_CONFIG: [(&str, &str); 2] = [
        ("GIT_CONFIG_GLOBAL", "/dev/null"),
        ("GIT_CONFIG_NOSYSTEM", "1"),
    ];
modified radicle/src/rad.rs
@@ -20,6 +20,12 @@ use crate::{identity, storage};

/// Name of the radicle storage remote.
pub static REMOTE_NAME: Lazy<git::RefString> = Lazy::new(|| git::refname!("rad"));
+
/// Name of the radicle storage remote.
+
pub static REMOTE_COMPONENT: Lazy<git::Component> = Lazy::new(|| git::fmt::name::component!("rad"));
+
/// Name of the radicle patches remote.
+
pub static PATCHES_REMOTE_NAME: Lazy<git::RefString> = Lazy::new(|| git::refname!("rad/patches"));
+
/// Refname used for pushing patches.
+
pub static PATCHES_REFNAME: Lazy<git::RefString> = Lazy::new(|| git::refname!("refs/patches"));

#[derive(Error, Debug)]
pub enum InitError {
@@ -72,7 +78,19 @@ pub fn init<G: Signer, S: WriteStorage>(
    let (project, _) = Repository::init(&doc, pk, storage, signer)?;
    let url = git::Url::from(project.id);

+
    git::configure_repository(repo)?;
    git::configure_remote(repo, &REMOTE_NAME, &url, &url.clone().with_namespace(*pk))?;
+
    git::configure_patches_remote(
+
        repo,
+
        &PATCHES_REMOTE_NAME,
+
        &git::Refspec {
+
            src: git::refname!("HEAD"),
+
            dst: PATCHES_REFNAME.clone(),
+
            force: false,
+
        },
+
        &url.with_namespace(*pk),
+
    )?;
+

    git::push(
        repo,
        &REMOTE_NAME,
@@ -249,7 +267,7 @@ pub fn checkout<P: AsRef<Path>, S: storage::ReadStorage>(
        repo.checkout_head(None)?;

        // Setup remote tracking for default branch.
-
        git::set_upstream(&repo, &REMOTE_NAME, project.default_branch(), branch_ref)?;
+
        git::set_upstream(&repo, &*REMOTE_NAME, project.default_branch(), branch_ref)?;
    }

    Ok(repo)
modified radicle/src/storage.rs
@@ -385,7 +385,7 @@ pub trait ReadRepository {
    /// Get the [`git2::Commit`] found using its `oid`.
    ///
    /// Returns `None` if the commit did not exist.
-
    fn commit(&self, oid: Oid) -> Result<git2::Commit, git_ext::Error>;
+
    fn commit(&self, oid: Oid) -> Result<git2::Commit, git::ext::Error>;

    /// Perform a revision walk of a commit history starting from the given head.
    fn revwalk(&self, head: Oid) -> Result<git2::Revwalk, git2::Error>;
@@ -400,6 +400,16 @@ pub trait ReadRepository {
    /// Get all references of the given remote.
    fn references_of(&self, remote: &RemoteId) -> Result<Refs, Error>;

+
    /// Get all references following a pattern.
+
    /// Skips references with names that are not parseable into [`Qualified`].
+
    ///
+
    /// This function always peels reference to the commit. For tags, this means the [`Oid`] of the
+
    /// commit pointed to by the tag is returned, and not the [`Oid`] of the tag itsself.
+
    fn references_glob(
+
        &self,
+
        pattern: &git::PatternStr,
+
    ) -> Result<Vec<(Qualified, Oid)>, git::ext::Error>;
+

    /// Get the given remote.
    fn remote(&self, remote: &RemoteId) -> Result<Remote<Verified>, refs::Error>;

modified radicle/src/storage/git.rs
@@ -457,6 +457,27 @@ impl ReadRepository for Repository {
        Ok(refs.into())
    }

+
    fn references_glob(
+
        &self,
+
        pattern: &self::PatternStr,
+
    ) -> Result<Vec<(Qualified, Oid)>, self::ext::Error> {
+
        let mut refs = Vec::new();
+

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

+
            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()));
+
            }
+
        }
+
        Ok(refs)
+
    }
+

    fn remotes(&self) -> Result<Remotes<Verified>, refs::Error> {
        let mut remotes = Vec::new();
        for remote in Repository::remotes(self)? {
modified radicle/src/test/storage.rs
@@ -201,6 +201,13 @@ impl ReadRepository for MockRepository {
        todo!()
    }

+
    fn references_glob(
+
        &self,
+
        _pattern: &git::PatternStr,
+
    ) -> Result<Vec<(fmt::Qualified, Oid)>, git::ext::Error> {
+
        todo!()
+
    }
+

    fn identity_doc(
        &self,
    ) -> Result<(Oid, crate::identity::Doc<crate::crypto::Unverified>), IdentityError> {