Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli(profile): align with current Radicle structure; safer atomic copy
✗ CI failure anon committed 4 months ago
commit de9628e1a2be2dcbe50bcb6c523bc47953f3e60c
parent 49b6cd29ee53c608a458b02978dd959fd92d3cce
2 failed (2 total) View logs
133 files changed +7005 -6989
modified crates/radicle-cli/Cargo.toml
@@ -3,7 +3,7 @@ name = "radicle-cli"
description = "Radicle CLI"
homepage.workspace = true
license.workspace = true
-
version = "0.16.0"
+
version = "0.17.0"
authors = ["cloudhead <cloudhead@radicle.xyz>"]
edition.workspace = true
build = "build.rs"
@@ -14,22 +14,21 @@ name = "rad"
path = "src/main.rs"

[dependencies]
-
anyhow = { workspace = true }
+
anyhow = "1"
chrono = { workspace = true, features = ["clock", "std"] }
+
clap = { version = "4.5.44", features = ["derive"] }
+
clap_complete = "4.5"
dunce = { workspace = true }
-
git-ref-format = { version = "0.3.0", features = ["macro"] }
human-panic.workspace = true
-
lexopt = { workspace = true }
+
itertools.workspace = true
localtime = { workspace = true }
log = { workspace = true, features = ["std"] }
nonempty = { workspace = true }
radicle = { workspace = true, features = ["logger", "schemars"] }
radicle-cob = { workspace = true }
radicle-crypto = { workspace = true }
-
# N.b. this is required to use macros, even though it's re-exported
-
# through radicle
-
radicle-git-ext = { workspace = true, features = ["serde"] }
-
radicle-surf = "0.22.0"
+
radicle-git-ref-format = { workspace = true, features = ["macro"] }
+
radicle-surf = { workspace = true }
radicle-term = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
modified crates/radicle-cli/examples/framework/home.md
@@ -8,23 +8,23 @@ $ touch file.bin
$ rad self --did
did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
$ pwd
-
[..]/home/bob/.radicle
+
[..]/bob/.radicle
$ mkdir src
$ cd src
$ pwd
-
[..]/home/bob/.radicle/src
+
[..]/bob/.radicle/src
```

``` ~alice
$ rad self --did
did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
$ pwd
-
[..]/home/alice/.radicle
+
[..]/alice/.radicle
```

``` ~bob
$ pwd
-
[..]/home/bob/.radicle/src
+
[..]/bob/.radicle/src
```

```
added crates/radicle-cli/examples/git/git-is-bare-repository.md
@@ -0,0 +1,4 @@
+
```
+
$ git rev-parse --is-bare-repository
+
true
+
```

\ No newline at end of file
modified crates/radicle-cli/examples/git/git-push-canonical-annotated-tags.md
@@ -120,7 +120,7 @@ From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji
 * [new tag]         v1.0-hotfix -> v1.0-hotfix
```

-
Since Alice crated an annotated tag, resolving the reference on Bob's end yields an object of type 'tag'.
+
Since Alice created an annotated tag, resolving the reference on Bob's end yields an object of type 'tag'.

``` ~bob
$ git cat-file -t v1.0-hotfix
modified crates/radicle-cli/examples/git/git-push-canonical-lightweight-tags.md
@@ -118,7 +118,7 @@ From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji
 * [new tag]         v1.0-hotfix -> v1.0-hotfix
```

-
Since Alice crated a lightweight tag, resolving the reference on Bob's end yields an object of type 'commit'.
+
Since Alice created a lightweight tag, resolving the reference on Bob's end yields an object of type 'commit'.

``` ~bob
$ git cat-file -t v1.0-hotfix
modified crates/radicle-cli/examples/git/git-push-converge.md
@@ -35,6 +35,7 @@ pushing to their `rad` remote -- but they won't sync to the network just yet:
$ git commit -m "Alice's commit" --allow-empty -q
$ git push rad -o no-sync
$ git ls-remote rad
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	HEAD
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	refs/heads/master
```

@@ -43,6 +44,7 @@ $ git add README
$ git commit -m "Bob's commit" -q
$ git push rad -o no-sync
$ git ls-remote rad
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	HEAD
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	refs/heads/master
```

@@ -51,6 +53,7 @@ $ git add README
$ git commit -m "Eve's commit" -q
$ git push rad -o no-sync
$ git ls-remote rad
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	HEAD
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	refs/heads/master
```

added crates/radicle-cli/examples/git/git-push-force-with-lease.md
@@ -0,0 +1,82 @@
+
Here we show that the Radicle remote helper supports the use of
+
`--force-with-lease`[^1].
+

+
First we will set things up by pushing an initial commit:
+

+
```
+
$ git commit -m "New changes" --allow-empty -q
+
$ git push rad master
+
```
+

+
Now, we will create a new commit, and use the `--force-with-lease`, which should
+
succeed. In fact, since the current setup ensures that you can only push to your
+
namespace, `--force-with-lease` should always work! No other person should be
+
able to push to your namespace, and so the commit should never have changed from
+
the last time you pushed.
+

+
``` (stderr)
+
$ git commit --amend -m "Neue Änderungen" --allow-empty -q
+
$ git push rad master --force-with-lease
+
✓ Canonical reference refs/heads/master updated to target commit 9170c8795d3a78f0381a0ffafb20ea69fb0f5b6b
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 + fb25886...9170c87 master -> master (forced update)
+
```
+

+
As per the documentation, you can also pass the reference name, as the expected
+
value, to `--force-push-lease`:
+

+
``` (stderr)
+
$ git commit --amend -m "Noch mehr Änderungen" --allow-empty -q
+
$ git push rad master --force-with-lease=master
+
✓ Canonical reference refs/heads/master updated to target commit 1e4213811eb4ce67360e4a0222cab81ad11a7ffe
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 + 9170c87...1e42138 master -> master (forced update)
+
```
+

+
As well as the named reference, and its expected value:
+

+
``` (stderr)
+
$ git commit --amend -m "Even more changes" --allow-empty -q
+
$ git push rad master --force-with-lease=master:1e4213811eb4ce67360e4a0222cab81ad11a7ffe
+
✓ Canonical reference refs/heads/master updated to target commit c4b74ef30953598852a82e0cd22b2ebb0d8d9e18
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 + 1e42138...c4b74ef master -> master (forced update)
+
```
+

+
If we try use the same expected value as the last push, it should fail since the
+
reference was updated in the last commit:
+

+
```
+
$ git commit --amend -m "And even more" --allow-empty -q
+
```
+

+
``` (stderr) (fail)
+
$ git push rad master --force-with-lease=master:1e4213811eb4ce67360e4a0222cab81ad11a7ffe
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 ! [rejected]        master -> master (stale info)
+
error: failed to push some refs to 'rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi'
+
```
+

+
And if we do not supply the commit, it should also fail, since this implies that
+
we expect the reference to not exist:
+

+
```
+
$ git commit --amend -m "And even more" --allow-empty -q
+
```
+

+
``` (stderr) (fail)
+
$ git push rad master --force-with-lease=master:
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 ! [rejected]        master -> master (stale info)
+
error: failed to push some refs to 'rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi'
+
```
+

+
So, let's create a new branch:
+

+
``` (stderr)
+
$ git push rad master:dev --force-with-lease=dev:
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new branch]      master -> dev
+
```
+

+
[^1]: https://git-scm.com/docs/git-push#Documentation/git-push.txt---force-with-lease
modified crates/radicle-cli/examples/git/git-push.md
@@ -54,6 +54,7 @@ List the canonical refs:

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

added crates/radicle-cli/examples/jj-config.md
@@ -0,0 +1,19 @@
+
Let's make sure that the config is exactly what we expect.
+

+
```
+
$ jj config list
+
ui.editor = "true"
+
user.name = "Test User"
+
user.email = "test.user@example.com"
+
debug.commit-timestamp = "2001-02-03T04:05:06+07:00"
+
debug.randomness-seed = 0
+
debug.operation-timestamp = "2001-02-03T04:05:06+07:00"
+
operation.hostname = "host.example.com"
+
operation.username = "test-username"
+
```
+

+
We enable writing Change ID headers to our commits.
+

+
```
+
$ jj config set --user git.write-change-id-header true
+
```

\ No newline at end of file
added crates/radicle-cli/examples/jj-init-bare.md
@@ -0,0 +1,19 @@
+
We initialize Jujutusu for our repository for use with a bare Git repo.
+

+
```(stderr)
+
$ jj git init --git-repo heartwood heartwood.jj
+
Done importing changes from the underlying Git repo.
+
Working copy  (@) now at: lvxkkpmk 9ec513df (empty) (no description set)
+
Parent commit (@-)      : xpnzuzwn f2de534b master | Second commit
+
Added 1 files, modified 0 files, removed 0 files
+
Initialized repo in "heartwood.jj"
+
```
+

+
```
+
$ cd heartwood.jj
+
```
+

+
```
+
$ rad .
+
rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
```

\ No newline at end of file
added crates/radicle-cli/examples/jj-init-colocate.md
@@ -0,0 +1,10 @@
+
We initialize Jujutusu for our repository by colocating with Git.
+

+
```(stderr)
+
$ jj git init --colocate
+
Done importing changes from the underlying Git repo.
+
Hint: The following remote bookmarks aren't associated with the existing local bookmarks:
+
  master@rad
+
Hint: Run `jj bookmark track master@rad` to keep local bookmarks updated on future pulls.
+
Initialized repo in "."
+
```

\ No newline at end of file
modified crates/radicle-cli/examples/rad-auth-errors.md
@@ -1,17 +1,23 @@
Note that aliases must not be longer than 32 bytes, or you will get an error.
There are other rules as well:

-
``` (fail)
+
``` (stderr) (fail)
$ rad auth --alias "5fad63fe6b339fa92c588d926121bea6240773a7"
-
✗ Error: rad auth: alias cannot be greater than 32 bytes
+
error: invalid value '5fad63fe6b339fa92c588d926121bea6240773a7' for '--alias <ALIAS>': alias cannot be greater than 32 bytes
+

+
For more information, try '--help'.
```

-
``` (fail)
+
``` (stderr) (fail)
$ rad auth --alias "john doe"
-
✗ Error: rad auth: alias cannot contain whitespace or control characters
+
error: invalid value 'john doe' for '--alias <ALIAS>': alias cannot contain whitespace or control characters
+

+
For more information, try '--help'.
```

-
``` (fail)
+
``` (stderr) (fail)
$ rad auth --alias ""
-
✗ Error: rad auth: alias cannot be empty
+
error: invalid value '' for '--alias <ALIAS>': alias cannot be empty
+

+
For more information, try '--help'.
```
added crates/radicle-cli/examples/rad-clone-bare.md
@@ -0,0 +1,81 @@
+
To create a local bare copy of a repository on the radicle network, we use the
+
`clone` command, followed by the identifier or *RID* of the repository:
+

+
```
+
$ rad clone rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --scope followed --bare
+
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'followed'
+
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found [..] potential seed(s).
+
✓ Target met: [..] seed(s)
+
✓ Creating checkout in ./heartwood..
+
✓ Remote alice@z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi added
+
✓ Remote-tracking branch alice@z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master created for z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
✓ Repository successfully cloned under [..]/heartwood/
+
╭────────────────────────────────────╮
+
│ heartwood                          │
+
│ Radicle Heartwood Protocol & Stack │
+
│ 0 issues · 0 patches               │
+
╰────────────────────────────────────╯
+
Run `cd ./heartwood` to go to the repository directory.
+
```
+

+
We can now have a look at the new directory that was created from the cloned
+
repository:
+

+
```
+
$ cd heartwood
+
$ ls
+
FETCH_HEAD
+
HEAD
+
config
+
description
+
hooks
+
info
+
objects
+
refs
+
```
+

+
As expected, some `git` commands fail:
+
``` (stderr) (fail)
+
$ git status
+
fatal: this operation must be run in a work tree
+
```
+

+
Let's check that the remote tracking branch was setup correctly:
+

+
```
+
$ git branch --remotes
+
  alice@z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master
+
  rad/master
+
```
+

+
The first branch is ours, and the second points to the repository delegate.
+
We can also take a look at the remotes:
+

+
```
+
$ git remote -v
+
alice@z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi	rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (fetch)
+
alice@z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi	rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (push)
+
rad	rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji (fetch)
+
rad	rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk (push)
+
```
+

+
Let's check the last commit!
+

+
```
+
$ git log -n 1
+
commit f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
+
Author: anonymous <anonymous@radicle.xyz>
+
Date:   Mon Jan 1 14:39:16 2018 +0000
+

+
    Second commit
+
```
+

+
Cloned repositories show up in `rad ls`:
+
```
+
$ rad ls --seeded
+
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ Name        RID                                 Visibility   Head      Description                        │
+
├───────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ heartwood   rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji   public       f2de534   Radicle Heartwood Protocol & Stack │
+
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────╯
+
```
modified crates/radicle-cli/examples/rad-cob-update.md
@@ -46,6 +46,7 @@ $ rad patch show 89f7afb
│ Patch     89f7afb1511b976482b21f6b2f39aef7f4fb88a2                  │
│ Author    alice (you)                                               │
│ Head      8945f6189adf027892c85ac57f7e9341049c2537                  │
+
│ Base      [..                                                     ] │
│ Branches  changes                                                   │
│ Commits   ahead 2, behind 0                                         │
│ Status    open                                                      │
@@ -69,6 +70,7 @@ $ rad patch show 89f7afb
│ Patch     89f7afb1511b976482b21f6b2f39aef7f4fb88a2                  │
│ Author    alice (you)                                               │
│ Head      8945f6189adf027892c85ac57f7e9341049c2537                  │
+
│ Base      [..                                                     ] │
│ Branches  changes                                                   │
│ Commits   ahead 2, behind 0                                         │
│ Status    open                                                      │
@@ -161,6 +163,7 @@ $ rad patch show 89f7afb
│ Patch     89f7afb1511b976482b21f6b2f39aef7f4fb88a2                  │
│ Author    alice (you)                                               │
│ Head      f1339dd109e538c6b3a7fed3e72403e1b4db08c9                  │
+
│ Base      [..                                                     ] │
│ Branches  changes                                                   │
│ Commits   ahead 3, behind 0                                         │
│ Status    open                                                      │
@@ -175,4 +178,4 @@ $ rad patch show 89f7afb
│ ↑ updated to 5d78dd5376453e25df5988ec86951c99cb73742c (8945f61) now │
│ ↑ updated to 2da9c025a1d14d93c4f2cec60a7878afbc5e2a3c (f1339dd) now │
╰─────────────────────────────────────────────────────────────────────╯
-
```

\ No newline at end of file
+
```
modified crates/radicle-cli/examples/rad-diff.md
@@ -1,3 +1,8 @@
+
``` (stderr)
+
$ rad diff
+
! Deprecated: The command/option `rad diff` is deprecated and will be removed. Please use `git diff` instead.
+
```
+

Exploring `rad diff`.

``` ./main.c
@@ -27,76 +32,74 @@ $ git commit -m "Make changes"

```
$ rad diff HEAD^ HEAD
-
╭────────────────────────────────────────────╮
-
│ README -> README.md ❲moved❳                │
-
╰────────────────────────────────────────────╯
-

-
╭────────────────────────────────────────────╮
-
│ main.c +6 ❲created❳                        │
-
├────────────────────────────────────────────┤
-
│ @@ -0,0 +1,6 @@                            │
-
│      1     + #include <stdio.h>            │
-
│      2     +                               │
-
│      3     + int main(void) {              │
-
│      4     +     printf("Hello World!/n"); │
-
│      5     +     return 0;                 │
-
│      6     + }                             │
-
╰────────────────────────────────────────────╯
-

+
diff --git a/README b/README.md
+
similarity index 100%
+
rename from README
+
rename to README.md
+
diff --git a/main.c b/main.c
+
new file mode 100644
+
index 0000000..aae4e0e
+
--- /dev/null
+
+++ b/main.c
+
@@ -0,0 +1,6 @@
+
+#include <stdio.h>
+
+
+
+int main(void) {
+
+    printf("Hello World!/n");
+
+    return 0;
+
+}
```

```
$ sed -i 's/Hello World/Hello Radicle/' main.c
$ rad diff
-
╭──────────────────────────────────────────────╮
-
│ main.c -1 +1                                 │
-
├──────────────────────────────────────────────┤
-
│ @@ -1,6 +1,6 @@                              │
-
│ 1    1       #include <stdio.h>              │
-
│ 2    2                                       │
-
│ 3    3       int main(void) {                │
-
│ 4          -     printf("Hello World!/n");   │
-
│      4     +     printf("Hello Radicle!/n"); │
-
│ 5    5           return 0;                   │
-
│ 6    6       }                               │
-
╰──────────────────────────────────────────────╯
-

+
diff --git a/main.c b/main.c
+
index aae4e0e..a3ed869 100644
+
--- a/main.c
+
+++ b/main.c
+
@@ -1,6 +1,6 @@
+
 #include <stdio.h>
+
 
+
 int main(void) {
+
-    printf("Hello World!/n");
+
+    printf("Hello Radicle!/n");
+
     return 0;
+
 }
```

```
$ git add main.c
$ rad diff
$ rad diff --staged
-
╭──────────────────────────────────────────────╮
-
│ main.c -1 +1                                 │
-
├──────────────────────────────────────────────┤
-
│ @@ -1,6 +1,6 @@                              │
-
│ 1    1       #include <stdio.h>              │
-
│ 2    2                                       │
-
│ 3    3       int main(void) {                │
-
│ 4          -     printf("Hello World!/n");   │
-
│      4     +     printf("Hello Radicle!/n"); │
-
│ 5    5           return 0;                   │
-
│ 6    6       }                               │
-
╰──────────────────────────────────────────────╯
-

+
diff --git a/main.c b/main.c
+
index aae4e0e..a3ed869 100644
+
--- a/main.c
+
+++ b/main.c
+
@@ -1,6 +1,6 @@
+
 #include <stdio.h>
+
 
+
 int main(void) {
+
-    printf("Hello World!/n");
+
+    printf("Hello Radicle!/n");
+
     return 0;
+
 }
```

```
$ git rm -f -q main.c
$ rad diff --staged
-
╭────────────────────────────────────────────╮
-
│ main.c -6 ❲deleted❳                        │
-
├────────────────────────────────────────────┤
-
│ @@ -1,6 +0,0 @@                            │
-
│ 1          - #include <stdio.h>            │
-
│ 2          -                               │
-
│ 3          - int main(void) {              │
-
│ 4          -     printf("Hello World!/n"); │
-
│ 5          -     return 0;                 │
-
│ 6          - }                             │
-
╰────────────────────────────────────────────╯
-

+
diff --git a/main.c b/main.c
+
deleted file mode 100644
+
index aae4e0e..0000000
+
--- a/main.c
+
+++ /dev/null
+
@@ -1,6 +0,0 @@
+
-#include <stdio.h>
+
-
+
-int main(void) {
+
-    printf("Hello World!/n");
+
-    return 0;
+
-}
```

For now, copies are not detected.
@@ -107,13 +110,13 @@ $ mkdir docs
$ cp README.md docs/README.md
$ git add docs
$ rad diff --staged
-
╭─────────────────────────────╮
-
│ docs/README.md +1 ❲created❳ │
-
├─────────────────────────────┤
-
│ @@ -0,0 +1,1 @@             │
-
│      1     + Hello World!   │
-
╰─────────────────────────────╯
-

+
diff --git a/docs/README.md b/docs/README.md
+
new file mode 100644
+
index 0000000..980a0d5
+
--- /dev/null
+
+++ b/docs/README.md
+
@@ -0,0 +1 @@
+
+Hello World!
$ git reset
$ git checkout .
```
@@ -124,10 +127,9 @@ Empty file.
$ touch EMPTY
$ git add EMPTY
$ rad diff --staged
-
╭─────────────────╮
-
│ EMPTY ❲created❳ │
-
╰─────────────────╯
-

+
diff --git a/EMPTY b/EMPTY
+
new file mode 100644
+
index 0000000..e69de29
$ git reset
$ git checkout .
```
@@ -137,10 +139,9 @@ File mode change.
```
$ chmod +x README.md
$ rad diff
-
╭───────────────────────────────────────────╮
-
│ README.md 100644 -> 100755 ❲mode changed❳ │
-
╰───────────────────────────────────────────╯
-

+
diff --git a/README.md b/README.md
+
old mode 100644
+
new mode 100755
$ git reset -q
$ git checkout .
```
@@ -152,8 +153,8 @@ $ touch file.bin
$ truncate -s 8 file.bin
$ git add file.bin
$ rad diff --staged
-
╭─────────────────────────────╮
-
│ file.bin ❲binary❳ ❲created❳ │
-
╰─────────────────────────────╯
-

+
diff --git a/file.bin b/file.bin
+
new file mode 100644
+
index 0000000..1b1cb4d
+
Binary files /dev/null and b/file.bin differ
```
added crates/radicle-cli/examples/rad-help.md
@@ -0,0 +1,52 @@
+
```
+
$ rad --help
+
Radicle is a sovereign code forge built on Git.
+

+
See `rad <COMMAND> --help` to learn about a specific command.
+

+
Do you have feedback?
+
 - Chat <radicle.zulipchat.com>
+
 - Mail <feedback@radicle.xyz>
+
   (Messages are automatically posted to the public #feedback channel on Zulip.)
+

+
Usage: rad <COMMAND>
+

+
Commands:
+
  auth      Manage identities and profiles
+
  block     Block repositories or nodes from being seeded or followed
+
  checkout  Checkout a repository into the local directory
+
  clean     Remove all remotes from a repository
+
  clone     Clone a Radicle repository
+
  config    Manage your local Radicle configuration
+
  debug     Write out information to help debug your Radicle node remotely
+
  follow    Manage node follow policies
+
  fork      Create a fork of a repository
+
  id        Manage repository identities
+
  inbox     Manage your Radicle notifications
+
  init      Initialize a Radicle repository
+
  inspect   Inspect a Radicle repository
+
  issue     Manage issues
+
  ls        List repositories
+
  node      Control and query the Radicle Node
+
  patch     Manage patches
+
  path      Display the Radicle home path
+
  publish   Publish a repository to the network
+
  remote    Manage a repository's remotes
+
  seed      Manage repository seeding policies
+
  self      Show information about your identity and device
+
  stats     Displays aggregated repository and node metrics
+
  sync      Sync repositories to the network
+
  unblock   Unblock repositories or nodes to allow them to be seeded or followed
+
  unfollow  Unfollow a peer
+
  unseed    Remove repository seeding policies
+
  watch     Wait for some state to be updated
+
  version   Print the version information of the CLI
+
  help      Print this message or the help of the given subcommand(s)
+

+
Options:
+
  -h, --help
+
          Print help (see a summary with '-h')
+

+
  -V, --version
+
          Print version
+
```
modified crates/radicle-cli/examples/rad-id-threshold.md
@@ -176,7 +176,7 @@ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 2 potential s
✓ Creating checkout in ./heartwood..
✓ Remote alice@z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi added
✓ Remote-tracking branch alice@z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master created for z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
✓ Repository successfully cloned under [..]/bob/heartwood/
+
✓ Repository successfully cloned under [..]/bob/work/heartwood/
╭────────────────────────────────────╮
│ heartwood                          │
│ Radicle Heartwood Protocol & Stack │
modified crates/radicle-cli/examples/rad-id.md
@@ -6,7 +6,7 @@ project.

For cases where `threshold > 1`, it is necessary to gather a quorum of
signatures to update the Radicle identity. To do this, we use the `rad id`
-
command. For now, since we are the only delegate, and `treshold` is `1`, we
+
command. For now, since we are the only delegate, and `threshold` is `1`, we
can update the identity ourselves.

Let's add Bob as a delegate using their DID,
added crates/radicle-cli/examples/rad-init-existing-bare.md
@@ -0,0 +1,48 @@
+
Let's clone a regular repository via plain Git:
+
```
+
$ git clone --bare $URL heartwood
+
$ cd heartwood
+
$ git rev-parse HEAD
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
+
```
+

+
We can see it's not a Radicle working copy:
+
``` (fail)
+
$ rad .
+
✗ Error: Current directory is not a Radicle repository
+
```
+

+
Let's pick an existing repository:
+
```
+
$ rad inspect rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
```
+

+
And initialize this working copy as that existing repository:
+
```
+
$ rad init --setup-signing --existing rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+

+
Configuring radicle signing key SHA256:UIedaL6Cxm6OUErh9GQUzzglSk7VpQlVTI1TAFB/HWA...
+

+
✓ Signing configured in [..]/heartwood/config
+
! Not writing .gitsigners file.
+
✓ Initialized existing repository rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji in [..]/heartwood/..
+
```
+

+
The warning about not writing `.gitsigners` is expected, as this requires a
+
working directory, which a bare repository does not have.
+

+
We can confirm that the working copy is initialized:
+
```
+
$ rad .
+
rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
$ git remote show rad
+
* remote rad
+
  Fetch URL: rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
  Push  URL: rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
  HEAD branch: master
+
  Remote branch:
+
    master new (next fetch will store in remotes/rad)
+
  Local ref configured for 'git push':
+
    master pushes to master (up to date)
+
```
modified crates/radicle-cli/examples/rad-init-existing.md
@@ -20,7 +20,12 @@ rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji

And initialize this working copy as that existing repository:
```
-
$ rad init --existing rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
$ rad init --setup-signing --existing rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+

+
Configuring radicle signing key SHA256:UIedaL6Cxm6OUErh9GQUzzglSk7VpQlVTI1TAFB/HWA...
+

+
✓ Signing configured in [..]/heartwood/.git/config
+
✓ Created .gitsigners file
✓ Initialized existing repository rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji in [..]/heartwood/..
```

@@ -32,7 +37,7 @@ $ git remote show rad
* remote rad
  Fetch URL: rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji
  Push  URL: rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
  HEAD branch: (unknown)
+
  HEAD branch: master
  Remote branch:
    master new (next fetch will store in remotes/rad)
  Local ref configured for 'git push':
modified crates/radicle-cli/examples/rad-init-no-seed.md
@@ -1,4 +1,4 @@
-
If we initialize a public repository without seeding it, it won't be advertized:
+
If we initialize a public repository without seeding it, it won't be advertised:
```
$ rad init --name heartwood --description "radicle heartwood protocol & stack" --no-confirm --public --no-seed

@@ -17,7 +17,7 @@ To push changes, run `git push`.
$ rad node inventory
```

-
If we then seed it, it becomes advertized in our inventory:
+
If we then seed it, it becomes advertised in our inventory:
```
$ rad seed rad:zhbMU4DUXrzB8xT6qAJh6yZ7bFMK
✓ Inventory updated with rad:zhbMU4DUXrzB8xT6qAJh6yZ7bFMK
modified crates/radicle-cli/examples/rad-init-private-clone-seed.md
@@ -1,6 +1,6 @@
Given a private repo `rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu` belonging to Alice,
Alice allows Bob to fetch it, and Bob, without the updated identity document
-
is able to fetch it by specifiying Alice as a seed.
+
is able to fetch it by specifying Alice as a seed.

``` ~alice
$ rad id update --title "Allow Bob" --description "" --allow did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk -q
modified crates/radicle-cli/examples/rad-init-private.md
@@ -17,7 +17,7 @@ To make it public, run `rad publish`.
To push changes, run `git push`.
```

-
The repository does not show up in our inventory, since it is not advertized,
+
The repository does not show up in our inventory, since it is not advertised,
despite being seeded:
```
$ rad node inventory
added crates/radicle-cli/examples/rad-issue-list.md
@@ -0,0 +1,62 @@
+
Let's say we have a project with an issue created already. We can list all open issues.
+

+
```
+
$ rad issue list
+
╭────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●   ID        Title                         Author           Labels             Assignees   Opened │
+
├────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●   d87dcfe   flux capacitor underpowered   alice    (you)   good-first-issue               now    │
+
╰────────────────────────────────────────────────────────────────────────────────────────────────────╯
+
```
+

+
We can now assign ourselves to the open issue.
+

+
```
+
$ rad issue assign d87dcfe --add did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --no-announce
+
```
+

+
It will now also show up in the list of issues assigned to us.
+

+
```
+
$ rad issue list --assigned me
+
╭────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●   ID        Title                         Author           Labels             Assignees   Opened │
+
├────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●   d87dcfe   flux capacitor underpowered   alice    (you)   good-first-issue   alice       now    │
+
╰────────────────────────────────────────────────────────────────────────────────────────────────────╯
+
```
+

+
If we now fix this issue, we can close it.
+

+
```
+
$ rad issue state --solved d87dcfe --no-announce
+
✓ Issue d87dcfe is now solved
+
```
+

+
It will not show up in the list of open issues anymore.
+

+
```
+
$ rad issue list
+
```
+

+
Instead, it will now show up in the list of solved issues.
+

+
```
+
$ rad issue list --solved
+
╭────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●   ID        Title                         Author           Labels             Assignees   Opened │
+
├────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●   d87dcfe   flux capacitor underpowered   alice    (you)   good-first-issue   alice       now    │
+
╰────────────────────────────────────────────────────────────────────────────────────────────────────╯
+
```
+

+
Note: You can achieve the same by omitting the `list` subcommand, since that's the fallback when no subcommand is specified.
+

+
```
+
$ rad issue --solved
+
╭────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●   ID        Title                         Author           Labels             Assignees   Opened │
+
├────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●   d87dcfe   flux capacitor underpowered   alice    (you)   good-first-issue   alice       now    │
+
╰────────────────────────────────────────────────────────────────────────────────────────────────────╯
+
```
added crates/radicle-cli/examples/rad-key-mismatch.md
@@ -0,0 +1,6 @@
+
This test assumes that one of the two keys in `$RAD_HOME/keys` was swapped so that `$RAD_HOME/keys/radicle{,.pub}` do not match anymore.
+

+
``` (fail)
+
$ rad issue open --title "flux capacitor underpowered" --description "Flux capacitor power requirements exceed current supply" --no-announce
+
✗ Error: secret key '[..]/.radicle/keys/radicle' and public key '[..]/.radicle/keys/radicle.pub' do not match
+
```

\ No newline at end of file
modified crates/radicle-cli/examples/rad-merge-via-push.md
@@ -83,35 +83,37 @@ $ rad patch --merged
│ ✓  [ ... ]  First change   alice   (you)  -        20aa5dd  +0  -0  now     │
╰─────────────────────────────────────────────────────────────────────────────╯
$ rad patch show 696ec5508494692899337afe6713fe1796d0315c
-
╭────────────────────────────────────────────────────────────────╮
-
│ Title     First change                                         │
-
│ Patch     696ec5508494692899337afe6713fe1796d0315c             │
-
│ Author    alice (you)                                          │
-
│ Head      20aa5dde6210796c3a2f04079b42316a31d02689             │
-
│ Branches  feature/1                                            │
-
│ Commits   ahead 0, behind 2                                    │
-
│ Status    merged                                               │
-
├────────────────────────────────────────────────────────────────┤
-
│ 20aa5dd First change                                           │
-
├────────────────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (20aa5dd) now                          │
-
│   └─ ✓ merged by alice (you) at revision 696ec55 (20aa5dd) now │
-
╰────────────────────────────────────────────────────────────────╯
+
╭────────────────────────────────────────────────────╮
+
│ Title     First change                             │
+
│ Patch     696ec5508494692899337afe6713fe1796d0315c │
+
│ Author    alice (you)                              │
+
│ Head      20aa5dde6210796c3a2f04079b42316a31d02689 │
+
│ Base      [..                                    ] │
+
│ Branches  feature/1                                │
+
│ Commits   ahead 0, behind 2                        │
+
│ Status    merged                                   │
+
├────────────────────────────────────────────────────┤
+
│ 20aa5dd First change                               │
+
├────────────────────────────────────────────────────┤
+
│ ● Revision 696ec55 @ 20aa5dd by alice (you) now    │
+
│   └─ ✓ merged                by alice (you)        │
+
╰────────────────────────────────────────────────────╯
$ rad patch show 356f73863a8920455ff6e77cd9c805d68910551b
-
╭────────────────────────────────────────────────────────────────╮
-
│ Title     Second change                                        │
-
│ Patch     356f73863a8920455ff6e77cd9c805d68910551b             │
-
│ Author    alice (you)                                          │
-
│ Head      daf349ff76bedf48c5f292290b682ee7be0683cf             │
-
│ Branches  feature/2                                            │
-
│ Commits   ahead 0, behind 2                                    │
-
│ Status    merged                                               │
-
├────────────────────────────────────────────────────────────────┤
-
│ daf349f Second change                                          │
-
├────────────────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (daf349f) now                          │
-
│   └─ ✓ merged by alice (you) at revision 356f738 (daf349f) now │
-
╰────────────────────────────────────────────────────────────────╯
+
╭────────────────────────────────────────────────────╮
+
│ Title     Second change                            │
+
│ Patch     356f73863a8920455ff6e77cd9c805d68910551b │
+
│ Author    alice (you)                              │
+
│ Head      daf349ff76bedf48c5f292290b682ee7be0683cf │
+
│ Base      [..                                    ] │
+
│ Branches  feature/2                                │
+
│ Commits   ahead 0, behind 2                        │
+
│ Status    merged                                   │
+
├────────────────────────────────────────────────────┤
+
│ daf349f Second change                              │
+
├────────────────────────────────────────────────────┤
+
│ ● Revision 356f738 @ daf349f by alice (you) now    │
+
│   └─ ✓ merged                by alice (you)        │
+
╰────────────────────────────────────────────────────╯
```

We can verify that the remote tracking branches were also deleted:
modified crates/radicle-cli/examples/rad-node.md
@@ -15,7 +15,7 @@ node status` command (or just `rad node` for short):

```
$ rad node status
-
✓ Node is running and listening on [..].
+
✓ Node is running with Node ID z6MknSL[..]Vi and listening for inbound connections on [..].
```

```
modified crates/radicle-cli/examples/rad-patch-ahead-behind.md
@@ -56,20 +56,20 @@ When showing the patch, we see that it is `ahead 1, behind 1`, since master has
diverged by one commit:
```
$ rad patch show -v -p 217f050
-
╭────────────────────────────────────────────────────╮
-
│ Title     Add Alan                                 │
-
│ Patch     217f050f8891def8fb863f7c0b4f85c89f97299d │
-
│ Author    alice (you)                              │
-
│ Head      5c88a79d75f5c2b4cc51ee6f163d2db91ee198d7 │
-
│ Base      f64fb2c8fe28f7c458c72ec8d700373924794943 │
-
│ Branches  feature/1                                │
-
│ Commits   ahead 1, behind 1                        │
-
│ Status    open                                     │
-
├────────────────────────────────────────────────────┤
-
│ 5c88a79 Add Alan                                   │
-
├────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (5c88a79) now              │
-
╰────────────────────────────────────────────────────╯
+
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ Title     Add Alan                                                                                                        │
+
│ Patch     217f050f8891def8fb863f7c0b4f85c89f97299d                                                                        │
+
│ Author    alice (you)                                                                                                     │
+
│ Head      5c88a79d75f5c2b4cc51ee6f163d2db91ee198d7                                                                        │
+
│ Base      f64fb2c8fe28f7c458c72ec8d700373924794943                                                                        │
+
│ Branches  feature/1                                                                                                       │
+
│ Commits   ahead 1, behind 1                                                                                               │
+
│ Status    open                                                                                                            │
+
├───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ 5c88a79 Add Alan                                                                                                          │
+
├───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ● Revision 217f050f8891def8fb863f7c0b4f85c89f97299d with head 5c88a79d75f5c2b4cc51ee6f163d2db91ee198d7 by alice (you) now │
+
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

commit 5c88a79d75f5c2b4cc51ee6f163d2db91ee198d7
Author: radicle <radicle@localhost>
@@ -102,21 +102,21 @@ When we look at the patch, we see that it has both commits, because this new
patch uses the same base as the previous patch:
```
$ rad patch show -v e22ff008e2a0ed47262890d13263031d7555b555
-
╭────────────────────────────────────────────────────╮
-
│ Title     Add Mel                                  │
-
│ Patch     e22ff008e2a0ed47262890d13263031d7555b555 │
-
│ Author    alice (you)                              │
-
│ Head      7f63fcbcf23fc39eea784c091ad3d20d7e4bd005 │
-
│ Base      f64fb2c8fe28f7c458c72ec8d700373924794943 │
-
│ Branches  feature/2                                │
-
│ Commits   ahead 2, behind 1                        │
-
│ Status    open                                     │
-
├────────────────────────────────────────────────────┤
-
│ 7f63fcb Add Mel                                    │
-
│ 5c88a79 Add Alan                                   │
-
├────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (7f63fcb) now              │
-
╰────────────────────────────────────────────────────╯
+
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ Title     Add Mel                                                                                                         │
+
│ Patch     e22ff008e2a0ed47262890d13263031d7555b555                                                                        │
+
│ Author    alice (you)                                                                                                     │
+
│ Head      7f63fcbcf23fc39eea784c091ad3d20d7e4bd005                                                                        │
+
│ Base      f64fb2c8fe28f7c458c72ec8d700373924794943                                                                        │
+
│ Branches  feature/2                                                                                                       │
+
│ Commits   ahead 2, behind 1                                                                                               │
+
│ Status    open                                                                                                            │
+
├───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ 7f63fcb Add Mel                                                                                                           │
+
│ 5c88a79 Add Alan                                                                                                          │
+
├───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ● Revision e22ff008e2a0ed47262890d13263031d7555b555 with head 7f63fcbcf23fc39eea784c091ad3d20d7e4bd005 by alice (you) now │
+
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```

If we want to instead create a "stacked" patch, we can do so with the
@@ -137,18 +137,18 @@ that it is still two commits ahead and one behind from `master`.

```
$ rad patch show -v a467ffa260c4fbe355b6fb550ba0c4956078717e
-
╭────────────────────────────────────────────────────╮
-
│ Title     Add Mel #2                               │
-
│ Patch     a467ffa260c4fbe355b6fb550ba0c4956078717e │
-
│ Author    alice (you)                              │
-
│ Head      7f63fcbcf23fc39eea784c091ad3d20d7e4bd005 │
-
│ Base      5c88a79d75f5c2b4cc51ee6f163d2db91ee198d7 │
-
│ Branches  feature/2                                │
-
│ Commits   ahead 2, behind 1                        │
-
│ Status    open                                     │
-
├────────────────────────────────────────────────────┤
-
│ 7f63fcb Add Mel                                    │
-
├────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (7f63fcb) now              │
-
╰────────────────────────────────────────────────────╯
+
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ Title     Add Mel #2                                                                                                      │
+
│ Patch     a467ffa260c4fbe355b6fb550ba0c4956078717e                                                                        │
+
│ Author    alice (you)                                                                                                     │
+
│ Head      7f63fcbcf23fc39eea784c091ad3d20d7e4bd005                                                                        │
+
│ Base      5c88a79d75f5c2b4cc51ee6f163d2db91ee198d7                                                                        │
+
│ Branches  feature/2                                                                                                       │
+
│ Commits   ahead 2, behind 1                                                                                               │
+
│ Status    open                                                                                                            │
+
├───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ 7f63fcb Add Mel                                                                                                           │
+
├───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ● Revision a467ffa260c4fbe355b6fb550ba0c4956078717e with head 7f63fcbcf23fc39eea784c091ad3d20d7e4bd005 by alice (you) now │
+
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```
modified crates/radicle-cli/examples/rad-patch-change-base.md
@@ -43,7 +43,7 @@ To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkE
Our second patch looks like the following:

```
-
$ rad patch show 183d343ab47d7fe18baf1b24b7209ad033d7fe5c -v
+
$ rad patch show 183d343ab47d7fe18baf1b24b7209ad033d7fe5c
╭────────────────────────────────────────────────────╮
│ Title     Add README, just for the fun             │
│ Patch     183d343ab47d7fe18baf1b24b7209ad033d7fe5c │
@@ -57,7 +57,7 @@ $ rad patch show 183d343ab47d7fe18baf1b24b7209ad033d7fe5c -v
│ 27857ec Add README, just for the fun               │
│ 3e674d1 Define power requirements                  │
├────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (27857ec) now              │
+
│ ● Revision 183d343 @ 27857ec by alice (you) now    │
╰────────────────────────────────────────────────────╯
```

@@ -74,20 +74,20 @@ Now, if we show the patch we can see the patch's base has changed and
we have a single commit:

```
-
$ rad patch show 183d343 -v
-
╭─────────────────────────────────────────────────────────────────────╮
-
│ Title     Add README, just for the fun                              │
-
│ Patch     183d343ab47d7fe18baf1b24b7209ad033d7fe5c                  │
-
│ Author    alice (you)                                               │
-
│ Head      27857ec9eb04c69cacab516e8bf4b5fd36090f66                  │
-
│ Base      3e674d1a1df90807e934f9ae5da2591dd6848a33                  │
-
│ Branches  add-readme                                                │
-
│ Commits   ahead 2, behind 0                                         │
-
│ Status    open                                                      │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ 27857ec Add README, just for the fun                                │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (27857ec) now                               │
-
│ ↑ updated to ebe76f9c2148eb595d7a745f82275786bf3458c3 (27857ec) now │
-
╰─────────────────────────────────────────────────────────────────────╯
+
$ rad patch show 183d343
+
╭────────────────────────────────────────────────────╮
+
│ Title     Add README, just for the fun             │
+
│ Patch     183d343ab47d7fe18baf1b24b7209ad033d7fe5c │
+
│ Author    alice (you)                              │
+
│ Head      27857ec9eb04c69cacab516e8bf4b5fd36090f66 │
+
│ Base      3e674d1a1df90807e934f9ae5da2591dd6848a33 │
+
│ Branches  add-readme                               │
+
│ Commits   ahead 2, behind 0                        │
+
│ Status    open                                     │
+
├────────────────────────────────────────────────────┤
+
│ 27857ec Add README, just for the fun               │
+
├────────────────────────────────────────────────────┤
+
│ ● Revision 183d343 @ 27857ec by alice (you) now    │
+
│ ↑ Revision ebe76f9 @ 27857ec by alice (you) now    │
+
╰────────────────────────────────────────────────────╯
```
modified crates/radicle-cli/examples/rad-patch-checkout-revision.md
@@ -15,25 +15,26 @@ We can see the list of revisions of the patch by `show`ing it:

```
$ rad patch show aa45913
-
╭─────────────────────────────────────────────────────────────────────╮
-
│ Title     Define power requirements                                 │
-
│ Patch     aa45913e757cacd46972733bddee5472c78fa32a                  │
-
│ Author    alice (you)                                               │
-
│ Head      639f44a25145a37f747f3c84265037a9461e44c5                  │
-
│ Branches  patch/aa45913                                             │
-
│ Commits   ahead 3, behind 0                                         │
-
│ Status    open                                                      │
-
│                                                                     │
-
│ See details.                                                        │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ 639f44a Add LICENSE, just for the business                          │
-
│ 27857ec Add README, just for the fun                                │
-
│ 3e674d1 Define power requirements                                   │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (3e674d1) now                               │
-
│ ↑ updated to 3156bed9d64d4675d6cf56612d217fc5f4e8a53a (27857ec) now │
-
│ ↑ updated to 2f5324f61e05cda65b667eeea02570d077a8e724 (639f44a) now │
-
╰─────────────────────────────────────────────────────────────────────╯
+
╭────────────────────────────────────────────────────╮
+
│ Title     Define power requirements                │
+
│ Patch     aa45913e757cacd46972733bddee5472c78fa32a │
+
│ Author    alice (you)                              │
+
│ Head      639f44a25145a37f747f3c84265037a9461e44c5 │
+
│ Base      [..                                    ] │
+
│ Branches  patch/aa45913                            │
+
│ Commits   ahead 3, behind 0                        │
+
│ Status    open                                     │
+
│                                                    │
+
│ See details.                                       │
+
├────────────────────────────────────────────────────┤
+
│ 639f44a Add LICENSE, just for the business         │
+
│ 27857ec Add README, just for the fun               │
+
│ 3e674d1 Define power requirements                  │
+
├────────────────────────────────────────────────────┤
+
│ ● Revision aa45913 @ 3e674d1 by alice (you) now    │
+
│ ↑ Revision 3156bed @ 27857ec by alice (you) now    │
+
│ ↑ Revision 2f5324f @ 639f44a by alice (you) now    │
+
╰────────────────────────────────────────────────────╯
```

So, let's checkout the previous revision, `0c0942e2`:
modified crates/radicle-cli/examples/rad-patch-delete.md
@@ -33,23 +33,23 @@ $ rad patch comment 6c61ef1 -m "I think we should use MIT"

``` ~alice
$ rad patch show 6c61ef1 -v
-
╭────────────────────────────────────────────────────╮
-
│ Title     Define LICENSE for project               │
-
│ Patch     6c61ef1716ad8a5c11e04dd7a3fec51e01fba70b │
-
│ Author    alice (you)                              │
-
│ Head      717c900ec17735639587325e0fd9fe09991c9edd │
-
│ Base      f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 │
-
│ Branches  prepare-license                          │
-
│ Commits   ahead 1, behind 0                        │
-
│ Status    draft                                    │
-
├────────────────────────────────────────────────────┤
-
│ 717c900 Introduce license                          │
-
├────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (717c900) now              │
-
├────────────────────────────────────────────────────┤
-
│ bob z6Mkt67…v4N1tRk now 833db19                    │
-
│ I think we should use MIT                          │
-
╰────────────────────────────────────────────────────╯
+
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ Title     Define LICENSE for project                                                                                      │
+
│ Patch     6c61ef1716ad8a5c11e04dd7a3fec51e01fba70b                                                                        │
+
│ Author    alice (you)                                                                                                     │
+
│ Head      717c900ec17735639587325e0fd9fe09991c9edd                                                                        │
+
│ Base      f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354                                                                        │
+
│ Branches  prepare-license                                                                                                 │
+
│ Commits   ahead 1, behind 0                                                                                               │
+
│ Status    draft                                                                                                           │
+
├───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ 717c900 Introduce license                                                                                                 │
+
├───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ● Revision 6c61ef1716ad8a5c11e04dd7a3fec51e01fba70b with head 717c900ec17735639587325e0fd9fe09991c9edd by alice (you) now │
+
├───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ bob z6Mkt67…v4N1tRk now 833db19                                                                                           │
+
│ I think we should use MIT                                                                                                 │
+
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
$ rad patch comment 6c61ef1 --reply-to 833db19 -m "Thanks, I'll add it!"
╭─────────────────────────╮
│ alice (you) now 1803a38 │
@@ -60,7 +60,6 @@ $ rad patch comment 6c61ef1 --reply-to 833db19 -m "Thanks, I'll add it!"

``` ~alice
$ touch MIT
-
$ ln MIT LICENSE -f
$ git add MIT
$ git commit -am "Add MIT License"
[prepare-license 1cc8cd9] Add MIT License
@@ -85,22 +84,22 @@ $ rad patch review 6c61ef1 --accept -m "LGTM!"
✓ Patch 6c61ef1 accepted
✓ Synced with 2 seed(s)
$ rad patch show 6c61ef1 -v
-
╭─────────────────────────────────────────────────────────────────────╮
-
│ Title    Define LICENSE for project                                 │
-
│ Patch    6c61ef1716ad8a5c11e04dd7a3fec51e01fba70b                   │
-
│ Author   alice z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi     │
-
│ Head     1cc8cd9de8ccc44b4fe3876f2dbd2cd1cf9ddc0e                   │
-
│ Base     f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354                   │
-
│ Commits  ahead 2, behind 0                                          │
-
│ Status   draft                                                      │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ 1cc8cd9 Add MIT License                                             │
-
│ 717c900 Introduce license                                           │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ ● opened by alice z6MknSL…StBU8Vi (717c900) now                     │
-
│ ↑ updated to 93915b9afa94a9dc4f52f12cdf077d4613ea3eb3 (1cc8cd9) now │
-
│   └─ ✓ accepted by bob (you) now                                    │
-
╰─────────────────────────────────────────────────────────────────────╯
+
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ Title    Define LICENSE for project                                                                                                                                  │
+
│ Patch    6c61ef1716ad8a5c11e04dd7a3fec51e01fba70b                                                                                                                    │
+
│ Author   alice z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi                                                                                                      │
+
│ Head     1cc8cd9de8ccc44b4fe3876f2dbd2cd1cf9ddc0e                                                                                                                    │
+
│ Base     f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354                                                                                                                    │
+
│ Commits  ahead 2, behind 0                                                                                                                                           │
+
│ Status   draft                                                                                                                                                       │
+
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ 1cc8cd9 Add MIT License                                                                                                                                              │
+
│ 717c900 Introduce license                                                                                                                                            │
+
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ● Revision 6c61ef1716ad8a5c11e04dd7a3fec51e01fba70b with head 717c900ec17735639587325e0fd9fe09991c9edd by alice z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi now │
+
│ ↑ Revision 93915b9afa94a9dc4f52f12cdf077d4613ea3eb3 with head 1cc8cd9de8ccc44b4fe3876f2dbd2cd1cf9ddc0e by alice z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi now │
+
│   └─ ✓ accepted by bob (you) now                                                                                                                                     │
+
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```

``` ~bob
@@ -110,22 +109,22 @@ $ rad patch delete 6c61ef1

``` ~alice
$ rad patch show 6c61ef1 -v
-
╭─────────────────────────────────────────────────────────────────────╮
-
│ Title     Define LICENSE for project                                │
-
│ Patch     6c61ef1716ad8a5c11e04dd7a3fec51e01fba70b                  │
-
│ Author    alice (you)                                               │
-
│ Head      1cc8cd9de8ccc44b4fe3876f2dbd2cd1cf9ddc0e                  │
-
│ Base      f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354                  │
-
│ Branches  prepare-license                                           │
-
│ Commits   ahead 2, behind 0                                         │
-
│ Status    draft                                                     │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ 1cc8cd9 Add MIT License                                             │
-
│ 717c900 Introduce license                                           │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (717c900) now                               │
-
│ ↑ updated to 93915b9afa94a9dc4f52f12cdf077d4613ea3eb3 (1cc8cd9) now │
-
╰─────────────────────────────────────────────────────────────────────╯
+
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ Title     Define LICENSE for project                                                                                      │
+
│ Patch     6c61ef1716ad8a5c11e04dd7a3fec51e01fba70b                                                                        │
+
│ Author    alice (you)                                                                                                     │
+
│ Head      1cc8cd9de8ccc44b4fe3876f2dbd2cd1cf9ddc0e                                                                        │
+
│ Base      f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354                                                                        │
+
│ Branches  prepare-license                                                                                                 │
+
│ Commits   ahead 2, behind 0                                                                                               │
+
│ Status    draft                                                                                                           │
+
├───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ 1cc8cd9 Add MIT License                                                                                                   │
+
│ 717c900 Introduce license                                                                                                 │
+
├───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ● Revision 6c61ef1716ad8a5c11e04dd7a3fec51e01fba70b with head 717c900ec17735639587325e0fd9fe09991c9edd by alice (you) now │
+
│ ↑ Revision 93915b9afa94a9dc4f52f12cdf077d4613ea3eb3 with head 1cc8cd9de8ccc44b4fe3876f2dbd2cd1cf9ddc0e by alice (you) now │
+
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```

If Alice also decides to delete the patch, then any seeds that have synced with
modified crates/radicle-cli/examples/rad-patch-diff.md
@@ -11,13 +11,13 @@ $ git push rad HEAD:refs/patches
```
```
$ rad patch diff 147309e
-
╭───────────────────────────╮
-
│ README.md +1 ❲created❳    │
-
├───────────────────────────┤
-
│ @@ -0,0 +1,1 @@           │
-
│      1     + Hello World! │
-
╰───────────────────────────╯
-

+
diff --git a/README.md b/README.md
+
new file mode 100644
+
index 0000000..980a0d5
+
--- /dev/null
+
+++ b/README.md
+
@@ -0,0 +1 @@
+
+Hello World!
```

If we add another file and update the patch, we can see it in the diff.
@@ -32,20 +32,20 @@ $ git push -f
```
```
$ rad patch diff 147309e
-
╭─────────────────────────────╮
-
│ RADICLE.md +1 ❲created❳     │
-
├─────────────────────────────┤
-
│ @@ -0,0 +1,1 @@             │
-
│      1     + Hello Radicle! │
-
╰─────────────────────────────╯
-

-
╭─────────────────────────────╮
-
│ README.md +1 ❲created❳      │
-
├─────────────────────────────┤
-
│ @@ -0,0 +1,1 @@             │
-
│      1     + Hello World!   │
-
╰─────────────────────────────╯
-

+
diff --git a/RADICLE.md b/RADICLE.md
+
new file mode 100644
+
index 0000000..e517184
+
--- /dev/null
+
+++ b/RADICLE.md
+
@@ -0,0 +1 @@
+
+Hello Radicle!
+
diff --git a/README.md b/README.md
+
new file mode 100644
+
index 0000000..980a0d5
+
--- /dev/null
+
+++ b/README.md
+
@@ -0,0 +1 @@
+
+Hello World!
```

Buf if we only want to see the changes from the first revision, we can do that
@@ -53,11 +53,11 @@ too.

```
$ rad patch diff 147309e --revision 147309e
-
╭───────────────────────────╮
-
│ README.md +1 ❲created❳    │
-
├───────────────────────────┤
-
│ @@ -0,0 +1,1 @@           │
-
│      1     + Hello World! │
-
╰───────────────────────────╯
-

+
diff --git a/README.md b/README.md
+
new file mode 100644
+
index 0000000..980a0d5
+
--- /dev/null
+
+++ b/README.md
+
@@ -0,0 +1 @@
+
+Hello World!
```
modified crates/radicle-cli/examples/rad-patch-draft.md
@@ -23,13 +23,14 @@ $ rad patch show 97e18f8598237a396a1c0ac1509c89028e666c97
│ Patch     97e18f8598237a396a1c0ac1509c89028e666c97 │
│ Author    alice (you)                              │
│ Head      2a465832b5a76abe25be44a3a5d224bbd7741ba7 │
+
│ Base      [..                                    ] │
│ Branches  cloudhead/draft                          │
│ Commits   ahead 1, behind 0                        │
│ Status    draft                                    │
├────────────────────────────────────────────────────┤
│ 2a46583 Nothing to see here..                      │
├────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (2a46583) [ .. ]           │
+
│ ● Revision 97e18f8 @ 2a46583 by alice (you) now    │
╰────────────────────────────────────────────────────╯
```

@@ -46,13 +47,14 @@ $ rad patch show 97e18f8598237a396a1c0ac1509c89028e666c97
│ Patch     97e18f8598237a396a1c0ac1509c89028e666c97 │
│ Author    alice (you)                              │
│ Head      2a465832b5a76abe25be44a3a5d224bbd7741ba7 │
+
│ Base      [..                                    ] │
│ Branches  cloudhead/draft                          │
│ Commits   ahead 1, behind 0                        │
│ Status    open                                     │
├────────────────────────────────────────────────────┤
│ 2a46583 Nothing to see here..                      │
├────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (2a46583) [ .. ]           │
+
│ ● Revision 97e18f8 @ 2a46583 by alice (you) now    │
╰────────────────────────────────────────────────────╯
```

@@ -67,12 +69,13 @@ $ rad patch show 97e18f8598237a396a1c0ac1509c89028e666c97
│ Patch     97e18f8598237a396a1c0ac1509c89028e666c97 │
│ Author    alice (you)                              │
│ Head      2a465832b5a76abe25be44a3a5d224bbd7741ba7 │
+
│ Base      [..                                    ] │
│ Branches  cloudhead/draft                          │
│ Commits   ahead 1, behind 0                        │
│ Status    draft                                    │
├────────────────────────────────────────────────────┤
│ 2a46583 Nothing to see here..                      │
├────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (2a46583) [ .. ]           │
+
│ ● Revision 97e18f8 @ 2a46583 by alice (you) now    │
╰────────────────────────────────────────────────────╯
```
modified crates/radicle-cli/examples/rad-patch-edit.md
@@ -45,21 +45,22 @@ Let's look at the patch, to see what it looks like before editing it:

```
$ rad patch show 89f7afb
-
╭─────────────────────────────────────────────────────────────────────╮
-
│ Title     Add README, just for the fun                              │
-
│ Patch     89f7afb1511b976482b21f6b2f39aef7f4fb88a2                  │
-
│ Author    alice (you)                                               │
-
│ Head      8945f6189adf027892c85ac57f7e9341049c2537                  │
-
│ Branches  changes                                                   │
-
│ Commits   ahead 2, behind 0                                         │
-
│ Status    open                                                      │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ 8945f61 Define the LICENSE                                          │
-
│ 03c02af Add README, just for the fun                                │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (03c02af) now                               │
-
│ ↑ updated to 5d78dd5376453e25df5988ec86951c99cb73742c (8945f61) now │
-
╰─────────────────────────────────────────────────────────────────────╯
+
╭────────────────────────────────────────────────────╮
+
│ Title     Add README, just for the fun             │
+
│ Patch     89f7afb1511b976482b21f6b2f39aef7f4fb88a2 │
+
│ Author    alice (you)                              │
+
│ Head      8945f6189adf027892c85ac57f7e9341049c2537 │
+
│ Base      [..                                    ] │
+
│ Branches  changes                                  │
+
│ Commits   ahead 2, behind 0                        │
+
│ Status    open                                     │
+
├────────────────────────────────────────────────────┤
+
│ 8945f61 Define the LICENSE                         │
+
│ 03c02af Add README, just for the fun               │
+
├────────────────────────────────────────────────────┤
+
│ ● Revision 89f7afb @ 03c02af by alice (you) now    │
+
│ ↑ Revision 5d78dd5 @ 8945f61 by alice (you) now    │
+
╰────────────────────────────────────────────────────╯
```

We can change the title and description of the patch itself by using a
@@ -68,23 +69,24 @@ multi-line message (using two `--message` options here):
```
$ rad patch edit 89f7afb --message "Add Metadata" --message "Add README & LICENSE" --no-announce
$ rad patch show 89f7afb
-
╭─────────────────────────────────────────────────────────────────────╮
-
│ Title     Add Metadata                                              │
-
│ Patch     89f7afb1511b976482b21f6b2f39aef7f4fb88a2                  │
-
│ Author    alice (you)                                               │
-
│ Head      8945f6189adf027892c85ac57f7e9341049c2537                  │
-
│ Branches  changes                                                   │
-
│ Commits   ahead 2, behind 0                                         │
-
│ Status    open                                                      │
-
│                                                                     │
-
│ Add README & LICENSE                                                │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ 8945f61 Define the LICENSE                                          │
-
│ 03c02af Add README, just for the fun                                │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (03c02af) now                               │
-
│ ↑ updated to 5d78dd5376453e25df5988ec86951c99cb73742c (8945f61) now │
-
╰─────────────────────────────────────────────────────────────────────╯
+
╭────────────────────────────────────────────────────╮
+
│ Title     Add Metadata                             │
+
│ Patch     89f7afb1511b976482b21f6b2f39aef7f4fb88a2 │
+
│ Author    alice (you)                              │
+
│ Head      8945f6189adf027892c85ac57f7e9341049c2537 │
+
│ Base      [..                                    ] │
+
│ Branches  changes                                  │
+
│ Commits   ahead 2, behind 0                        │
+
│ Status    open                                     │
+
│                                                    │
+
│ Add README & LICENSE                               │
+
├────────────────────────────────────────────────────┤
+
│ 8945f61 Define the LICENSE                         │
+
│ 03c02af Add README, just for the fun               │
+
├────────────────────────────────────────────────────┤
+
│ ● Revision 89f7afb @ 03c02af by alice (you) now    │
+
│ ↑ Revision 5d78dd5 @ 8945f61 by alice (you) now    │
+
╰────────────────────────────────────────────────────╯
```

Notice that the `Title` is now `Add Metadata`, and the patch now has a
@@ -96,23 +98,24 @@ If we want to change a specific revision's description, we can use the
```
$ rad patch edit 89f7afb --revision 5d78dd5 --message "Changes: Adds LICENSE file" --no-announce
$ rad patch show 89f7afb
-
╭─────────────────────────────────────────────────────────────────────╮
-
│ Title     Add Metadata                                              │
-
│ Patch     89f7afb1511b976482b21f6b2f39aef7f4fb88a2                  │
-
│ Author    alice (you)                                               │
-
│ Head      8945f6189adf027892c85ac57f7e9341049c2537                  │
-
│ Branches  changes                                                   │
-
│ Commits   ahead 2, behind 0                                         │
-
│ Status    open                                                      │
-
│                                                                     │
-
│ Add README & LICENSE                                                │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ 8945f61 Define the LICENSE                                          │
-
│ 03c02af Add README, just for the fun                                │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (03c02af) now                               │
-
│ ↑ updated to 5d78dd5376453e25df5988ec86951c99cb73742c (8945f61) now │
-
╰─────────────────────────────────────────────────────────────────────╯
+
╭────────────────────────────────────────────────────╮
+
│ Title     Add Metadata                             │
+
│ Patch     89f7afb1511b976482b21f6b2f39aef7f4fb88a2 │
+
│ Author    alice (you)                              │
+
│ Head      8945f6189adf027892c85ac57f7e9341049c2537 │
+
│ Base      [..                                    ] │
+
│ Branches  changes                                  │
+
│ Commits   ahead 2, behind 0                        │
+
│ Status    open                                     │
+
│                                                    │
+
│ Add README & LICENSE                               │
+
├────────────────────────────────────────────────────┤
+
│ 8945f61 Define the LICENSE                         │
+
│ 03c02af Add README, just for the fun               │
+
├────────────────────────────────────────────────────┤
+
│ ● Revision 89f7afb @ 03c02af by alice (you) now    │
+
│ ↑ Revision 5d78dd5 @ 8945f61 by alice (you) now    │
+
╰────────────────────────────────────────────────────╯
```

We can see that this didn't affect the patch's description, but
modified crates/radicle-cli/examples/rad-patch-fetch-2.md
@@ -22,6 +22,7 @@ $ git branch -r
$ git pull
Already up to date.
$ git branch -r
+
  rad/HEAD -> rad/master
  rad/master
  rad/patches/5e2dedcc5d515fcbc1cca483d3376609fe889bfb
```
added crates/radicle-cli/examples/rad-patch-jj.md
@@ -0,0 +1,91 @@
+
The scenario in this file is a variation of the one in `rad-patch.md`,
+
but uses Jujutsu.
+

+
```
+
$ touch REQUIREMENTS
+
$ jj describe --message "Define power requirements"
+
$ jj status
+
Working copy changes:
+
A REQUIREMENTS
+
Working copy  (@) : lvxkkpmk a6ea7b72 Define power requirements
+
Parent commit (@-): xpnzuzwn f2de534b master master@rad | Second commit
+
```
+

+
```
+
$ jj new
+
```
+

+
Just making sure that Git sees the Change ID…
+

+
```
+
$ git cat-file commit a6ea7b72
+
tree [..]
+
parent f2de534b[..]
+
author Test User <test.user@example.com> 981147906 +0700
+
committer Test User <test.user@example.com> 981147906 +0700
+
change-id lvxkkpmk[..]
+

+
Define power requirements
+
```
+

+
As of 2025-05 we can't use `jj` to do push with options directly, see:
+

+
 - <https://github.com/jj-vcs/jj/issues/4075>
+
 - <https://github.com/jj-vcs/jj/pull/2098>
+

+
However, since we initialized Jujutusu to colocate with Git, we can just use
+
Git to push.
+

+
``` (stderr)
+
$ git push rad -o patch.message="Define power requirements" -o patch.message="See details." HEAD:refs/patches
+
✓ Patch 1e31055ed3c41a48f2a71ba5317feb863b089700 opened
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new reference]   HEAD -> refs/patches
+
```
+

+
It will now be listed as one of the open patches.
+

+
```
+
$ rad patch
+
╭─────────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●  ID       Title                      Author         Reviews  Head     +   -   Updated │
+
├─────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●  1e31055  Define power requirements  alice   (you)  -        a6ea7b7  +0  -0  now     │
+
╰─────────────────────────────────────────────────────────────────────────────────────────╯
+
```
+

+
Let's also create a bookmark for it.
+

+
```
+
$ jj bookmark create flux-capacitor-power
+
```
+

+
```
+
$ rad patch show 1e31055 -p
+
╭───────────────────────────────────────────────────╮
+
│ Title    Define power requirements                │
+
│ Patch    1e31055[..                             ] │
+
│ Author   alice (you)                              │
+
│ Head     a6ea7b7[..                             ] │
+
│ Base     f2de534[..                             ] │
+
│ Commits  ahead 1, behind 0                        │
+
│ Status   open                                     │
+
│                                                   │
+
│ See details.                                      │
+
├───────────────────────────────────────────────────┤
+
│ a6ea7b7 Define power requirements                 │
+
├───────────────────────────────────────────────────┤
+
│ ● Revision 1e31055 @ a6ea7b7 by alice (you) now   │
+
╰───────────────────────────────────────────────────╯
+

+
commit a6ea7b7[..]
+
Author: Test User <test.user@example.com>
+
Date:   Sat Feb 3 04:05:06 2001 +0700
+

+
    Define power requirements
+

+
diff --git a/REQUIREMENTS b/REQUIREMENTS
+
new file mode 100644
+
index 0000000..e69de29
+

+
```

\ No newline at end of file
modified crates/radicle-cli/examples/rad-patch-pull-update.md
@@ -97,21 +97,23 @@ Alice pulls the update.

``` ~alice
$ rad patch show 55b9721
-
╭─────────────────────────────────────────────────────────────────────╮
-
│ Title    Bob's patch                                                │
-
│ Patch    55b9721ed7f6bfec38f43729e9b6631c5dc812fb                   │
-
│ Author   bob z6Mkt67…v4N1tRk                                        │
-
│ Head     cad2666a8a2250e4dee175ed5044be2c251ff08b                   │
-
│ Commits  ahead 2, behind 0                                          │
-
│ Status   open                                                       │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ cad2666 Bob's commit #2                                             │
-
│ bdcdb30 Bob's commit #1                                             │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ ● opened by bob z6Mkt67…v4N1tRk (bdcdb30) now                       │
-
│ ↑ updated to f91e056da05b2d9a58af1160c76245bc3debf7a8 (cad2666) now │
-
╰─────────────────────────────────────────────────────────────────────╯
+
╭─────────────────────────────────────────────────────────╮
+
│ Title    Bob's patch                                    │
+
│ Patch    55b9721ed7f6bfec38f43729e9b6631c5dc812fb       │
+
│ Author   bob z6Mkt67…v4N1tRk                            │
+
│ Head     cad2666a8a2250e4dee175ed5044be2c251ff08b       │
+
│ Base     [..                                          ] │
+
│ Commits  ahead 2, behind 0                              │
+
│ Status   open                                           │
+
├─────────────────────────────────────────────────────────┤
+
│ cad2666 Bob's commit #2                                 │
+
│ bdcdb30 Bob's commit #1                                 │
+
├─────────────────────────────────────────────────────────┤
+
│ ● Revision 55b9721 @ bdcdb30 by bob z6Mkt67…v4N1tRk now │
+
│ ↑ Revision f91e056 @ cad2666 by bob z6Mkt67…v4N1tRk now │
+
╰─────────────────────────────────────────────────────────╯
$ git ls-remote rad
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	HEAD
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	refs/heads/master
cad2666a8a2250e4dee175ed5044be2c251ff08b	refs/heads/patches/55b9721ed7f6bfec38f43729e9b6631c5dc812fb
```
modified crates/radicle-cli/examples/rad-patch-revert-merge.md
@@ -21,20 +21,21 @@ First we see the patch as merged.

```
$ rad patch show 696ec5508494692899337afe6713fe1796d0315c
-
╭────────────────────────────────────────────────────────────────╮
-
│ Title     First change                                         │
-
│ Patch     696ec5508494692899337afe6713fe1796d0315c             │
-
│ Author    alice (you)                                          │
-
│ Head      20aa5dde6210796c3a2f04079b42316a31d02689             │
-
│ Branches  feature/1, master                                    │
-
│ Commits   up to date                                           │
-
│ Status    merged                                               │
-
├────────────────────────────────────────────────────────────────┤
-
│ 20aa5dd First change                                           │
-
├────────────────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (20aa5dd) now                          │
-
│   └─ ✓ merged by alice (you) at revision 696ec55 (20aa5dd) now │
-
╰────────────────────────────────────────────────────────────────╯
+
╭────────────────────────────────────────────────────╮
+
│ Title     First change                             │
+
│ Patch     696ec5508494692899337afe6713fe1796d0315c │
+
│ Author    alice (you)                              │
+
│ Head      20aa5dde6210796c3a2f04079b42316a31d02689 │
+
│ Base      [..                                    ] │
+
│ Branches  feature/1, master                        │
+
│ Commits   up to date                               │
+
│ Status    merged                                   │
+
├────────────────────────────────────────────────────┤
+
│ 20aa5dd First change                               │
+
├────────────────────────────────────────────────────┤
+
│ ● Revision 696ec55 @ 20aa5dd by alice (you) now    │
+
│   └─ ✓ merged                by alice (you)        │
+
╰────────────────────────────────────────────────────╯
```

Now let's revert the patch by pushing a new `master` that doesn't include
@@ -64,12 +65,13 @@ $ rad patch show 696ec5508494692899337afe6713fe1796d0315c
│ Patch     696ec5508494692899337afe6713fe1796d0315c │
│ Author    alice (you)                              │
│ Head      20aa5dde6210796c3a2f04079b42316a31d02689 │
+
│ Base      [..                                    ] │
│ Branches  feature/1                                │
│ Commits   ahead 1, behind 0                        │
│ Status    open                                     │
├────────────────────────────────────────────────────┤
│ 20aa5dd First change                               │
├────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (20aa5dd) now              │
+
│ ● Revision 696ec55 @ 20aa5dd by alice (you) now    │
╰────────────────────────────────────────────────────╯
```
modified crates/radicle-cli/examples/rad-patch-update.md
@@ -18,13 +18,14 @@ $ rad patch show b6a23eb08656de0ef1fcc0b5fe8820841e5cb2e5
│ Patch     b6a23eb08656de0ef1fcc0b5fe8820841e5cb2e5 │
│ Author    alice (you)                              │
│ Head      51b2f0f77b9849bfaa3e9d3ff68ee2f57771d20c │
+
│ Base      [..                                    ] │
│ Branches  feature/1                                │
│ Commits   ahead 1, behind 0                        │
│ Status    open                                     │
├────────────────────────────────────────────────────┤
│ 51b2f0f Not a real change                          │
├────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (51b2f0f) now              │
+
│ ● Revision b6a23eb @ 51b2f0f by alice (you) now    │
╰────────────────────────────────────────────────────╯
```

@@ -54,19 +55,20 @@ The command outputs the new Revision ID, which we can now see here:

```
$ rad patch show b6a23eb08656de0ef1fcc0b5fe8820841e5cb2e5
-
╭─────────────────────────────────────────────────────────────────────╮
-
│ Title     Not a real change                                         │
-
│ Patch     b6a23eb08656de0ef1fcc0b5fe8820841e5cb2e5                  │
-
│ Author    alice (you)                                               │
-
│ Head      4d272148458a17620541555b1f0905c01658aa9f                  │
-
│ Branches  feature/1                                                 │
-
│ Commits   ahead 2, behind 0                                         │
-
│ Status    open                                                      │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ 4d27214 Rename readme file                                          │
-
│ 51b2f0f Not a real change                                           │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (51b2f0f) now                               │
-
│ ↑ updated to ea7def3857f62f404606d7cd6490cd0de4eaebd1 (4d27214) now │
-
╰─────────────────────────────────────────────────────────────────────╯
+
╭────────────────────────────────────────────────────╮
+
│ Title     Not a real change                        │
+
│ Patch     b6a23eb08656de0ef1fcc0b5fe8820841e5cb2e5 │
+
│ Author    alice (you)                              │
+
│ Head      4d272148458a17620541555b1f0905c01658aa9f │
+
│ Base      [..                                    ] │
+
│ Branches  feature/1                                │
+
│ Commits   ahead 2, behind 0                        │
+
│ Status    open                                     │
+
├────────────────────────────────────────────────────┤
+
│ 4d27214 Rename readme file                         │
+
│ 51b2f0f Not a real change                          │
+
├────────────────────────────────────────────────────┤
+
│ ● Revision b6a23eb @ 51b2f0f by alice (you) now    │
+
│ ↑ Revision ea7def3 @ 4d27214 by alice (you) now    │
+
╰────────────────────────────────────────────────────╯
```
modified crates/radicle-cli/examples/rad-patch-via-push.md
@@ -9,7 +9,7 @@ Switched to a new branch 'feature/1'
$ git commit -a -m "Add things" -q --allow-empty
$ git push -o patch.message="Add things #1" -o patch.message="See commits for details." rad HEAD:refs/patches
✓ Patch 6035d2f582afbe01ff23ea87528ae523d76875b6 opened
-
hint: to update, run `git push` or `git push rad -f HEAD:patches/6035d2f582afbe01ff23ea87528ae523d76875b6`
+
hint: to update, run `git push` or `git push rad --force-with-lease HEAD:patches/6035d2f582afbe01ff23ea87528ae523d76875b6`
hint: offline push, your node is not running
hint: to sync with the network, run `rad node start`
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
@@ -25,6 +25,7 @@ $ rad patch show 6035d2f582afbe01ff23ea87528ae523d76875b6
│ Patch     6035d2f582afbe01ff23ea87528ae523d76875b6 │
│ Author    alice (you)                              │
│ Head      42d894a83c9c356552a57af09ccdbd5587a99045 │
+
│ Base      [..                                    ] │
│ Branches  feature/1                                │
│ Commits   ahead 1, behind 0                        │
│ Status    open                                     │
@@ -33,7 +34,7 @@ $ rad patch show 6035d2f582afbe01ff23ea87528ae523d76875b6
├────────────────────────────────────────────────────┤
│ 42d894a Add things                                 │
├────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (42d894a) now              │
+
│ ● Revision 6035d2f @ 42d894a by alice (you) now    │
╰────────────────────────────────────────────────────╯
```

@@ -61,6 +62,7 @@ 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/HEAD
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/remotes/rad/master
42d894a83c9c356552a57af09ccdbd5587a99045 refs/remotes/rad/patches/6035d2f582afbe01ff23ea87528ae523d76875b6
```
@@ -137,21 +139,22 @@ We can then see that the patch head has moved:

```
$ rad patch show 9580891
-
╭─────────────────────────────────────────────────────────────────────╮
-
│ Title     Add more things                                           │
-
│ Patch     95808913573cead52ad7b42c7b475260ec45c4b2                  │
-
│ Author    alice (you)                                               │
-
│ Head      02bef3fac41b2f98bb3c02b868a53ddfecb55b5f                  │
-
│ Branches  feature/2                                                 │
-
│ Commits   ahead 2, behind 0                                         │
-
│ Status    open                                                      │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ 02bef3f Improve code                                                │
-
│ 8b0ea80 Add more things                                             │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (8b0ea80) now                               │
-
│ ↑ updated to d7040c6c97629c2b94f86fb639bebbff5de39697 (02bef3f) now │
-
╰─────────────────────────────────────────────────────────────────────╯
+
╭────────────────────────────────────────────────────╮
+
│ Title     Add more things                          │
+
│ Patch     95808913573cead52ad7b42c7b475260ec45c4b2 │
+
│ Author    alice (you)                              │
+
│ Head      02bef3fac41b2f98bb3c02b868a53ddfecb55b5f │
+
│ Base      [..                                    ] │
+
│ Branches  feature/2                                │
+
│ Commits   ahead 2, behind 0                        │
+
│ Status    open                                     │
+
├────────────────────────────────────────────────────┤
+
│ 02bef3f Improve code                               │
+
│ 8b0ea80 Add more things                            │
+
├────────────────────────────────────────────────────┤
+
│ ● Revision 9580891 @ 8b0ea80 by alice (you) now    │
+
│ ↑ Revision d7040c6 @ 02bef3f by alice (you) now    │
+
╰────────────────────────────────────────────────────╯
```

And we can check that all the refs are properly updated in our repository:
@@ -200,10 +203,10 @@ 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.
+
use `--force-with-lease` (or `--force`) to force the update.

``` (stderr)
-
$ git push --force
+
$ git push --force-with-lease
✓ Patch 9580891 updated to revision 670d02794aa05afd6e0851f4aa848bc87c4712c7
To compare against your previous revision d7040c6, run:

@@ -217,22 +220,107 @@ That worked. We can see the new revision if we call `rad patch show`:

```
$ rad patch show 9580891
-
╭─────────────────────────────────────────────────────────────────────╮
-
│ Title     Add more things                                           │
-
│ Patch     95808913573cead52ad7b42c7b475260ec45c4b2                  │
-
│ Author    alice (you)                                               │
-
│ Head      9304dbc445925187994a7a93222a3f8bde73b785                  │
-
│ Branches  feature/2                                                 │
-
│ Commits   ahead 2, behind 0                                         │
-
│ Status    open                                                      │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ 9304dbc Amended commit                                              │
-
│ 8b0ea80 Add more things                                             │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (8b0ea80) now                               │
-
│ ↑ updated to d7040c6c97629c2b94f86fb639bebbff5de39697 (02bef3f) now │
-
│ ↑ updated to 670d02794aa05afd6e0851f4aa848bc87c4712c7 (9304dbc) now │
-
╰─────────────────────────────────────────────────────────────────────╯
+
╭────────────────────────────────────────────────────╮
+
│ Title     Add more things                          │
+
│ Patch     95808913573cead52ad7b42c7b475260ec45c4b2 │
+
│ Author    alice (you)                              │
+
│ Head      9304dbc445925187994a7a93222a3f8bde73b785 │
+
│ Base      [..                                    ] │
+
│ Branches  feature/2                                │
+
│ Commits   ahead 2, behind 0                        │
+
│ Status    open                                     │
+
├────────────────────────────────────────────────────┤
+
│ 9304dbc Amended commit                             │
+
│ 8b0ea80 Add more things                            │
+
├────────────────────────────────────────────────────┤
+
│ ● Revision 9580891 @ 8b0ea80 by alice (you) now    │
+
│ ↑ Revision d7040c6 @ 02bef3f by alice (you) now    │
+
│ ↑ Revision 670d027 @ 9304dbc by alice (you) now    │
+
╰────────────────────────────────────────────────────╯
+
```
+

+
## Detached HEAD
+

+
In some cases, we may be creating patches from a detached HEAD state, but we
+
still want to have a tracking branch. We can do this using the `patch.branch`
+
option.
+

+
```
+
$ git commit --allow-empty -m "Going into detached HEAD"
+
[feature/2 831e838] Going into detached HEAD
+
```
+

+
``` (stderr)
+
$ git checkout 831e838
+
Note: switching to '831e838'.
+

+
You are in 'detached HEAD' state. You can look around, make experimental
+
changes and commit them, and you can discard any commits you make in this
+
state without impacting any branches by switching back to a branch.
+

+
If you want to create a new branch to retain commits you create, you may
+
do so (now or later) by using -c with the switch command. Example:
+

+
  git switch -c <new-branch-name>
+

+
Or undo this operation with:
+

+
  git switch -
+

+
Turn off this advice by setting config variable advice.detachedHead to false
+

+
HEAD is now at 831e838 Going into detached HEAD
+
$ git push rad HEAD:refs/patches -o patch.branch
+
✓ Patch e0fd879ac0a7d3ddcf3b440d8db656f5d7cebea3 opened
+
✓ Branch patches/e0fd879ac0a7d3ddcf3b440d8db656f5d7cebea3 created
+
hint: to update, run `git push rad patches/e0fd879ac0a7d3ddcf3b440d8db656f5d7cebea3`
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new reference]   HEAD -> refs/patches
+
```
+

+
The default name used for the branch is `patches/<patch id>`. So let's checkout
+
the branch and push a new revision:
+

+
``` (stderr)
+
$ git checkout patches/e0fd879ac0a7d3ddcf3b440d8db656f5d7cebea3
+
Switched to branch 'patches/e0fd879ac0a7d3ddcf3b440d8db656f5d7cebea3'
+
$ git commit --allow-empty -m "Pushing new revision"
+
$ git push rad
+
✓ Patch e0fd879 updated to revision 943cbd9769e855d5e4eba419e68374c5141a2785
+
To compare against your previous revision e0fd879, run:
+

+
   git range-diff [..] [..] [..]
+

+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
   831e838..d0ff2a1  patches/e0fd879ac0a7d3ddcf3b440d8db656f5d7cebea3 -> patches/e0fd879ac0a7d3ddcf3b440d8db656f5d7cebea3
+
```
+

+
However, we also allow you to name the branch yourself:
+

+
``` (stderr)
+
$ git checkout 831e838 -q
+
$ git push rad HEAD:refs/patches -o patch.branch='feature/3'
+
✓ Patch e0fd879ac0a7d3ddcf3b440d8db656f5d7cebea3 opened
+
✓ Branch feature/3 created
+
hint: to update, run `git push rad feature/3`
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new reference]   HEAD -> refs/patches
+
```
+

+
Let's checkout this branch and also push a new revision:
+

+
``` (stderr)
+
$ git checkout feature/3
+
Switched to branch 'feature/3'
+
$ git commit --allow-empty -m "Pushing new revision"
+
$ git push rad
+
✓ Patch e0fd879 updated to revision 943cbd9769e855d5e4eba419e68374c5141a2785
+
To compare against your previous revision e0fd879, run:
+

+
   git range-diff [..] [..] [..]
+

+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
   831e838..d0ff2a1  feature/3 -> patches/e0fd879ac0a7d3ddcf3b440d8db656f5d7cebea3
```

## Empty patch
@@ -242,6 +330,7 @@ we should get an error:

``` (stderr) (fail)
$ git push rad master:refs/patches
+
warn: attempted to create a patch using the commit f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354, but this commit is already included in the base branch
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 ! [remote rejected] master -> refs/patches (patch commits are already included in the base branch)
error: failed to push some refs to 'rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi'
modified crates/radicle-cli/examples/rad-patch.md
@@ -48,6 +48,7 @@ $ rad patch show aa45913e757cacd46972733bddee5472c78fa32a -p
│ Patch     aa45913e757cacd46972733bddee5472c78fa32a │
│ Author    alice (you)                              │
│ Head      3e674d1a1df90807e934f9ae5da2591dd6848a33 │
+
│ Base      [..                                    ] │
│ Branches  flux-capacitor-power                     │
│ Commits   ahead 1, behind 0                        │
│ Status    open                                     │
@@ -56,7 +57,7 @@ $ rad patch show aa45913e757cacd46972733bddee5472c78fa32a -p
├────────────────────────────────────────────────────┤
│ 3e674d1 Define power requirements                  │
├────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (3e674d1) now              │
+
│ ● Revision aa45913 @ 3e674d1 by alice (you) now    │
╰────────────────────────────────────────────────────╯

commit 3e674d1a1df90807e934f9ae5da2591dd6848a33
@@ -102,6 +103,7 @@ $ rad patch show aa45913
│ Author    alice (you)                              │
│ Labels    fun                                      │
│ Head      3e674d1a1df90807e934f9ae5da2591dd6848a33 │
+
│ Base      [..                                    ] │
│ Branches  flux-capacitor-power                     │
│ Commits   ahead 1, behind 0                        │
│ Status    open                                     │
@@ -110,7 +112,7 @@ $ rad patch show aa45913
├────────────────────────────────────────────────────┤
│ 3e674d1 Define power requirements                  │
├────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (3e674d1) now              │
+
│ ● Revision aa45913 @ 3e674d1 by alice (you) now    │
╰────────────────────────────────────────────────────╯
```

@@ -183,25 +185,26 @@ Showing the patch list now will reveal the favorable verdict:

```
$ rad patch show aa45913
-
╭─────────────────────────────────────────────────────────────────────╮
-
│ Title     Define power requirements                                 │
-
│ Patch     aa45913e757cacd46972733bddee5472c78fa32a                  │
-
│ Author    alice (you)                                               │
-
│ Labels    fun                                                       │
-
│ Head      27857ec9eb04c69cacab516e8bf4b5fd36090f66                  │
-
│ Branches  flux-capacitor-power, patch/aa45913                       │
-
│ Commits   ahead 2, behind 0                                         │
-
│ Status    open                                                      │
-
│                                                                     │
-
│ See details.                                                        │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ 27857ec Add README, just for the fun                                │
-
│ 3e674d1 Define power requirements                                   │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (3e674d1) now                               │
-
│ ↑ updated to 6e5a3b7b2ce27b32e7ccc2f0b3f4594897dde638 (27857ec) now │
-
│   └─ ✓ accepted by alice (you) now                                  │
-
╰─────────────────────────────────────────────────────────────────────╯
+
╭────────────────────────────────────────────────────╮
+
│ Title     Define power requirements                │
+
│ Patch     aa45913e757cacd46972733bddee5472c78fa32a │
+
│ Author    alice (you)                              │
+
│ Labels    fun                                      │
+
│ Head      27857ec9eb04c69cacab516e8bf4b5fd36090f66 │
+
│ Base      [..                                    ] │
+
│ Branches  flux-capacitor-power, patch/aa45913      │
+
│ Commits   ahead 2, behind 0                        │
+
│ Status    open                                     │
+
│                                                    │
+
│ See details.                                       │
+
├────────────────────────────────────────────────────┤
+
│ 27857ec Add README, just for the fun               │
+
│ 3e674d1 Define power requirements                  │
+
├────────────────────────────────────────────────────┤
+
│ ● Revision aa45913 @ 3e674d1 by alice (you) now    │
+
│ ↑ Revision 6e5a3b7 @ 27857ec by alice (you) now    │
+
│   └─ ✓ accepted              by alice (you) now    │
+
╰────────────────────────────────────────────────────╯
$ rad patch list
╭─────────────────────────────────────────────────────────────────────────────────────────╮
│ ●  ID       Title                      Author         Reviews  Head     +   -   Updated │
@@ -215,23 +218,24 @@ If you make a mistake on the patch description, you can always change it!
```
$ rad patch edit aa45913 --message "Define power requirements" --message "Add requirements file" --no-announce
$ rad patch show aa45913
-
╭─────────────────────────────────────────────────────────────────────╮
-
│ Title     Define power requirements                                 │
-
│ Patch     aa45913e757cacd46972733bddee5472c78fa32a                  │
-
│ Author    alice (you)                                               │
-
│ Labels    fun                                                       │
-
│ Head      27857ec9eb04c69cacab516e8bf4b5fd36090f66                  │
-
│ Branches  flux-capacitor-power, patch/aa45913                       │
-
│ Commits   ahead 2, behind 0                                         │
-
│ Status    open                                                      │
-
│                                                                     │
-
│ Add requirements file                                               │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ 27857ec Add README, just for the fun                                │
-
│ 3e674d1 Define power requirements                                   │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (3e674d1) now                               │
-
│ ↑ updated to 6e5a3b7b2ce27b32e7ccc2f0b3f4594897dde638 (27857ec) now │
-
│   └─ ✓ accepted by alice (you) now                                  │
-
╰─────────────────────────────────────────────────────────────────────╯
+
╭────────────────────────────────────────────────────╮
+
│ Title     Define power requirements                │
+
│ Patch     aa45913e757cacd46972733bddee5472c78fa32a │
+
│ Author    alice (you)                              │
+
│ Labels    fun                                      │
+
│ Head      27857ec9eb04c69cacab516e8bf4b5fd36090f66 │
+
│ Base      [..                                    ] │
+
│ Branches  flux-capacitor-power, patch/aa45913      │
+
│ Commits   ahead 2, behind 0                        │
+
│ Status    open                                     │
+
│                                                    │
+
│ Add requirements file                              │
+
├────────────────────────────────────────────────────┤
+
│ 27857ec Add README, just for the fun               │
+
│ 3e674d1 Define power requirements                  │
+
├────────────────────────────────────────────────────┤
+
│ ● Revision aa45913 @ 3e674d1 by alice (you) now    │
+
│ ↑ Revision 6e5a3b7 @ 27857ec by alice (you) now    │
+
│   └─ ✓ accepted              by alice (you) now    │
+
╰────────────────────────────────────────────────────╯
```
modified crates/radicle-cli/examples/rad-self.md
@@ -3,17 +3,17 @@ device and node.

```
$ rad self
-
Alias           alice
-
DID             did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
└╴Node ID (NID) z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
SSH             not running
-
├╴Key (hash)    SHA256:UIedaL6Cxm6OUErh9GQUzzglSk7VpQlVTI1TAFB/HWA
-
└╴Key (full)    ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHahWSBEpuT1ESZbynOmBNkLBSnR32Ar4woZqSV2YNH1
-
Home            [..]/home/alice/.radicle
-
├╴Config        [..]/home/alice/.radicle/config.json
-
├╴Storage       [..]/home/alice/.radicle/storage
-
├╴Keys          [..]/home/alice/.radicle/keys
-
└╴Node          [..]/home/alice/.radicle/node
+
Alias        alice
+
DID          did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
Node         not running
+
SSH          not running
+
├╴Key (hash) SHA256:UIedaL6Cxm6OUErh9GQUzzglSk7VpQlVTI1TAFB/HWA
+
└╴Key (full) ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHahWSBEpuT1ESZbynOmBNkLBSnR32Ar4woZqSV2YNH1
+
Home         [..]/alice/.radicle
+
├╴Config     [..]/alice/.radicle/config.json
+
├╴Storage    [..]/alice/.radicle/storage
+
├╴Keys       [..]/alice/.radicle/keys
+
└╴Node       [..]/alice/.radicle/node
```

If you need to display only your DID, Node ID, or SSH Public Key, you can use
@@ -29,6 +29,11 @@ $ rad self --nid
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
```

+
``` (stderr)
+
$ rad self --nid
+
! Deprecated: The command/option `rad self --nid` is deprecated and will be removed. Please use `rad node status --only nid` instead.
+
```
+

```
$ rad self --ssh-key
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHahWSBEpuT1ESZbynOmBNkLBSnR32Ar4woZqSV2YNH1
@@ -36,5 +41,5 @@ ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHahWSBEpuT1ESZbynOmBNkLBSnR32Ar4woZqSV2YNH1

```
$ rad self --home
-
[..]/home/alice/.radicle
+
[..]/alice/.radicle
```
modified crates/radicle-cli/examples/workflow/4-patching-contributor.md
@@ -46,6 +46,7 @@ $ rad patch show e4934b6d9dbe01ce3c7fbb5b77a80d5f1dacdc46
│ Patch     e4934b6d9dbe01ce3c7fbb5b77a80d5f1dacdc46 │
│ Author    bob (you)                                │
│ Head      3e674d1a1df90807e934f9ae5da2591dd6848a33 │
+
│ Base      [..                                    ] │
│ Branches  flux-capacitor-power                     │
│ Commits   ahead 1, behind 0                        │
│ Status    open                                     │
@@ -54,7 +55,7 @@ $ rad patch show e4934b6d9dbe01ce3c7fbb5b77a80d5f1dacdc46
├────────────────────────────────────────────────────┤
│ 3e674d1 Define power requirements                  │
├────────────────────────────────────────────────────┤
-
│ ● opened by bob (you) (3e674d1) now                │
+
│ ● Revision e4934b6 @ 3e674d1 by bob (you) now      │
╰────────────────────────────────────────────────────╯
```

modified crates/radicle-cli/examples/workflow/5-patching-maintainer.md
@@ -28,22 +28,23 @@ $ git branch -r
  bob/patches/e4934b6d9dbe01ce3c7fbb5b77a80d5f1dacdc46
  rad/master
$ rad patch show e4934b6
-
╭─────────────────────────────────────────────────────────────────────╮
-
│ Title    Define power requirements                                  │
-
│ Patch    e4934b6d9dbe01ce3c7fbb5b77a80d5f1dacdc46                   │
-
│ Author   bob z6Mkt67…v4N1tRk                                        │
-
│ Head     27857ec9eb04c69cacab516e8bf4b5fd36090f66                   │
-
│ Commits  ahead 2, behind 0                                          │
-
│ Status   open                                                       │
-
│                                                                     │
-
│ See details.                                                        │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ 27857ec Add README, just for the fun                                │
-
│ 3e674d1 Define power requirements                                   │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ ● opened by bob z6Mkt67…v4N1tRk (3e674d1) now                       │
-
│ ↑ updated to 773b9aab58b11e9fa83d0ed0baca2bea6ff889c9 (27857ec) now │
-
╰─────────────────────────────────────────────────────────────────────╯
+
╭─────────────────────────────────────────────────────────╮
+
│ Title    Define power requirements                      │
+
│ Patch    e4934b6d9dbe01ce3c7fbb5b77a80d5f1dacdc46       │
+
│ Author   bob z6Mkt67…v4N1tRk                            │
+
│ Head     27857ec9eb04c69cacab516e8bf4b5fd36090f66       │
+
│ Base     [..                                          ] │
+
│ Commits  ahead 2, behind 0                              │
+
│ Status   open                                           │
+
│                                                         │
+
│ See details.                                            │
+
├─────────────────────────────────────────────────────────┤
+
│ 27857ec Add README, just for the fun                    │
+
│ 3e674d1 Define power requirements                       │
+
├─────────────────────────────────────────────────────────┤
+
│ ● Revision e4934b6 @ 3e674d1 by bob z6Mkt67…v4N1tRk now │
+
│ ↑ Revision 773b9aa @ 27857ec by bob z6Mkt67…v4N1tRk now │
+
╰─────────────────────────────────────────────────────────╯
```

Wait! There's a mistake.  The REQUIREMENTS should be a markdown file.  Let's
@@ -102,25 +103,26 @@ The patch is now merged and closed :).

```
$ rad patch show e4934b6
-
╭─────────────────────────────────────────────────────────────────────╮
-
│ Title    Define power requirements                                  │
-
│ Patch    e4934b6d9dbe01ce3c7fbb5b77a80d5f1dacdc46                   │
-
│ Author   bob z6Mkt67…v4N1tRk                                        │
-
│ Head     27857ec9eb04c69cacab516e8bf4b5fd36090f66                   │
-
│ Commits  ahead 0, behind 1                                          │
-
│ Status   merged                                                     │
-
│                                                                     │
-
│ See details.                                                        │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ 27857ec Add README, just for the fun                                │
-
│ 3e674d1 Define power requirements                                   │
-
├─────────────────────────────────────────────────────────────────────┤
-
│ ● opened by bob z6Mkt67…v4N1tRk (3e674d1) now                       │
-
│ ↑ updated to 773b9aab58b11e9fa83d0ed0baca2bea6ff889c9 (27857ec) now │
-
│ * revised by alice (you) in 9d62420 (f567f69) now                   │
-
│   └─ ✓ accepted by alice (you) now                                  │
-
│   └─ ✓ merged by alice (you) at revision 9d62420 (f567f69) now      │
-
╰─────────────────────────────────────────────────────────────────────╯
+
╭─────────────────────────────────────────────────────────╮
+
│ Title    Define power requirements                      │
+
│ Patch    e4934b6d9dbe01ce3c7fbb5b77a80d5f1dacdc46       │
+
│ Author   bob z6Mkt67…v4N1tRk                            │
+
│ Head     27857ec9eb04c69cacab516e8bf4b5fd36090f66       │
+
│ Base     [..                                          ] │
+
│ Commits  ahead 0, behind 1                              │
+
│ Status   merged                                         │
+
│                                                         │
+
│ See details.                                            │
+
├─────────────────────────────────────────────────────────┤
+
│ 27857ec Add README, just for the fun                    │
+
│ 3e674d1 Define power requirements                       │
+
├─────────────────────────────────────────────────────────┤
+
│ ● Revision e4934b6 @ 3e674d1 by bob z6Mkt67…v4N1tRk now │
+
│ ↑ Revision 773b9aa @ 27857ec by bob z6Mkt67…v4N1tRk now │
+
│ ↑ Revision 9d62420 @ f567f69 by alice (you) now         │
+
│   └─ ✓ accepted              by alice (you) now         │
+
│   └─ ✓ merged                by alice (you)             │
+
╰─────────────────────────────────────────────────────────╯
```

To publish our new state to the network, we simply push:
modified crates/radicle-cli/src/commands/auth.rs
@@ -1,6 +1,6 @@
#![allow(clippy::or_fun_call)]
-
use std::ffi::OsString;
-
use std::ops::Not as _;
+
mod args;
+

use std::str::FromStr;

use anyhow::{anyhow, Context};
@@ -12,73 +12,17 @@ use radicle::profile::env;
use radicle::{profile, Profile};

use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
-

-
pub const HELP: Help = Help {
-
    name: "auth",
-
    description: "Manage identities and profiles",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad auth [<option>...]
-

-
    A passphrase may be given via the environment variable `RAD_PASSPHRASE` or
-
    via the standard input stream if `--stdin` is used. Using either of these
-
    methods disables the passphrase prompt.
-

-
Options
-

-
    --alias                 When initializing an identity, sets the node alias
-
    --stdin                 Read passphrase from stdin (default: false)
-
    --help                  Print help
-
"#,
-
};

-
#[derive(Debug)]
-
pub struct Options {
-
    pub stdin: bool,
-
    pub alias: Option<Alias>,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut stdin = false;
-
        let mut alias = None;
-
        let mut parser = lexopt::Parser::from_args(args);
+
pub use args::Args;

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("alias") => {
-
                    let val = parser.value()?;
-
                    let val = term::args::alias(&val)?;
-

-
                    alias = Some(val);
-
                }
-
                Long("stdin") => {
-
                    stdin = true;
-
                }
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        Ok((Options { alias, stdin }, vec![]))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    match ctx.profile() {
-
        Ok(profile) => authenticate(options, &profile),
-
        Err(_) => init(options),
+
        Ok(profile) => authenticate(args, &profile),
+
        Err(_) => init(args),
    }
}

-
pub fn init(options: Options) -> anyhow::Result<()> {
+
pub fn init(args: Args) -> anyhow::Result<()> {
    term::headline("Initializing your radicle 👾 identity");

    if let Ok(version) = radicle::git::version() {
@@ -90,26 +34,28 @@ pub fn init(options: Options) -> anyhow::Result<()> {
            term::blank();
        }
    } else {
-
        anyhow::bail!("a Git installation is required for Radicle to run");
+
        anyhow::bail!("A Git installation is required for Radicle to run.");
    }

-
    let alias: Alias = if let Some(alias) = options.alias {
+
    let alias: Alias = if let Some(alias) = args.alias {
        alias
    } else {
        let user = env::var("USER").ok().and_then(|u| Alias::from_str(&u).ok());
-
        term::input(
+
        let user = term::input(
            "Enter your alias:",
            user,
            Some("This is your node alias. You can always change it later"),
-
        )?
+
        )?;
+

+
        user.ok_or_else(|| anyhow::anyhow!("An alias is required for Radicle to run."))?
    };
    let home = profile::home()?;
-
    let passphrase = if options.stdin {
-
        term::passphrase_stdin()
+
    let passphrase = if args.stdin {
+
        Some(term::passphrase_stdin()?)
    } else {
-
        term::passphrase_confirm("Enter a passphrase:", env::RAD_PASSPHRASE)
-
    }?;
-
    let passphrase = passphrase.trim().is_empty().not().then_some(passphrase);
+
        term::passphrase_confirm("Enter a passphrase:", env::RAD_PASSPHRASE)?
+
    };
+
    let passphrase = passphrase.filter(|passphrase| !passphrase.trim().is_empty());
    let spinner = term::spinner("Creating your Ed25519 keypair...");
    let profile = Profile::init(home, alias, passphrase.clone(), env::seed())?;
    let mut agent = true;
@@ -164,7 +110,7 @@ pub fn init(options: Options) -> anyhow::Result<()> {

/// Try loading the identity's key into SSH Agent, falling back to verifying `RAD_PASSPHRASE` for
/// use.
-
pub fn authenticate(options: Options, profile: &Profile) -> anyhow::Result<()> {
+
pub fn authenticate(args: Args, profile: &Profile) -> anyhow::Result<()> {
    if !profile.keystore.is_encrypted()? {
        term::success!("Authenticated as {}", term::format::tertiary(profile.id()));
        return Ok(());
@@ -185,10 +131,16 @@ pub fn authenticate(options: Options, profile: &Profile) -> anyhow::Result<()> {
            }
            let passphrase = if let Some(phrase) = profile::env::passphrase() {
                phrase
-
            } else if options.stdin {
+
            } else if args.stdin {
                term::passphrase_stdin()?
-
            } else {
+
            } else if let Some(passphrase) =
                term::io::passphrase(term::io::PassphraseValidator::new(profile.keystore.clone()))?
+
            {
+
                passphrase
+
            } else {
+
                anyhow::bail!(
+
                    "A passphrase is required to read your Radicle key. Unable to continue."
+
                )
            };
            register(&mut agent, profile, passphrase)?;

@@ -233,7 +185,7 @@ pub fn register(
                e.into()
            }
        })?
-
        .ok_or_else(|| anyhow!("Key not found in {:?}", profile.keystore.path()))?;
+
        .ok_or_else(|| anyhow!("Key not found in {:?}", profile.keystore.secret_key_path()))?;

    agent.register(&secret)?;

added crates/radicle-cli/src/commands/auth/args.rs
@@ -0,0 +1,21 @@
+
use clap::Parser;
+
use radicle::node::Alias;
+

+
const ABOUT: &str = "Manage identities and profiles";
+
const LONG_ABOUT: &str = r#"
+
A passphrase may be given via the environment variable `RAD_PASSPHRASE` or
+
via the standard input stream if `--stdin` is used. Using either of these
+
methods disables the passphrase prompt.
+
"#;
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    /// When initializing an identity, sets the node alias
+
    #[arg(long)]
+
    pub alias: Option<Alias>,
+

+
    /// Read passphrase from stdin
+
    #[arg(long, default_value_t = false)]
+
    pub stdin: bool,
+
}
modified crates/radicle-cli/src/commands/block.rs
@@ -1,96 +1,23 @@
-
use std::ffi::OsString;
+
mod args;

use radicle::node::policy::Policy;
-
use radicle::prelude::{NodeId, RepoId};

use crate::terminal as term;
-
use crate::terminal::args;
-
use crate::terminal::args::{Args, Error, Help};

-
pub const HELP: Help = Help {
-
    name: "block",
-
    description: "Block repositories or nodes from being seeded or followed",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
+
use term::args::BlockTarget;

-
    rad block <rid> [<option>...]
-
    rad block <nid> [<option>...]
+
pub use args::Args;

-
    Blocks a repository from being seeded or a node from being followed.
-

-
Options
-

-
    --help          Print help
-
"#,
-
};
-

-
enum Target {
-
    Node(NodeId),
-
    Repo(RepoId),
-
}
-

-
impl std::fmt::Display for Target {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        match self {
-
            Self::Node(nid) => nid.fmt(f),
-
            Self::Repo(rid) => rid.fmt(f),
-
        }
-
    }
-
}
-

-
pub struct Options {
-
    target: Target,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut target = None;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Value(val) if target.is_none() => {
-
                    if let Ok(rid) = args::rid(&val) {
-
                        target = Some(Target::Repo(rid));
-
                    } else if let Ok(nid) = args::nid(&val) {
-
                        target = Some(Target::Node(nid));
-
                    } else {
-
                        anyhow::bail!(
-
                            "invalid repository or node specified, see `rad block --help`"
-
                        )
-
                    }
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                target: target.ok_or(anyhow::anyhow!(
-
                    "a repository or node to block must be specified, see `rad block --help`"
-
                ))?,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let mut policies = profile.policies_mut()?;

-
    let updated = match options.target {
-
        Target::Node(nid) => policies.set_follow_policy(&nid, Policy::Block)?,
-
        Target::Repo(rid) => policies.set_seed_policy(&rid, Policy::Block)?,
+
    let updated = match args.target {
+
        BlockTarget::Node(nid) => policies.set_follow_policy(&nid, Policy::Block)?,
+
        BlockTarget::Repo(rid) => policies.set_seed_policy(&rid, Policy::Block)?,
    };
    if updated {
-
        term::success!("Policy for {} set to 'block'", options.target);
+
        term::success!("Policy for {} set to 'block'", args.target);
    }
    Ok(())
}
added crates/radicle-cli/src/commands/block/args.rs
@@ -0,0 +1,42 @@
+
use clap::Parser;
+

+
use crate::terminal::args::BlockTarget;
+

+
const ABOUT: &str = "Block repositories or nodes from being seeded or followed";
+

+
#[derive(Parser, Debug)]
+
#[command(about = ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    /// A Repository ID or Node ID to block from seeding or following (respectively)
+
    ///
+
    /// [example values: rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH, z6MkiswaKJ85vafhffCGBu2gdBsYoDAyHVBWRxL3j297fwS9]
+
    #[arg(value_name = "RID|NID")]
+
    pub(super) target: BlockTarget,
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use clap::error::ErrorKind;
+
    use clap::Parser;
+

+
    use super::Args;
+

+
    #[test]
+
    fn should_parse_nid() {
+
        let args =
+
            Args::try_parse_from(["block", "z6MkiswaKJ85vafhffCGBu2gdBsYoDAyHVBWRxL3j297fwS9"]);
+
        assert!(args.is_ok())
+
    }
+

+
    #[test]
+
    fn should_parse_rid() {
+
        let args = Args::try_parse_from(["block", "rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH"]);
+
        assert!(args.is_ok())
+
    }
+

+
    #[test]
+
    fn should_not_parse() {
+
        let err = Args::try_parse_from(["block", "bee"]).unwrap_err();
+
        assert_eq!(err.kind(), ErrorKind::ValueValidation);
+
    }
+
}
modified crates/radicle-cli/src/commands/checkout.rs
@@ -1,5 +1,6 @@
#![allow(clippy::box_default)]
-
use std::ffi::OsString;
+
mod args;
+

use std::path::PathBuf;

use anyhow::anyhow;
@@ -12,80 +13,21 @@ use radicle::storage::git::transport;

use crate::project;
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
-

-
pub const HELP: Help = Help {
-
    name: "checkout",
-
    description: "Checkout a repository into the local directory",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad checkout <rid> [--remote <did>] [<option>...]
-

-
    Creates a working copy from a repository in local storage.
-

-
Options
-

-
    --remote <did>  Remote peer to checkout
-
    --no-confirm    Don't ask for confirmation during checkout
-
    --help          Print help
-
"#,
-
};

-
pub struct Options {
-
    pub id: RepoId,
-
    pub remote: Option<Did>,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut id = None;
-
        let mut remote = None;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("no-confirm") => {
-
                    // Ignored for now.
-
                }
-
                Long("help") | Short('h') => return Err(Error::Help.into()),
-
                Long("remote") => {
-
                    let val = parser.value().unwrap();
-
                    remote = Some(term::args::did(&val)?);
-
                }
-
                Value(val) if id.is_none() => {
-
                    id = Some(term::args::rid(&val)?);
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                id: id.ok_or_else(|| anyhow!("a repository to checkout must be provided"))?,
-
                remote,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
+
pub use args::Args;

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
-
    execute(options, &profile)?;
+
    execute(args, &profile)?;

    Ok(())
}

-
fn execute(options: Options, profile: &Profile) -> anyhow::Result<PathBuf> {
-
    let id = options.id;
+
fn execute(args: Args, profile: &Profile) -> anyhow::Result<PathBuf> {
    let storage = &profile.storage;
-
    let remote = options.remote.unwrap_or(profile.did());
+
    let remote = args.remote.unwrap_or(profile.did());
    let doc = storage
-
        .repository(id)?
+
        .repository(args.repo)?
        .identity_doc()
        .context("repository could not be found in local storage")?;
    let payload = doc.project()?;
@@ -98,7 +40,7 @@ fn execute(options: Options, profile: &Profile) -> anyhow::Result<PathBuf> {
    }

    let mut spinner = term::spinner("Performing checkout...");
-
    let repo = match radicle::rad::checkout(options.id, &remote, path.clone(), &storage) {
+
    let repo = match radicle::rad::checkout(args.repo, &remote, path.clone(), &storage, false) {
        Ok(repo) => repo,
        Err(err) => {
            spinner.failed();
@@ -124,7 +66,7 @@ fn execute(options: Options, profile: &Profile) -> anyhow::Result<PathBuf> {
    // Setup remote tracking branches for project delegates.
    setup_remotes(
        project::SetupRemote {
-
            rid: id,
+
            rid: args.repo,
            tracking: Some(payload.default_branch().clone()),
            repo: &repo,
            fetch: true,
@@ -156,9 +98,9 @@ pub fn setup_remotes(
pub fn setup_remote(
    setup: &project::SetupRemote,
    remote_id: &NodeId,
-
    remote_name: Option<git::RefString>,
+
    remote_name: Option<git::fmt::RefString>,
    aliases: &impl AliasStore,
-
) -> anyhow::Result<git::RefString> {
+
) -> anyhow::Result<git::fmt::RefString> {
    let remote_name = if let Some(name) = remote_name {
        name
    } else {
@@ -167,7 +109,7 @@ pub fn setup_remote(
        } else {
            remote_id.to_human()
        };
-
        git::RefString::try_from(name.as_str())
+
        git::fmt::RefString::try_from(name.as_str())
            .map_err(|_| anyhow!("invalid remote name: '{name}'"))?
    };
    let (remote, branch) = setup.run(&remote_name, *remote_id)?;
added crates/radicle-cli/src/commands/checkout/args.rs
@@ -0,0 +1,24 @@
+
use clap::Parser;
+
use radicle::prelude::{Did, RepoId};
+

+
const ABOUT: &str = "Checkout a repository into the local directory";
+
const LONG_ABOUT: &str = r#"
+
Creates a working copy from a repository in local storage.
+
"#;
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    /// Repository ID of the repository to checkout
+
    #[arg(value_name = "RID")]
+
    pub(super) repo: RepoId,
+

+
    /// The DID of the remote peer to checkout
+
    #[arg(long, value_name = "DID")]
+
    pub(super) remote: Option<Did>,
+

+
    /// Don't ask for confirmation during checkout
+
    // TODO(erikli): This is obsolete and should be removed
+
    #[arg(long)]
+
    no_confirm: bool,
+
}
modified crates/radicle-cli/src/commands/clean.rs
@@ -1,86 +1,23 @@
-
use std::ffi::OsString;
+
mod args;

-
use anyhow::anyhow;
-

-
use radicle::identity::RepoId;
use radicle::storage;
use radicle::storage::WriteStorage;

use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
-

-
pub const HELP: Help = Help {
-
    name: "clean",
-
    description: "Remove all remotes from a repository",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad clean <rid> [<option>...]
-

-
    Removes all remotes from a repository, as long as they are not the
-
    local operator or a delegate of the repository.

-
    Note that remotes will still be fetched as long as they are
-
    followed and/or the follow scope is "all".
-

-
Options
-

-
    --no-confirm        Do not ask for confirmation before removal (default: false)
-
    --help              Print help
-
"#,
-
};
-

-
pub struct Options {
-
    rid: RepoId,
-
    confirm: bool,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut id: Option<RepoId> = None;
-
        let mut confirm = true;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("no-confirm") => {
-
                    confirm = false;
-
                }
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Value(val) if id.is_none() => {
-
                    id = Some(term::args::rid(&val)?);
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                rid: id
-
                    .ok_or_else(|| anyhow!("an RID must be provided; see `rad clean --help`"))?,
-
                confirm,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
+
pub use args::Args;

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let storage = &profile.storage;
-
    let rid = options.rid;
+
    let rid = args.repo;
    let path = storage::git::paths::repository(storage, &rid);

    if !path.exists() {
        anyhow::bail!("repository {rid} was not found");
    }

-
    if !options.confirm || term::confirm(format!("Clean {rid}?")) {
+
    if args.no_confirm || term::confirm(format!("Clean {rid}?")) {
        let cleaned = storage.clean(rid)?;
        for remote in cleaned {
            term::info!("Removed {remote}");
added crates/radicle-cli/src/commands/clean/args.rs
@@ -0,0 +1,25 @@
+
use clap::Parser;
+

+
use radicle::prelude::RepoId;
+

+
const ABOUT: &str = "Remove all remotes from a repository";
+

+
const LONG_ABOUT: &str = r#"
+
Removes all remotes from a repository, as long as they are not the
+
local operator or a delegate of the repository.
+

+
Note that remotes will still be fetched as long as they are
+
followed and/or the follow scope is "all".
+
"#;
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    /// Operate on the given repository
+
    #[arg(value_name = "RID")]
+
    pub(super) repo: RepoId,
+

+
    /// Do not ask for confirmation before removal
+
    #[arg(long)]
+
    pub(super) no_confirm: bool,
+
}
modified crates/radicle-cli/src/commands/clone.rs
@@ -1,10 +1,7 @@
-
#![allow(clippy::or_fun_call)]
-
use std::ffi::OsString;
+
pub mod args;
+

use std::path::{Path, PathBuf};
-
use std::str::FromStr;
-
use std::time;

-
use anyhow::anyhow;
use radicle::issue::cache::Issues as _;
use radicle::patch::cache::Patches as _;
use thiserror::Error;
@@ -21,117 +18,16 @@ use radicle::storage;
use radicle::storage::RemoteId;
use radicle::storage::{HasRepoId, RepositoryError};

-
use crate::commands::rad_checkout as checkout;
-
use crate::commands::rad_sync as sync;
+
use crate::commands::checkout;
+
use crate::commands::sync;
use crate::node::SyncSettings;
use crate::project;
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
use crate::terminal::Element as _;

-
pub const HELP: Help = Help {
-
    name: "clone",
-
    description: "Clone a Radicle repository",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad clone <rid> [<directory>] [--scope <scope>] [<option>...]
-

-
    The `clone` command will use your local node's routing table to find seeds from
-
    which it can clone the repository.
-

-
    For private repositories, use the `--seed` options, to clone directly
-
    from known seeds in the privacy set.
-

-
Options
-

-
        --scope <scope>     Follow scope: `followed` or `all` (default: all)
-
    -s, --seed <nid>        Clone from this seed (may be specified multiple times)
-
        --timeout <secs>    Timeout for fetching repository (default: 9)
-
        --help              Print help
-

-
"#,
-
};
-

-
#[derive(Debug)]
-
pub struct Options {
-
    /// The RID of the repository.
-
    id: RepoId,
-
    /// The target directory for the repository to be cloned into.
-
    directory: Option<PathBuf>,
-
    /// The seeding scope of the repository.
-
    scope: Scope,
-
    /// Sync settings.
-
    sync: SyncSettings,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut id: Option<RepoId> = None;
-
        let mut scope = Scope::All;
-
        let mut sync = SyncSettings::default();
-
        let mut directory = None;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("seed") | Short('s') => {
-
                    let value = parser.value()?;
-
                    let value = term::args::nid(&value)?;
-

-
                    sync.seeds.insert(value);
-
                }
-
                Long("scope") => {
-
                    let value = parser.value()?;
-

-
                    scope = term::args::parse_value("scope", value)?;
-
                }
-
                Long("timeout") => {
-
                    let value = parser.value()?;
-
                    let secs = term::args::number(&value)?;
-

-
                    sync.timeout = time::Duration::from_secs(secs as u64);
-
                }
-
                Long("no-confirm") => {
-
                    // We keep this flag here for consistency though it doesn't have any effect,
-
                    // since the command is fully non-interactive.
-
                }
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Value(val) if id.is_none() => {
-
                    let val = val.to_string_lossy();
-
                    let val = val.strip_prefix("rad://").unwrap_or(&val);
-
                    let val = RepoId::from_str(val)?;
-

-
                    id = Some(val);
-
                }
-
                // Parse <directory> once <rid> has been parsed
-
                Value(val) if id.is_some() && directory.is_none() => {
-
                    directory = Some(Path::new(&val).to_path_buf());
-
                }
-
                _ => return Err(anyhow!(arg.unexpected())),
-
            }
-
        }
-
        let id =
-
            id.ok_or_else(|| anyhow!("to clone, an RID must be provided; see `rad clone --help`"))?;
-

-
        Ok((
-
            Options {
-
                id,
-
                directory,
-
                scope,
-
                sync,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
+
pub use args::Args;

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let mut node = radicle::Node::new(profile.socket());

@@ -147,15 +43,16 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        doc,
        project: proj,
    } = clone(
-
        options.id,
-
        options.directory.clone(),
-
        options.scope,
-
        options.sync.with_profile(&profile),
+
        args.repo,
+
        args.directory.clone(),
+
        args.scope,
+
        SyncSettings::from(args.sync).with_profile(&profile),
        &mut node,
        &profile,
+
        args.bare,
    )?
    .print_or_success()
-
    .ok_or_else(|| anyhow::anyhow!("failed to clone {}", options.id))?;
+
    .ok_or_else(|| anyhow::anyhow!("failed to clone {}", args.repo))?;
    let delegates = doc
        .delegates()
        .iter()
@@ -163,13 +60,17 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        .filter(|id| id != profile.id())
        .collect::<Vec<_>>();
    let default_branch = proj.default_branch().clone();
-
    let path = working.workdir().unwrap(); // SAFETY: The working copy is not bare.
+
    let path = if !args.bare {
+
        working.workdir().unwrap()
+
    } else {
+
        working.path()
+
    };

    // Configure repository and setup tracking for repository delegates.
    radicle::git::configure_repository(&working)?;
    checkout::setup_remotes(
        project::SetupRemote {
-
            rid: options.id,
+
            rid: args.repo,
            tracking: Some(default_branch),
            repo: &working,
            fetch: true,
@@ -199,7 +100,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    ])]);
    info.print();

-
    let location = options
+
    let location = args
        .directory
        .map_or(proj.name().to_string(), |loc| loc.display().to_string());
    term::info!(
@@ -229,6 +130,7 @@ struct Checkout {
    repository: storage::git::Repository,
    doc: Doc,
    project: Project,
+
    bare: bool,
}

impl Checkout {
@@ -236,6 +138,7 @@ impl Checkout {
        repository: storage::git::Repository,
        profile: &Profile,
        directory: Option<PathBuf>,
+
        bare: bool,
    ) -> Result<Self, CheckoutFailure> {
        let rid = repository.rid();
        let doc = repository
@@ -257,6 +160,7 @@ impl Checkout {
            repository,
            doc: doc.doc,
            project: proj,
+
            bare,
        })
    }

@@ -274,7 +178,7 @@ impl Checkout {
            "Creating checkout in ./{}..",
            term::format::tertiary(destination.display())
        ));
-
        match rad::checkout(self.id, &self.remote, self.path, storage) {
+
        match rad::checkout(self.id, &self.remote, self.path, storage, self.bare) {
            Err(err) => {
                spinner.message(format!(
                    "Failed to checkout in ./{}",
@@ -303,6 +207,7 @@ fn clone(
    settings: SyncSettings,
    node: &mut Node,
    profile: &Profile,
+
    bare: bool,
) -> Result<CloneResult, CloneError> {
    // Seed repository.
    if node.seed(id, scope)? {
@@ -322,7 +227,7 @@ fn clone(
                node::sync::FetcherResult::TargetReached(_) => {
                    profile.storage.repository(id).map_or_else(
                        |err| Ok(CloneResult::RepositoryMissing { rid: id, err }),
-
                        |repository| Ok(perform_checkout(repository, profile, directory)?),
+
                        |repository| Ok(perform_checkout(repository, profile, directory, bare)?),
                    )
                }
                node::sync::FetcherResult::TargetError(failure) => {
@@ -330,7 +235,7 @@ fn clone(
                }
            }
        }
-
        Ok(repository) => Ok(perform_checkout(repository, profile, directory)?),
+
        Ok(repository) => Ok(perform_checkout(repository, profile, directory, bare)?),
    }
}

@@ -338,8 +243,9 @@ fn perform_checkout(
    repository: storage::git::Repository,
    profile: &Profile,
    directory: Option<PathBuf>,
+
    bare: bool,
) -> Result<CloneResult, rad::CheckoutError> {
-
    Checkout::new(repository, profile, directory).map_or_else(
+
    Checkout::new(repository, profile, directory, bare).map_or_else(
        |failure| Ok(CloneResult::Failure(failure)),
        |checkout| checkout.run(&profile.storage),
    )
added crates/radicle-cli/src/commands/clone/args.rs
@@ -0,0 +1,105 @@
+
use std::path::PathBuf;
+
use std::time;
+

+
use clap::Parser;
+

+
use crate::node::SyncSettings;
+
use radicle::identity::doc::RepoId;
+
use radicle::identity::IdError;
+
use radicle::node::policy::Scope;
+
use radicle::prelude::*;
+

+
use crate::terminal;
+

+
const ABOUT: &str = "Clone a Radicle repository";
+

+
const LONG_ABOUT: &str = r#"
+
The `clone` command will use your local node's routing table to find seeds from
+
which it can clone the repository.
+

+
For private repositories, use the `--seed` options, to clone directly
+
from known seeds in the privacy set."#;
+

+
/// Parse an RID, optionally stripping "rad://" prefix.
+
fn parse_rid(value: &str) -> Result<RepoId, IdError> {
+
    value.strip_prefix("rad://").unwrap_or(value).parse()
+
}
+

+
#[derive(Debug, Parser)]
+
pub(super) struct SyncArgs {
+
    /// Clone from this seed (may be specified multiple times)
+
    #[arg(short, long = "seed", value_name = "NID", action = clap::ArgAction::Append)]
+
    seeds: Vec<NodeId>,
+

+
    /// Timeout for fetching repository in seconds
+
    #[arg(long, default_value_t = 9, value_name = "SECS")]
+
    timeout: usize,
+
}
+

+
impl From<SyncArgs> for SyncSettings {
+
    fn from(args: SyncArgs) -> Self {
+
        SyncSettings {
+
            timeout: time::Duration::from_secs(args.timeout as u64),
+
            seeds: args.seeds.into_iter().collect(),
+
            ..SyncSettings::default()
+
        }
+
    }
+
}
+

+
#[derive(Debug, Parser)]
+
#[clap(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    /// ID of the repository to clone
+
    ///
+
    /// [example values: rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH, rad://z3Tr6bC7ctEg2EHmLvknUr29mEDLH]
+
    #[arg(value_name = "RID", value_parser = parse_rid)]
+
    pub(super) repo: RepoId,
+

+
    /// The target directory for the repository to be cloned into
+
    #[arg(value_name = "PATH")]
+
    pub(super) directory: Option<PathBuf>,
+

+
    /// Follow scope
+
    #[arg(
+
        long,
+
        default_value_t = Scope::All,
+
        value_parser = terminal::args::ScopeParser
+
    )]
+
    pub(super) scope: Scope,
+

+
    #[clap(flatten)]
+
    pub(super) sync: SyncArgs,
+

+
    /// Make a bare repository
+
    #[arg(long)]
+
    pub(super) bare: bool,
+

+
    // We keep this flag here for consistency though it doesn't have any effect,
+
    // since the command is fully non-interactive.
+
    #[arg(long, hide = true)]
+
    pub(super) no_confirm: bool,
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use super::Args;
+
    use clap::Parser;
+

+
    #[test]
+
    fn should_parse_rid_non_urn() {
+
        let args = Args::try_parse_from(["clone", "z3Tr6bC7ctEg2EHmLvknUr29mEDLH"]);
+
        assert!(args.is_ok())
+
    }
+

+
    #[test]
+
    fn should_parse_rid_urn() {
+
        let args = Args::try_parse_from(["clone", "rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH"]);
+
        assert!(args.is_ok())
+
    }
+

+
    #[test]
+
    fn should_parse_rid_url() {
+
        let args = Args::try_parse_from(["clone", "rad://z3Tr6bC7ctEg2EHmLvknUr29mEDLH"]);
+
        assert!(args.is_ok())
+
    }
+
}
modified crates/radicle-cli/src/commands/cob.rs
@@ -1,7 +1,7 @@
-
use std::ffi::OsString;
-
use std::path::PathBuf;
-
use std::str::FromStr;
-
use std::{fs, io};
+
mod args;
+

+
use std::io;
+
use std::path::Path;

use anyhow::{anyhow, bail};

@@ -18,392 +18,51 @@ use radicle::storage;

use crate::git::Rev;
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
-

-
pub const HELP: Help = Help {
-
    name: "cob",
-
    description: "Manage collaborative objects",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad cob <command> [<option>...]
-

-
    rad cob create  --repo <rid> --type <typename> <filename> [<option>...]
-
    rad cob list    --repo <rid> --type <typename>
-
    rad cob log     --repo <rid> --type <typename> --object <oid> [<option>...]
-
    rad cob migrate [<option>...]
-
    rad cob show    --repo <rid> --type <typename> --object <oid> [<option>...]
-
    rad cob update  --repo <rid> --type <typename> --object <oid> <filename>
-
                    [<option>...]
-

-
Commands
-

-
    create                      Create a new COB of a given type given initial actions
-
    list                        List all COBs of a given type (--object is not needed)
-
    log                         Print a log of all raw operations on a COB
-
    migrate                     Migrate the COB database to the latest version
-
    update                      Add actions to a COB
-
    show                        Print the state of COBs
-

-
Create, Update options
-

-
    --embed-file <name> <path>  Supply embed of given name via file at given path
-
    --embed-hash <name> <oid>   Supply embed of given name via object ID of blob
-

-
Log options
-

-
    --format (pretty | json)    Desired output format (default: pretty)
-
    --from <oid>                Git object ID of the commit of the operation to
-
                                start iterating at.
-
    --until <oid>               Git object ID of the commit of the operation to
-
                                stop iterating at.
-

-
Show options
-

-
    --format json               Desired output format (default: json)
-

-
Other options
-

-
    --help                      Print help
-
"#,
-
};
-

-
#[derive(Clone, Copy, PartialEq)]
-
enum OperationName {
-
    Update,
-
    Create,
-
    List,
-
    Log,
-
    Migrate,
-
    Show,
-
}
-

-
enum Operation {
-
    Create {
-
        rid: RepoId,
-
        type_name: FilteredTypeName,
-
        message: String,
-
        actions: PathBuf,
-
        embeds: Vec<Embed>,
-
    },
-
    List {
-
        rid: RepoId,
-
        type_name: FilteredTypeName,
-
    },
-
    Log {
-
        rid: RepoId,
-
        type_name: FilteredTypeName,
-
        oid: Rev,
-
        format: Format,
-
        from: Option<Rev>,
-
        until: Option<Rev>,
-
    },
-
    Migrate,
-
    Show {
-
        rid: RepoId,
-
        type_name: FilteredTypeName,
-
        oids: Vec<Rev>,
-
    },
-
    Update {
-
        rid: RepoId,
-
        type_name: FilteredTypeName,
-
        oid: Rev,
-
        message: String,
-
        actions: PathBuf,
-
        embeds: Vec<Embed>,
-
    },
-
}
-

-
enum Format {
-
    Json,
-
    Pretty,
-
}
-

-
pub struct Options {
-
    op: Operation,
-
}
-

-
/// A precursor to [`cob::Embed`] used for parsing
-
/// that can be initialized without relying on a [`git::Repository`].
-
struct Embed {
-
    name: String,
-
    content: EmbedContent,
-
}
-

-
enum EmbedContent {
-
    Path(PathBuf),
-
    Hash(Rev),
-
}
-

-
/// A thin wrapper around [`cob::TypeName`] used for parsing.
-
/// Well known COB type names are captured as variants,
-
/// with [`FilteredTypeName::Other`] as an escape hatch for type names
-
/// that are not well known.
-
enum FilteredTypeName {
-
    Issue,
-
    Patch,
-
    Identity,
-
    Other(cob::TypeName),
-
}
-

-
impl From<cob::TypeName> for FilteredTypeName {
-
    fn from(value: cob::TypeName) -> Self {
-
        if value == *cob::issue::TYPENAME {
-
            FilteredTypeName::Issue
-
        } else if value == *cob::patch::TYPENAME {
-
            FilteredTypeName::Patch
-
        } else if value == *cob::identity::TYPENAME {
-
            FilteredTypeName::Identity
-
        } else {
-
            FilteredTypeName::Other(value)
-
        }
-
    }
-
}
-

-
impl AsRef<cob::TypeName> for FilteredTypeName {
-
    fn as_ref(&self) -> &cob::TypeName {
-
        match self {
-
            FilteredTypeName::Issue => &cob::issue::TYPENAME,
-
            FilteredTypeName::Patch => &cob::patch::TYPENAME,
-
            FilteredTypeName::Identity => &cob::identity::TYPENAME,
-
            FilteredTypeName::Other(value) => value,
-
        }
-
    }
-
}
-

-
impl std::fmt::Display for FilteredTypeName {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        self.as_ref().fmt(f)
-
    }
-
}
-

-
impl Embed {
-
    fn try_into_bytes(
-
        self,
-
        repo: &storage::git::Repository,
-
    ) -> anyhow::Result<cob::Embed<cob::Uri>> {
-
        Ok(match self.content {
-
            EmbedContent::Hash(hash) => cob::Embed {
-
                name: self.name,
-
                content: hash.resolve::<git::Oid>(&repo.backend)?.into(),
-
            },
-
            EmbedContent::Path(path) => {
-
                cob::Embed::store(self.name, &std::fs::read(path)?, &repo.backend)?
-
            }
-
        })
-
    }
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-
        use term::args::string;
-
        use OperationName::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-

-
        let op = match parser.next()? {
-
            None | Some(Long("help") | Short('h')) => {
-
                return Err(Error::Help.into());
-
            }
-
            Some(Value(val)) => match val.to_string_lossy().as_ref() {
-
                "update" => Update,
-
                "create" => Create,
-
                "list" => List,
-
                "log" => Log,
-
                "migrate" => Migrate,
-
                "show" => Show,
-
                unknown => bail!("unknown operation '{unknown}'"),
-
            },
-
            Some(arg) => return Err(anyhow!(arg.unexpected())),
-
        };
-

-
        let mut type_name: Option<FilteredTypeName> = None;
-
        let mut oids: Vec<Rev> = vec![];
-
        let mut rid: Option<RepoId> = None;
-
        let mut format: Format = Format::Pretty;
-
        let mut message: Option<String> = None;
-
        let mut embeds: Vec<Embed> = vec![];
-
        let mut actions: Option<PathBuf> = None;
-
        let mut from: Option<Rev> = None;
-
        let mut until: Option<Rev> = None;
-

-
        while let Some(arg) = parser.next()? {
-
            match (&op, &arg) {
-
                (_, Long("help") | Short('h')) => {
-
                    return Err(Error::Help.into());
-
                }
-
                (_, Long("repo") | Short('r')) => {
-
                    rid = Some(term::args::rid(&parser.value()?)?);
-
                }
-
                (_, Long("type") | Short('t')) => {
-
                    let v = string(&parser.value()?);
-
                    type_name = Some(FilteredTypeName::from(cob::TypeName::from_str(&v)?));
-
                }
-
                (Update | Log | Show, Long("object") | Short('o')) => {
-
                    let v = string(&parser.value()?);
-
                    oids.push(Rev::from(v));
-
                }
-
                (Update | Create, Long("message") | Short('m')) => {
-
                    message = Some(string(&parser.value()?));
-
                }
-
                (Log | Show | Update, Long("format")) => {
-
                    format = match (op, string(&parser.value()?).as_ref()) {
-
                        (Log, "pretty") => Format::Pretty,
-
                        (Log | Show | Update, "json") => Format::Json,
-
                        (_, unknown) => bail!("unknown format '{unknown}'"),
-
                    };
-
                }
-
                (Update | Create, Long("embed-file")) => {
-
                    let mut values = parser.values()?;
-

-
                    let name = values
-
                        .next()
-
                        .map(|s| term::args::string(&s))
-
                        .ok_or(anyhow!("expected name of embed"))?;
-

-
                    let content = EmbedContent::Path(PathBuf::from(
-
                        values
-
                            .next()
-
                            .ok_or(anyhow!("expected path to file to embed"))?,
-
                    ));
-

-
                    embeds.push(Embed { name, content });
-
                }
-
                (Update | Create, Long("embed-hash")) => {
-
                    let mut values = parser.values()?;

-
                    let name = values
-
                        .next()
-
                        .map(|s| term::args::string(&s))
-
                        .ok_or(anyhow!("expected name of embed"))?;
+
pub use args::Args;

-
                    let content = EmbedContent::Hash(Rev::from(term::args::string(
-
                        &values
-
                            .next()
-
                            .ok_or(anyhow!("expected hash of file to embed"))?,
-
                    )));
+
use args::{parse_many_embeds, FilteredTypeName, Format};

-
                    embeds.push(Embed { name, content });
-
                }
-
                (Update | Create, Value(val)) => {
-
                    actions = Some(PathBuf::from(term::args::string(val)));
-
                }
-
                (Log, Long("from")) => {
-
                    let v = parser.value()?;
-
                    from = Some(term::args::rev(&v)?);
-
                }
-
                (Log, Long("until")) => {
-
                    let v = parser.value()?;
-
                    until = Some(term::args::rev(&v)?);
-
                }
-
                _ => return Err(anyhow!(arg.unexpected())),
-
            }
-
        }
-

-
        if op == OperationName::Migrate {
-
            return Ok((
-
                Options {
-
                    op: Operation::Migrate,
-
                },
-
                vec![],
-
            ));
-
        }
-

-
        let rid = rid.ok_or_else(|| anyhow!("a repository id must be specified with `--repo`"))?;
-
        let type_name =
-
            type_name.ok_or_else(|| anyhow!("an object type must be specified with `--type`"))?;
-

-
        let missing_oid = || anyhow!("an object id must be specified with `--object`");
-
        let missing_message = || anyhow!("a message must be specified with `--message`");
-

-
        Ok((
-
            Options {
-
                op: match op {
-
                    Create => Operation::Create {
-
                        rid,
-
                        type_name,
-
                        message: message.ok_or_else(missing_message)?,
-
                        actions: actions.ok_or_else(|| {
-
                            anyhow!("a file containing initial actions must be specified")
-
                        })?,
-
                        embeds,
-
                    },
-
                    List => Operation::List { rid, type_name },
-
                    Log => Operation::Log {
-
                        rid,
-
                        type_name,
-
                        oid: oids.pop().ok_or_else(missing_oid)?,
-
                        format,
-
                        from,
-
                        until,
-
                    },
-
                    Migrate => Operation::Migrate,
-
                    Show => {
-
                        if oids.is_empty() {
-
                            return Err(missing_oid());
-
                        }
-
                        Operation::Show {
-
                            rid,
-
                            oids,
-
                            type_name,
-
                        }
-
                    }
-
                    Update => Operation::Update {
-
                        rid,
-
                        type_name,
-
                        oid: oids.pop().ok_or_else(missing_oid)?,
-
                        message: message.ok_or_else(missing_message)?,
-
                        actions: actions.ok_or_else(|| {
-
                            anyhow!("a file containing actions must be specified")
-
                        })?,
-
                        embeds,
-
                    },
-
                },
-
            },
-
            vec![],
-
        ))
-
    }
+
fn embeds(
+
    repo: &storage::git::Repository,
+
    files: Vec<String>,
+
    hashes: Vec<String>,
+
) -> anyhow::Result<Vec<cob::Embed<cob::Uri>>> {
+
    parse_many_embeds::<std::path::PathBuf>(&files)
+
        .chain(parse_many_embeds::<Rev>(&hashes))
+
        .map(|embed| embed.try_into_bytes(repo))
+
        .collect::<anyhow::Result<Vec<_>>>()
}

-
pub fn run(Options { op }: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
+
    use args::Command::*;
+
    use args::FilteredTypeName::*;
    use cob::store::Store;
-
    use FilteredTypeName::*;
-
    use Operation::*;

    let profile = ctx.profile()?;
    let storage = &profile.storage;

-
    match op {
-
        Create {
-
            rid,
+
    match args.command {
+
        Create(args::Create {
+
            repo,
            type_name,
-
            message,
-
            embeds,
-
            actions,
-
        } => {
+
            operation,
+
        }) => {
            let signer = &profile.signer()?;
-
            let repo = storage.repository_mut(rid)?;
-

-
            let reader = io::BufReader::new(fs::File::open(actions)?);
-

-
            let embeds = embeds
-
                .into_iter()
-
                .map(|embed| embed.try_into_bytes(&repo))
-
                .collect::<anyhow::Result<Vec<_>>>()?;
+
            let repo = storage.repository_mut(repo)?;
+
            let embeds = embeds(&repo, operation.embed_files, operation.embed_hashes)?;

            let oid = match type_name {
                Patch => {
                    let store: Store<cob::patch::Patch, _> = Store::open(&repo)?;
-
                    let actions = read_jsonl_actions(reader)?;
-
                    let (oid, _) = store.create(&message, actions, embeds, signer)?;
+
                    let actions = read_jsonl_actions(&operation.actions)?;
+
                    let (oid, _) = store.create(&operation.message, actions, embeds, signer)?;
                    oid
                }
                Issue => {
                    let store: Store<cob::issue::Issue, _> = Store::open(&repo)?;
-
                    let actions = read_jsonl_actions(reader)?;
-
                    let (oid, _) = store.create(&message, actions, embeds, signer)?;
+
                    let actions = read_jsonl_actions(&operation.actions)?;
+
                    let (oid, _) = store.create(&operation.message, actions, embeds, signer)?;
                    oid
                }
                Identity => anyhow::bail!(
@@ -413,8 +72,8 @@ pub fn run(Options { op }: Options, ctx: impl term::Context) -> anyhow::Result<(
                Other(type_name) => {
                    let store: Store<cob::external::External, _> =
                        Store::open_for(&type_name, &repo)?;
-
                    let actions = read_jsonl_actions(reader)?;
-
                    let (oid, _) = store.create(&message, actions, embeds, signer)?;
+
                    let actions = read_jsonl_actions(&operation.actions)?;
+
                    let (oid, _) = store.create(&operation.message, actions, embeds, signer)?;
                    oid
                }
            };
@@ -431,30 +90,33 @@ pub fn run(Options { op }: Options, ctx: impl term::Context) -> anyhow::Result<(
                );
            }
        }
-
        List { rid, type_name } => {
-
            let repo = storage.repository(rid)?;
-
            let cobs = radicle_cob::list::<NonEmpty<cob::Entry>, _>(&repo, type_name.as_ref())?;
+
        List { repo, type_name } => {
+
            let repo = storage.repository(repo)?;
+
            let cobs = radicle_cob::list::<NonEmpty<cob::Entry>, _>(
+
                &repo,
+
                FilteredTypeName::from(type_name).as_ref(),
+
            )?;
            for cob in cobs {
                println!("{}", cob.id);
            }
        }
        Log {
-
            rid,
+
            repo,
            type_name,
-
            oid,
+
            object,
            format,
            from,
            until,
        } => {
-
            let repo = storage.repository(rid)?;
-
            let oid = oid.resolve(&repo.backend)?;
+
            let repo = storage.repository(repo)?;
+
            let oid = object.resolve(&repo.backend)?;

            let from = from.map(|from| from.resolve(&repo.backend)).transpose()?;
            let until = until
                .map(|until| until.resolve(&repo.backend))
                .transpose()?;

-
            match type_name {
+
            match type_name.into() {
                Issue => operations::<cob::issue::Action>(
                    &cob::issue::TYPENAME,
                    oid,
@@ -485,12 +147,13 @@ pub fn run(Options { op }: Options, ctx: impl term::Context) -> anyhow::Result<(
            }
        }
        Show {
-
            rid,
-
            oids,
+
            repo,
+
            objects,
            type_name,
+
            format: _,
        } => {
-
            let repo = storage.repository(rid)?;
-
            if let Err(e) = show(oids, &repo, type_name, &profile) {
+
            let repo = storage.repository(repo)?;
+
            if let Err(e) = show(objects, &repo, type_name.into(), &profile) {
                if let Some(err) = e.downcast_ref::<std::io::Error>() {
                    if err.kind() == std::io::ErrorKind::BrokenPipe {
                        return Ok(());
@@ -499,39 +162,36 @@ pub fn run(Options { op }: Options, ctx: impl term::Context) -> anyhow::Result<(
                return Err(e);
            }
        }
-
        Update {
-
            rid,
+
        Update(args::Update {
+
            repo,
            type_name,
-
            oid,
-
            message,
-
            actions,
-
            embeds,
-
        } => {
+
            object,
+
            operation,
+
            format: _,
+
        }) => {
            let signer = &profile.signer()?;
-
            let repo = storage.repository_mut(rid)?;
-
            let reader = io::BufReader::new(fs::File::open(actions)?);
-
            let oid = &oid.resolve(&repo.backend)?;
-
            let embeds = embeds
-
                .into_iter()
-
                .map(|embed| embed.try_into_bytes(&repo))
-
                .collect::<anyhow::Result<Vec<_>>>()?;
+
            let repo = storage.repository_mut(repo)?;
+
            let oid = object.resolve::<radicle::git::Oid>(&repo.backend)?.into();
+
            let embeds = embeds(&repo, operation.embed_files, operation.embed_hashes)?;

            let oid = match type_name {
                Patch => {
-
                    let actions: Vec<cob::patch::Action> = read_jsonl(reader)?;
+
                    let actions: Vec<cob::patch::Action> =
+
                        read_jsonl_actions(&operation.actions)?.into();
                    let mut patches = profile.patches_mut(&repo)?;
-
                    let mut patch = patches.get_mut(oid)?;
-
                    patch.transaction(&message, &*profile.signer()?, |tx| {
+
                    let mut patch = patches.get_mut(&oid)?;
+
                    patch.transaction(&operation.message, &*profile.signer()?, |tx| {
                        tx.extend(actions)?;
                        tx.embed(embeds)?;
                        Ok(())
                    })?
                }
                Issue => {
-
                    let actions: Vec<cob::issue::Action> = read_jsonl(reader)?;
+
                    let actions: Vec<cob::issue::Action> =
+
                        read_jsonl_actions(&operation.actions)?.into();
                    let mut issues = profile.issues_mut(&repo)?;
-
                    let mut issue = issues.get_mut(oid)?;
-
                    issue.transaction(&message, &*profile.signer()?, |tx| {
+
                    let mut issue = issues.get_mut(&oid)?;
+
                    issue.transaction(&operation.message, &*profile.signer()?, |tx| {
                        tx.extend(actions)?;
                        tx.embed(embeds)?;
                        Ok(())
@@ -543,10 +203,10 @@ pub fn run(Options { op }: Options, ctx: impl term::Context) -> anyhow::Result<(
                ),
                Other(type_name) => {
                    use cob::external::{Action, External};
-
                    let actions: Vec<Action> = read_jsonl(reader)?;
+
                    let actions: Vec<Action> = read_jsonl_actions(&operation.actions)?.into();
                    let mut store: Store<External, _> = Store::open_for(&type_name, &repo)?;
                    let tx = cob::store::Transaction::new(type_name.clone(), actions, embeds);
-
                    let (_, oid) = tx.commit(&message, *oid, &mut store, signer)?;
+
                    let (_, oid) = tx.commit(&operation.message, oid, &mut store, signer)?;
                    oid
                }
            };
@@ -684,11 +344,12 @@ where

/// Tiny utility to read a [`NonEmpty`] of COB actions.
/// This is used for `rad cob create` and `rad cob update`.
-
fn read_jsonl_actions<R, A>(reader: io::BufReader<R>) -> anyhow::Result<NonEmpty<A>>
+
fn read_jsonl_actions<A>(path: impl AsRef<Path>) -> anyhow::Result<NonEmpty<A>>
where
-
    R: io::Read,
    A: CobAction + serde::de::DeserializeOwned,
{
+
    let reader = io::BufReader::new(std::fs::File::open(&path)?);
+

    NonEmpty::from_vec(read_jsonl(reader)?)
        .ok_or_else(|| anyhow!("at least one action is required"))
}
added crates/radicle-cli/src/commands/cob/args.rs
@@ -0,0 +1,417 @@
+
use std::fmt;
+
use std::fs;
+
use std::path::PathBuf;
+
use std::str::FromStr;
+

+
use thiserror::Error;
+

+
use clap::{Parser, Subcommand};
+

+
use radicle::cob;
+
use radicle::git;
+
use radicle::prelude::*;
+
use radicle::storage;
+

+
use crate::git::Rev;
+

+
#[derive(Parser, Debug)]
+
#[command(disable_version_flag = true)]
+
pub struct Args {
+
    #[command(subcommand)]
+
    pub(super) command: Command,
+
}
+

+
#[derive(Subcommand, Debug)]
+
pub(super) enum Command {
+
    /// Create a new COB of a given type given initial actions
+
    Create(#[clap(flatten)] Create),
+

+
    /// List all COBs of a given type
+
    List {
+
        /// Repository ID of the repository to operate on
+
        #[arg(long, short, value_name = "RID")]
+
        repo: RepoId,
+

+
        /// Typename of the object(s) to list
+
        #[arg(long = "type", short, value_name = "TYPENAME")]
+
        type_name: cob::TypeName,
+
    },
+

+
    /// Print a log of all raw operations on a COB
+
    Log {
+
        /// Tepository ID of the repository to operate on
+
        #[arg(long, short, value_name = "RID")]
+
        repo: RepoId,
+

+
        /// Typename of the object(s) to show
+
        #[arg(long = "type", short, value_name = "TYPENAME")]
+
        type_name: cob::TypeName,
+

+
        /// Object ID of the object to log
+
        #[arg(long, short, value_name = "OID")]
+
        object: Rev,
+

+
        /// Desired output format
+
        #[arg(long, default_value_t = Format::Pretty, value_parser = FormatParser)]
+
        format: Format,
+

+
        /// Object ID of the commit of the operation to start iterating at
+
        #[arg(long, value_name = "OID")]
+
        from: Option<Rev>,
+

+
        /// Object ID of the commit of the operation to stop iterating at
+
        #[arg(long, value_name = "OID")]
+
        until: Option<Rev>,
+
    },
+

+
    /// Migrate the COB database to the latest version
+
    Migrate,
+

+
    /// Print the state of COBs
+
    Show {
+
        /// Repository ID of the repository to operate on
+
        #[arg(long, short, value_name = "RID")]
+
        repo: RepoId,
+

+
        /// Typename of the object(s) to show
+
        #[arg(long = "type", short, value_name = "TYPENAME")]
+
        type_name: cob::TypeName,
+

+
        /// Object ID(s) of the objects to show
+
        #[arg(long = "object", short, value_name = "OID", action = clap::ArgAction::Append, required = true)]
+
        objects: Vec<Rev>,
+

+
        /// Desired output format
+
        #[arg(long, default_value_t = Format::Json, value_parser = FormatParser)]
+
        format: Format,
+
    },
+

+
    /// Add actions to a COB
+
    Update(#[clap(flatten)] Update),
+
}
+

+
#[derive(Parser, Debug)]
+
pub(super) struct Operation {
+
    /// Message describing the operation
+
    #[arg(long, short)]
+
    pub(super) message: String,
+

+
    /// Supply embed of given name via file at given path
+
    #[arg(long = "embed-file", value_names = ["NAME", "PATH"], num_args = 2)]
+
    pub(super) embed_files: Vec<String>,
+

+
    /// Supply embed of given name via object ID of blob
+
    #[arg(long = "embed-hash", value_names = ["NAME", "OID"], num_args = 2)]
+
    pub(super) embed_hashes: Vec<String>,
+

+
    /// A file that contains a sequence actions (in JSONL format) to apply.
+
    #[arg(value_name = "FILENAME")]
+
    pub(super) actions: PathBuf,
+
}
+

+
#[derive(Parser, Debug)]
+
pub(super) struct Create {
+
    /// Repository ID of the repository to operate on
+
    #[arg(long, short, value_name = "RID")]
+
    pub(super) repo: RepoId,
+

+
    /// Typename of the object to create
+
    #[arg(long = "type", short, value_name = "TYPENAME")]
+
    pub(super) type_name: FilteredTypeName,
+

+
    #[clap(flatten)]
+
    pub(super) operation: Operation,
+
}
+

+
#[derive(Parser, Debug)]
+
pub(super) struct Update {
+
    /// Repository ID of the repository to operate on
+
    #[arg(long, short)]
+
    pub(super) repo: RepoId,
+

+
    /// Typename of the object to update
+
    #[arg(long = "type", short, value_name = "TYPENAME")]
+
    pub(super) type_name: FilteredTypeName,
+

+
    /// Object ID of the object to update
+
    #[arg(long, short, value_name = "OID")]
+
    pub(super) object: Rev,
+

+
    // TODO(finto): `Format` is unused and is obsolete for this command
+
    /// Desired output format
+
    #[arg(long, default_value_t = Format::Json, value_parser = FormatParser)]
+
    pub(super) format: Format,
+

+
    #[clap(flatten)]
+
    pub(super) operation: Operation,
+
}
+

+
/// A precursor to [`cob::Embed`] used for parsing
+
/// that can be initialized without relying on a [`git::Repository`].
+
#[derive(Clone, Debug)]
+
pub(super) struct Embed {
+
    name: String,
+
    content: EmbedContent,
+
}
+

+
impl Embed {
+
    pub(super) fn try_into_bytes(
+
        self,
+
        repo: &storage::git::Repository,
+
    ) -> anyhow::Result<cob::Embed<cob::Uri>> {
+
        Ok(match self.content {
+
            EmbedContent::Hash(hash) => cob::Embed {
+
                name: self.name,
+
                content: hash.resolve::<git::Oid>(&repo.backend)?.into(),
+
            },
+
            EmbedContent::Path(path) => {
+
                cob::Embed::store(self.name, &fs::read(path)?, &repo.backend)?
+
            }
+
        })
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub(super) enum EmbedContent {
+
    Path(PathBuf),
+
    Hash(Rev),
+
}
+

+
impl From<PathBuf> for EmbedContent {
+
    fn from(path: PathBuf) -> Self {
+
        EmbedContent::Path(path)
+
    }
+
}
+

+
impl From<Rev> for EmbedContent {
+
    fn from(rev: Rev) -> Self {
+
        EmbedContent::Hash(rev)
+
    }
+
}
+

+
/// Parses a slice of all embeds as name-path or name-oid pairs as aggregated by
+
/// `clap`.
+
/// E.g. `["image", "./image.png", "code", "d87dcfe8c2b3200e78b128d9b959cfdf7063fefe"]`
+
/// will result a `Vec` of two [`Embed`]s.
+
///
+
/// # Panics
+
///
+
/// If the length of `values` is not divisible by 2.
+
pub(super) fn parse_many_embeds<T>(values: &[String]) -> impl Iterator<Item = Embed> + use<'_, T>
+
where
+
    T: From<String>,
+
    EmbedContent: From<T>,
+
{
+
    // `clap` ensures we have 2 values per option occurrence,
+
    // so we can chunk the aggregated slice exactly.
+
    let chunks = values.chunks_exact(2);
+

+
    assert!(chunks.remainder().is_empty());
+

+
    chunks.map(|chunk| {
+
        // Slice accesses will not panic, guaranteed by `chunks_exact(2)`.
+
        Embed {
+
            name: chunk[0].to_string(),
+
            content: EmbedContent::from(T::from(chunk[1].clone())),
+
        }
+
    })
+
}
+

+
#[derive(Clone, Debug, PartialEq)]
+
pub(super) enum Format {
+
    Json,
+
    Pretty,
+
}
+

+
impl fmt::Display for Format {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        match self {
+
            Format::Json => f.write_str("json"),
+
            Format::Pretty => f.write_str("pretty"),
+
        }
+
    }
+
}
+

+
#[non_exhaustive]
+
#[derive(Debug, Error)]
+
#[error("invalid format value: {0:?}")]
+
pub struct FormatParseError(String);
+

+
impl FromStr for Format {
+
    type Err = FormatParseError;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        match s {
+
            "json" => Ok(Self::Json),
+
            "pretty" => Ok(Self::Pretty),
+
            _ => Err(FormatParseError(s.to_string())),
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
struct FormatParser;
+

+
impl clap::builder::TypedValueParser for FormatParser {
+
    type Value = Format;
+

+
    fn parse_ref(
+
        &self,
+
        cmd: &clap::Command,
+
        arg: Option<&clap::Arg>,
+
        value: &std::ffi::OsStr,
+
    ) -> Result<Self::Value, clap::Error> {
+
        use clap::error::ErrorKind;
+

+
        let format = <Format as std::str::FromStr>::from_str.parse_ref(cmd, arg, value)?;
+
        match cmd.get_name() {
+
            "show" | "update" if format == Format::Pretty => Err(clap::Error::raw(
+
                ErrorKind::ValueValidation,
+
                format!("output format `{format}` is not allowed in this command"),
+
            )
+
            .with_cmd(cmd)),
+
            _ => Ok(format),
+
        }
+
    }
+

+
    fn possible_values(
+
        &self,
+
    ) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
+
        use clap::builder::PossibleValue;
+
        Some(Box::new(
+
            [PossibleValue::new("json"), PossibleValue::new("pretty")].into_iter(),
+
        ))
+
    }
+
}
+

+
/// A thin wrapper around [`cob::TypeName`] used for parsing.
+
/// Well known COB type names are captured as variants,
+
/// with [`FilteredTypeName::Other`] as an escape hatch for type names
+
/// that are not well known.
+
#[derive(Clone, Debug)]
+
pub(super) enum FilteredTypeName {
+
    Issue,
+
    Patch,
+
    Identity,
+
    Other(cob::TypeName),
+
}
+

+
impl AsRef<cob::TypeName> for FilteredTypeName {
+
    fn as_ref(&self) -> &cob::TypeName {
+
        match self {
+
            FilteredTypeName::Issue => &cob::issue::TYPENAME,
+
            FilteredTypeName::Patch => &cob::patch::TYPENAME,
+
            FilteredTypeName::Identity => &cob::identity::TYPENAME,
+
            FilteredTypeName::Other(value) => value,
+
        }
+
    }
+
}
+

+
impl From<cob::TypeName> for FilteredTypeName {
+
    fn from(value: cob::TypeName) -> Self {
+
        if value == *cob::issue::TYPENAME {
+
            FilteredTypeName::Issue
+
        } else if value == *cob::patch::TYPENAME {
+
            FilteredTypeName::Patch
+
        } else if value == *cob::identity::TYPENAME {
+
            FilteredTypeName::Identity
+
        } else {
+
            FilteredTypeName::Other(value)
+
        }
+
    }
+
}
+

+
impl std::fmt::Display for FilteredTypeName {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        self.as_ref().fmt(f)
+
    }
+
}
+

+
impl std::str::FromStr for FilteredTypeName {
+
    type Err = cob::TypeNameParse;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        Ok(Self::from(s.parse::<cob::TypeName>()?))
+
    }
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use super::Args;
+
    use clap::error::ErrorKind;
+
    use clap::Parser;
+

+
    const ARGS: &[&str] = &[
+
        "--repo",
+
        "rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH",
+
        "--type",
+
        "xyz.radicle.issue",
+
        "--object",
+
        "f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354",
+
    ];
+

+
    #[test]
+
    fn should_allow_log_json_format() {
+
        let args = Args::try_parse_from(
+
            ["cob", "log", "--format", "json"]
+
                .iter()
+
                .chain(ARGS.iter())
+
                .collect::<Vec<_>>(),
+
        );
+
        assert!(args.is_ok())
+
    }
+

+
    #[test]
+
    fn should_allow_log_pretty_format() {
+
        let args = Args::try_parse_from(
+
            ["cob", "log", "--format", "pretty"]
+
                .iter()
+
                .chain(ARGS.iter())
+
                .collect::<Vec<_>>(),
+
        );
+
        assert!(args.is_ok())
+
    }
+

+
    #[test]
+
    fn should_allow_show_json_format() {
+
        let args = Args::try_parse_from(
+
            ["cob", "show", "--format", "json"]
+
                .iter()
+
                .chain(ARGS.iter())
+
                .collect::<Vec<_>>(),
+
        );
+
        assert!(args.is_ok())
+
    }
+

+
    #[test]
+
    fn should_allow_update_json_format() {
+
        let args = Args::try_parse_from(
+
            [
+
                "cob",
+
                "update",
+
                "--format",
+
                "json",
+
                "--message",
+
                "",
+
                "/dev/null",
+
            ]
+
            .iter()
+
            .chain(ARGS.iter())
+
            .collect::<Vec<_>>(),
+
        );
+
        println!("{args:?}");
+
        assert!(args.is_ok())
+
    }
+

+
    #[test]
+
    fn should_not_allow_show_pretty_format() {
+
        let err = Args::try_parse_from(["cob", "show", "--format", "pretty"]).unwrap_err();
+
        assert_eq!(err.kind(), ErrorKind::ValueValidation);
+
    }
+

+
    #[test]
+
    fn should_not_allow_update_pretty_format() {
+
        let err = Args::try_parse_from(["cob", "update", "--format", "pretty"]).unwrap_err();
+
        assert_eq!(err.kind(), ErrorKind::ValueValidation);
+
    }
+
}
modified crates/radicle-cli/src/commands/config.rs
@@ -1,152 +1,29 @@
-
#![allow(clippy::or_fun_call)]
-
use std::ffi::OsString;
+
mod args;
+

+
pub use args::Args;
+
use args::Command;
+

use std::path::Path;
-
use std::str::FromStr;

-
use anyhow::anyhow;
-
use radicle::node::Alias;
use radicle::profile::{config, Config, ConfigPath, RawConfig};

use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
use crate::terminal::Element as _;

-
pub const HELP: Help = Help {
-
    name: "config",
-
    description: "Manage your local Radicle configuration",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad config [<option>...]
-
    rad config show [<option>...]
-
    rad config init --alias <alias> [<option>...]
-
    rad config edit [<option>...]
-
    rad config get <key> [<option>...]
-
    rad config schema [<option>...]
-
    rad config set <key> <value> [<option>...]
-
    rad config unset <key> [<option>...]
-
    rad config push <key> <value> [<option>...]
-
    rad config remove <key> <value> [<option>...]
-

-
    If no argument is specified, prints the current radicle configuration as JSON.
-
    To initialize a new configuration file, use `rad config init`.
-

-
Options
-

-
    --help    Print help
-

-
"#,
-
};
-

-
#[derive(Default)]
-
enum Operation {
-
    #[default]
-
    Show,
-
    Get(String),
-
    Schema,
-
    Set(String, String),
-
    Push(String, String),
-
    Remove(String, String),
-
    Unset(String),
-
    Init,
-
    Edit,
-
}
-

-
pub struct Options {
-
    op: Operation,
-
    alias: Option<Alias>,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut op: Option<Operation> = None;
-
        let mut alias = None;
-

-
        #[allow(clippy::never_loop)]
-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Long("alias") => {
-
                    let value = parser.value()?;
-
                    let input = value.to_string_lossy();
-
                    let input = Alias::from_str(&input)?;
-

-
                    alias = Some(input);
-
                }
-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "show" => op = Some(Operation::Show),
-
                    "schema" => op = Some(Operation::Schema),
-
                    "edit" => op = Some(Operation::Edit),
-
                    "init" => op = Some(Operation::Init),
-
                    "get" => {
-
                        let key = parser.value()?;
-
                        let key = key.to_string_lossy();
-
                        op = Some(Operation::Get(key.to_string()));
-
                    }
-
                    "set" => {
-
                        let key = parser.value()?;
-
                        let key = key.to_string_lossy();
-
                        let value = parser.value()?;
-
                        let value = value.to_string_lossy();
-

-
                        op = Some(Operation::Set(key.to_string(), value.to_string()));
-
                    }
-
                    "push" => {
-
                        let key = parser.value()?;
-
                        let key = key.to_string_lossy();
-
                        let value = parser.value()?;
-
                        let value = value.to_string_lossy();
-

-
                        op = Some(Operation::Push(key.to_string(), value.to_string()));
-
                    }
-
                    "remove" => {
-
                        let key = parser.value()?;
-
                        let key = key.to_string_lossy();
-
                        let value = parser.value()?;
-
                        let value = value.to_string_lossy();
-

-
                        op = Some(Operation::Remove(key.to_string(), value.to_string()));
-
                    }
-
                    "unset" => {
-
                        let key = parser.value()?;
-
                        let key = key.to_string_lossy();
-
                        op = Some(Operation::Unset(key.to_string()));
-
                    }
-
                    unknown => anyhow::bail!("unknown operation '{unknown}'"),
-
                },
-
                _ => return Err(anyhow!(arg.unexpected())),
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                op: op.unwrap_or_default(),
-
                alias,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let home = ctx.home()?;
    let path = home.config();
+
    let command = args.command.unwrap_or(Command::Show);

-
    match options.op {
-
        Operation::Show => {
+
    match command {
+
        Command::Show => {
            let profile = ctx.profile()?;
            term::json::to_pretty(&profile.config, path.as_path())?.print();
        }
-
        Operation::Schema => {
-
            term::json::to_pretty(&schemars::schema_for!(Config), path.as_path())?.print();
+
        Command::Schema => {
+
            term::json::to_pretty(&schemars::schema_for!(Config), path.as_path())?.print()
        }
-
        Operation::Get(key) => {
+
        Command::Get { key } => {
            let mut temp_config = RawConfig::from_file(&path)?;
            let key: ConfigPath = key.into();
            let value = temp_config.get_mut(&key).ok_or_else(|| {
@@ -154,38 +31,33 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            })?;
            print_value(value)?;
        }
-
        Operation::Set(key, value) => {
+
        Command::Set { key, value } => {
            let value = modify(path, |tmp| tmp.set(&key.into(), value.into()))?;
            print_value(&value)?;
        }
-
        Operation::Push(key, value) => {
+
        Command::Push { key, value } => {
            let value = modify(path, |tmp| tmp.push(&key.into(), value.into()))?;
            print_value(&value)?;
        }
-
        Operation::Remove(key, value) => {
+
        Command::Remove { key, value } => {
            let value = modify(path, |tmp| tmp.remove(&key.into(), value.into()))?;
            print_value(&value)?;
        }
-
        Operation::Unset(key) => {
+
        Command::Unset { key } => {
            let value = modify(path, |tmp| tmp.unset(&key.into()))?;
            print_value(&value)?;
        }
-
        Operation::Init => {
+
        Command::Init { alias } => {
            if path.try_exists()? {
                anyhow::bail!("configuration file already exists at `{}`", path.display());
            }
-
            Config::init(
-
                options.alias.ok_or(anyhow!(
-
                    "an alias must be provided to initialize a new configuration"
-
                ))?,
-
                &path,
-
            )?;
+
            Config::init(alias, &path)?;
            term::success!(
                "Initialized new Radicle configuration at {}",
                path.display()
            );
        }
-
        Operation::Edit => match term::editor::Editor::new(&path)?.extension("json").edit()? {
+
        Command::Edit => match term::editor::Editor::new(&path)?.extension("json").edit()? {
            Some(_) => {
                term::success!("Successfully made changes to the configuration at {path:?}")
            }
added crates/radicle-cli/src/commands/config/args.rs
@@ -0,0 +1,68 @@
+
use clap::{Parser, Subcommand};
+
use radicle::node::Alias;
+

+
const ABOUT: &str = "Manage your local Radicle configuration";
+

+
const LONG_ABOUT: &str = r#"
+
If no argument is specified, prints the current radicle configuration as JSON.
+
To initialize a new configuration file, use `rad config init`.
+
"#;
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    #[command(subcommand)]
+
    pub(crate) command: Option<Command>,
+
}
+

+
#[derive(Subcommand, Debug)]
+
#[group(multiple = false)]
+
pub(crate) enum Command {
+
    /// Show the current radicle configuration as JSON (default)
+
    Show,
+
    /// Initialize a new config file
+
    Init {
+
        /// Alias to use for the new configuration
+
        #[arg(long)]
+
        alias: Alias,
+
    },
+
    /// Open the config in your editor
+
    Edit,
+
    /// Get a value from the current configuration
+
    Get {
+
        /// The JSON key path to the value you want to get
+
        key: String,
+
    },
+
    /// Prints the JSON Schema of the Radicle configuration
+
    Schema,
+
    /// Set a key to a value in the current configuration
+
    Set {
+
        /// The JSON key path to the value you want to set
+
        key: String,
+
        /// The JSON value used to set the field
+
        value: String,
+
    },
+
    /// Set a key in the current configuration to `null`
+
    Unset {
+
        /// The JSON key path to the value you want to unset
+
        key: String,
+
    },
+
    /// Push a value onto an array, which is identified by the key, in the
+
    /// current configuration
+
    Push {
+
        /// The JSON key path to the array you want to push to
+
        key: String,
+
        /// The JSON value being pushed onto the array
+
        value: String,
+
    },
+
    /// Remove a value from an array, which is identified by the key, in the
+
    /// current configuration
+
    ///
+
    /// All instances of the value in the array will be removed
+
    Remove {
+
        /// The JSON key path to the array you want to push to
+
        key: String,
+
        /// The JSON value being pushed onto the array
+
        value: String,
+
    },
+
}
modified crates/radicle-cli/src/commands/debug.rs
@@ -1,7 +1,7 @@
-
#![allow(clippy::or_fun_call)]
+
mod args;
+

use std::collections::BTreeMap;
use std::env;
-
use std::ffi::OsString;
use std::path::PathBuf;
use std::process::Command;

@@ -11,40 +11,15 @@ use serde::Serialize;
use radicle::Profile;

use crate::terminal as term;
-
use crate::terminal::args::{Args, Help};
+

+
pub use args::Args;

pub const NAME: &str = "rad";
pub const VERSION: &str = env!("RADICLE_VERSION");
pub const DESCRIPTION: &str = "Radicle command line interface";
pub const GIT_HEAD: &str = env!("GIT_HEAD");

-
pub const HELP: Help = Help {
-
    name: "debug",
-
    description: "Write out information to help debug your Radicle node remotely",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad debug
-

-
    Run this if you are reporting a problem in Radicle. The output is
-
    helpful for Radicle developers to debug your problem remotely. The
-
    output is meant to not include any sensitive information, but
-
    please check it, and then forward to the Radicle developers.
-

-
"#,
-
};
-

-
#[derive(Debug)]
-
pub struct Options {}
-

-
impl Args for Options {
-
    fn from_args(_args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        Ok((Options {}, vec![]))
-
    }
-
}
-

-
pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(_args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    match ctx.profile() {
        Ok(profile) => debug(Some(&profile)),
        Err(e) => {
added crates/radicle-cli/src/commands/debug/args.rs
@@ -0,0 +1,13 @@
+
use clap::Parser;
+

+
const ABOUT: &str = "Write out information to help debug your Radicle node remotely";
+

+
const LONG_ABOUT: &str = r#"
+
Run this if you are reporting a problem in Radicle. The output is
+
helpful for Radicle developers to debug your problem remotely. The
+
output is meant to not include any sensitive information, but
+
please check it, and then forward to the Radicle developers."#;
+

+
#[derive(Parser, Debug)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {}
modified crates/radicle-cli/src/commands/diff.rs
@@ -1,151 +1,14 @@
-
use std::ffi::OsString;
+
use std::{ffi::OsString, process};

-
use anyhow::anyhow;
+
pub fn run(args: Vec<OsString>) -> anyhow::Result<()> {
+
    crate::warning::deprecated("rad diff", "git diff");

-
use radicle::git;
-
use radicle::rad;
-
use radicle_surf as surf;
+
    let mut child = process::Command::new("git")
+
        .arg("diff")
+
        .args(args)
+
        .spawn()?;

-
use crate::git::pretty_diff::ToPretty as _;
-
use crate::git::Rev;
-
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
-
use crate::terminal::highlight::Highlighter;
+
    let exit_status = child.wait()?;

-
pub const HELP: Help = Help {
-
    name: "diff",
-
    description: "Show changes between commits",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad diff [<commit>] [--staged] [<option>...]
-
    rad diff <commit> [<commit>] [<option>...]
-

-
    This command is meant to operate as closely as possible to `git diff`,
-
    except its output is optimized for human-readability.
-

-
Options
-

-
    --unified, -U   Context lines to show (default: 5)
-
    --staged        View staged changes
-
    --color         Force color output
-
    --help          Print help
-
"#,
-
};
-

-
pub struct Options {
-
    pub commits: Vec<Rev>,
-
    pub staged: bool,
-
    pub unified: usize,
-
    pub color: bool,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut commits = Vec::new();
-
        let mut staged = false;
-
        let mut unified = 5;
-
        let mut color = false;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("unified") | Short('U') => {
-
                    let val = parser.value()?;
-
                    unified = term::args::number(&val)?;
-
                }
-
                Long("staged") | Long("cached") => staged = true,
-
                Long("color") => color = true,
-
                Long("help") | Short('h') => return Err(Error::Help.into()),
-
                Value(val) => {
-
                    let rev = term::args::rev(&val)?;
-

-
                    commits.push(rev);
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                commits,
-
                staged,
-
                unified,
-
                color,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
pub fn run(options: Options, _ctx: impl term::Context) -> anyhow::Result<()> {
-
    let repo = rad::repo()?;
-
    let oids = options
-
        .commits
-
        .into_iter()
-
        .map(|rev| {
-
            repo.revparse_single(rev.as_str())
-
                .map_err(|e| anyhow!("unknown object {rev}: {e}"))
-
                .and_then(|o| {
-
                    o.into_commit()
-
                        .map_err(|_| anyhow!("object {rev} is not a commit"))
-
                })
-
        })
-
        .collect::<Result<Vec<_>, _>>()?;
-

-
    let mut opts = git::raw::DiffOptions::new();
-
    opts.patience(true)
-
        .minimal(true)
-
        .context_lines(options.unified as u32);
-

-
    let mut find_opts = git::raw::DiffFindOptions::new();
-
    find_opts.exact_match_only(true);
-
    find_opts.all(true);
-

-
    let mut diff = match oids.as_slice() {
-
        [] => {
-
            if options.staged {
-
                let head = repo.head()?.peel_to_tree()?;
-
                // HEAD vs. index.
-
                repo.diff_tree_to_index(Some(&head), None, Some(&mut opts))
-
            } else {
-
                // Working tree vs. index.
-
                repo.diff_index_to_workdir(None, None)
-
            }
-
        }
-
        [commit] => {
-
            let commit = commit.tree()?;
-
            if options.staged {
-
                // Commit vs. index.
-
                repo.diff_tree_to_index(Some(&commit), None, Some(&mut opts))
-
            } else {
-
                // Commit vs. working tree.
-
                repo.diff_tree_to_workdir(Some(&commit), Some(&mut opts))
-
            }
-
        }
-
        [left, right] => {
-
            // Commit vs. commit.
-
            let left = left.tree()?;
-
            let right = right.tree()?;
-

-
            repo.diff_tree_to_tree(Some(&left), Some(&right), Some(&mut opts))
-
        }
-
        _ => {
-
            anyhow::bail!("Too many commits given. See `rad diff --help` for usage.");
-
        }
-
    }?;
-
    diff.find_similar(Some(&mut find_opts))?;
-

-
    term::Paint::force(options.color);
-

-
    let diff = surf::diff::Diff::try_from(diff)?;
-
    let mut hi = Highlighter::default();
-
    let pretty = diff.pretty(&mut hi, &(), &repo);
-

-
    crate::pager::run(pretty)?;
-

-
    Ok(())
+
    process::exit(exit_status.code().unwrap_or(1));
}
modified crates/radicle-cli/src/commands/follow.rs
@@ -1,106 +1,25 @@
-
use std::ffi::OsString;
-

-
use anyhow::anyhow;
+
mod args;

use radicle::node::{policy, Alias, AliasStore, Handle, NodeId};
use radicle::{prelude::*, Node};
use radicle_term::{Element as _, Paint, Table};

use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
-

-
pub const HELP: Help = Help {
-
    name: "follow",
-
    description: "Manage node follow policies",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad follow [<nid>] [--alias <name>] [<option>...]
-

-
    The `follow` command will print all nodes being followed, optionally filtered by alias, if no
-
    Node ID is provided.
-
    Otherwise, it takes a Node ID, optionally in DID format, and updates the follow policy
-
    for that peer, optionally giving the peer the alias provided.
-

-
Options
-

-
    --alias <name>         Associate an alias to a followed peer
-
    --verbose, -v          Verbose output
-
    --help                 Print help
-
"#,
-
};
-

-
#[derive(Debug)]
-
pub enum Operation {
-
    Follow { nid: NodeId, alias: Option<Alias> },
-
    List { alias: Option<Alias> },
-
}
-

-
#[derive(Debug, Default)]
-
pub enum OperationName {
-
    Follow,
-
    #[default]
-
    List,
-
}
-

-
#[derive(Debug)]
-
pub struct Options {
-
    pub op: Operation,
-
    pub verbose: bool,
-
}

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut verbose = false;
-
        let mut nid: Option<NodeId> = None;
-
        let mut alias: Option<Alias> = None;
-

-
        while let Some(arg) = parser.next()? {
-
            match &arg {
-
                Value(val) if nid.is_none() => {
-
                    if let Ok(did) = term::args::did(val) {
-
                        nid = Some(did.into());
-
                    } else if let Ok(val) = term::args::nid(val) {
-
                        nid = Some(val);
-
                    } else {
-
                        anyhow::bail!("invalid Node ID `{}` specified", val.to_string_lossy());
-
                    }
-
                }
-
                Long("alias") if alias.is_none() => {
-
                    let name = parser.value()?;
-
                    let name = term::args::alias(&name)?;
-

-
                    alias = Some(name.to_owned());
-
                }
-
                Long("verbose") | Short('v') => verbose = true,
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                _ => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
-

-
        let op = match nid {
-
            Some(nid) => Operation::Follow { nid, alias },
-
            None => Operation::List { alias },
-
        };
-
        Ok((Options { op, verbose }, vec![]))
-
    }
-
}
+
pub use args::Args;
+
use args::Operation;

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let mut node = radicle::Node::new(profile.socket());

-
    match options.op {
-
        Operation::Follow { nid, alias } => follow(nid, alias, &mut node, &profile)?,
-
        Operation::List { alias } => following(&profile, alias)?,
+
    match Operation::from(args) {
+
        Operation::Follow {
+
            nid,
+
            alias,
+
            verbose: _,
+
        } => follow(nid, alias, &mut node, &profile)?,
+
        Operation::List { alias, verbose: _ } => following(&profile, alias)?,
    }

    Ok(())
added crates/radicle-cli/src/commands/follow/args.rs
@@ -0,0 +1,63 @@
+
use clap::Parser;
+

+
use radicle::node::{Alias, NodeId};
+

+
use crate::terminal as term;
+

+
const ABOUT: &str = "Manage node follow policies";
+

+
const LONG_ABOUT: &str = r#"
+
The `follow` command will print all nodes being followed, optionally filtered by alias, if no
+
Node ID is provided.
+
Otherwise, it takes a Node ID, optionally in DID format, and updates the follow policy
+
for that peer, optionally giving the peer the alias provided.
+
"#;
+

+
#[derive(Parser, Debug)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    /// The DID or Node ID of the peer to follow
+
    #[arg(value_parser = term::args::parse_nid)]
+
    nid: Option<NodeId>,
+

+
    /// Associate an alias to a followed peer
+
    #[arg(long)]
+
    alias: Option<Alias>,
+

+
    /// Verbose output
+
    #[arg(long, short)]
+
    verbose: bool,
+
}
+

+
pub(super) enum Operation {
+
    Follow {
+
        nid: NodeId,
+
        alias: Option<Alias>,
+
        #[allow(dead_code)]
+
        verbose: bool,
+
    },
+
    List {
+
        alias: Option<Alias>,
+
        #[allow(dead_code)]
+
        verbose: bool,
+
    },
+
}
+

+
impl From<Args> for Operation {
+
    fn from(
+
        Args {
+
            nid,
+
            alias,
+
            verbose,
+
        }: Args,
+
    ) -> Self {
+
        match nid {
+
            Some(nid) => Self::Follow {
+
                nid,
+
                alias,
+
                verbose,
+
            },
+
            None => Self::List { alias, verbose },
+
        }
+
    }
+
}
modified crates/radicle-cli/src/commands/fork.rs
@@ -1,66 +1,22 @@
-
use std::ffi::OsString;
+
mod args;

use anyhow::Context as _;

-
use radicle::prelude::RepoId;
use radicle::rad;

use crate::terminal as term;
-
use crate::terminal::args;
-
use crate::terminal::args::{Args, Error, Help};

-
pub const HELP: Help = Help {
-
    name: "fork",
-
    description: "Create a fork of a repository",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
+
pub use args::Args;

-
    rad fork [<rid>] [<option>...]
-

-
Options
-

-
    --help          Print help
-
"#,
-
};
-

-
pub struct Options {
-
    rid: Option<RepoId>,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut rid = None;
-

-
        if let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Value(val) if rid.is_none() => {
-
                    rid = Some(args::rid(&val)?);
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        Ok((Options { rid }, vec![]))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let signer = profile.signer()?;
    let storage = &profile.storage;

-
    let rid = match options.rid {
+
    let rid = match args.rid {
        Some(rid) => rid,
        None => {
-
            let (_, rid) =
-
                radicle::rad::cwd().context("Current directory is not a Radicle repository")?;
+
            let (_, rid) = rad::cwd().context("Current directory is not a Radicle repository")?;

            rid
        }
added crates/radicle-cli/src/commands/fork/args.rs
@@ -0,0 +1,39 @@
+
use radicle::identity::RepoId;
+

+
const ABOUT: &str = "Create a fork of a repository";
+

+
#[derive(Debug, clap::Parser)]
+
#[command(about = ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    /// The Repository ID of the repository to fork
+
    ///
+
    /// [example values: rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH, z3Tr6bC7ctEg2EHmLvknUr29mEDLH]
+
    #[arg(value_name = "RID")]
+
    pub(super) rid: Option<RepoId>,
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use super::Args;
+
    use clap::error::ErrorKind;
+
    use clap::Parser;
+

+
    #[test]
+
    fn should_parse_rid_non_urn() {
+
        let args = Args::try_parse_from(["fork", "z3Tr6bC7ctEg2EHmLvknUr29mEDLH"]);
+
        assert!(args.is_ok())
+
    }
+

+
    #[test]
+
    fn should_parse_rid_urn() {
+
        let args = Args::try_parse_from(["fork", "rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH"]);
+
        assert!(args.is_ok())
+
    }
+

+
    #[test]
+
    fn should_not_parse_rid_url() {
+
        let err =
+
            Args::try_parse_from(["fork", "rad://z3Tr6bC7ctEg2EHmLvknUr29mEDLH"]).unwrap_err();
+
        assert_eq!(err.kind(), ErrorKind::ValueValidation);
+
    }
+
}
deleted crates/radicle-cli/src/commands/help.rs
@@ -1,103 +0,0 @@
-
use std::ffi::OsString;
-

-
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
-

-
use super::*;
-

-
pub const HELP: Help = Help {
-
    name: "help",
-
    description: "CLI help",
-
    version: env!("RADICLE_VERSION"),
-
    usage: "Usage: rad help [--help]",
-
};
-

-
const COMMANDS: &[Help] = &[
-
    rad_auth::HELP,
-
    rad_block::HELP,
-
    rad_checkout::HELP,
-
    rad_clone::HELP,
-
    rad_config::HELP,
-
    rad_fork::HELP,
-
    rad_help::HELP,
-
    rad_id::HELP,
-
    rad_init::HELP,
-
    rad_inbox::HELP,
-
    rad_inspect::HELP,
-
    rad_issue::HELP,
-
    rad_ls::HELP,
-
    rad_node::HELP,
-
    rad_patch::HELP,
-
    rad_path::HELP,
-
    rad_clean::HELP,
-
    rad_self::HELP,
-
    rad_seed::HELP,
-
    rad_follow::HELP,
-
    rad_unblock::HELP,
-
    rad_unfollow::HELP,
-
    rad_unseed::HELP,
-
    rad_remote::HELP,
-
    rad_stats::HELP,
-
    rad_sync::HELP,
-
    rad_profile::HELP
-
];
-

-
#[derive(Default)]
-
pub struct Options {}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        let mut parser = lexopt::Parser::from_args(args);
-

-
        if let Some(arg) = parser.next()? {
-
            anyhow::bail!(arg.unexpected());
-
        }
-
        Err(Error::HelpManual { name: "rad" }.into())
-
    }
-
}
-

-
pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
-
    term::print("Usage: rad <command> [--help]");
-

-
    if let Err(e) = ctx.profile() {
-
        term::blank();
-
        match e.downcast_ref() {
-
            Some(term::args::Error::WithHint { err, hint }) => {
-
                term::print(term::format::yellow(err));
-
                term::print(term::format::yellow(hint));
-
            }
-
            Some(e) => {
-
                term::error(e);
-
            }
-
            None => {
-
                term::error(e);
-
            }
-
        }
-
        term::blank();
-
    }
-

-
    term::print("Common `rad` commands used in various situations:");
-
    term::blank();
-

-
    for help in COMMANDS {
-
        term::info!(
-
            "\t{} {}",
-
            term::format::bold(format!("{:-12}", help.name)),
-
            term::format::dim(help.description)
-
        );
-
    }
-
    term::blank();
-
    term::print("See `rad <command> --help` to learn about a specific command.");
-
    term::blank();
-

-
    term::print("Do you have feedback?");
-
    term::print(
-
        " - Chat <\x1b]8;;https://radicle.zulipchat.com\x1b\\radicle.zulipchat.com\x1b]8;;\x1b\\>",
-
    );
-
    term::print(
-
        " - Mail <\x1b]8;;mailto:feedback@radicle.xyz\x1b\\feedback@radicle.xyz\x1b]8;;\x1b\\>",
-
    );
-
    term::print("   (Messages are automatically posted to the public #feedback channel on Zulip.)");
-

-
    Ok(())
-
}
modified crates/radicle-cli/src/commands/id.rs
@@ -1,285 +1,33 @@
+
mod args;
+

use std::collections::BTreeSet;
-
use std::{ffi::OsString, io};

use anyhow::{anyhow, Context};

use radicle::cob::identity::{self, IdentityMut, Revision, RevisionId};
use radicle::cob::Title;
use radicle::identity::doc::update;
-
use radicle::identity::doc::update::EditVisibility;
use radicle::identity::{doc, Doc, Identity, RawDoc};
use radicle::node::device::Device;
use radicle::node::NodeId;
-
use radicle::prelude::{Did, RepoId};
use radicle::storage::{ReadStorage as _, WriteRepository};
use radicle::{cob, crypto, Profile};
use radicle_surf::diff::Diff;
use radicle_term::Element;
-
use serde_json as json;

use crate::git::unified_diff::Encode as _;
use crate::git::Rev;
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
+
use crate::terminal::args::Error;
use crate::terminal::patch::Message;
-
use crate::terminal::Interactive;
-

-
pub const HELP: Help = Help {
-
    name: "id",
-
    description: "Manage repository identities",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad id list [<option>...]
-
    rad id update [--title <string>] [--description <string>]
-
                  [--delegate <did>] [--rescind <did>]
-
                  [--threshold <num>] [--visibility <private | public>]
-
                  [--allow <did>] [--disallow <did>]
-
                  [--no-confirm] [--payload <id> <key> <val>...] [--edit] [<option>...]
-
    rad id edit <revision-id> [--title <string>] [--description <string>] [<option>...]
-
    rad id show <revision-id> [<option>...]
-
    rad id <accept | reject | redact> <revision-id> [<option>...]
-

-
    The *rad id* command is used to manage and propose changes to the
-
    identity of a Radicle repository.
-

-
    See the rad-id(1) man page for more information.
-

-
Options
-

-
    --repo <rid>           Repository (defaults to the current repository)
-
    --quiet, -q            Don't print anything
-
    --help                 Print help
-
"#,
-
};
-

-
#[derive(Clone, Debug, Default)]
-
pub enum Operation {
-
    Update {
-
        title: Option<Title>,
-
        description: Option<String>,
-
        delegate: Vec<Did>,
-
        rescind: Vec<Did>,
-
        threshold: Option<usize>,
-
        visibility: Option<EditVisibility>,
-
        allow: BTreeSet<Did>,
-
        disallow: BTreeSet<Did>,
-
        payload: Vec<(doc::PayloadId, String, json::Value)>,
-
        edit: bool,
-
    },
-
    AcceptRevision {
-
        revision: Rev,
-
    },
-
    RejectRevision {
-
        revision: Rev,
-
    },
-
    EditRevision {
-
        revision: Rev,
-
        title: Option<Title>,
-
        description: Option<String>,
-
    },
-
    RedactRevision {
-
        revision: Rev,
-
    },
-
    ShowRevision {
-
        revision: Rev,
-
    },
-
    #[default]
-
    ListRevisions,
-
}
-

-
#[derive(Default, PartialEq, Eq)]
-
pub enum OperationName {
-
    Accept,
-
    Reject,
-
    Edit,
-
    Update,
-
    Show,
-
    Redact,
-
    #[default]
-
    List,
-
}
-

-
pub struct Options {
-
    pub op: Operation,
-
    pub rid: Option<RepoId>,
-
    pub interactive: Interactive,
-
    pub quiet: bool,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut op: Option<OperationName> = None;
-
        let mut revision: Option<Rev> = None;
-
        let mut rid: Option<RepoId> = None;
-
        let mut title: Option<Title> = None;
-
        let mut description: Option<String> = None;
-
        let mut delegate: Vec<Did> = Vec::new();
-
        let mut rescind: Vec<Did> = Vec::new();
-
        let mut visibility: Option<EditVisibility> = None;
-
        let mut allow: BTreeSet<Did> = BTreeSet::new();
-
        let mut disallow: BTreeSet<Did> = BTreeSet::new();
-
        let mut threshold: Option<usize> = None;
-
        let mut interactive = Interactive::new(io::stdout());
-
        let mut payload = Vec::new();
-
        let mut edit = false;
-
        let mut quiet = false;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") => {
-
                    return Err(Error::HelpManual { name: "rad-id" }.into());
-
                }
-
                Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Long("title")
-
                    if op == Some(OperationName::Edit) || op == Some(OperationName::Update) =>
-
                {
-
                    let val = parser.value()?;
-
                    title = Some(term::args::string(&val).try_into()?);
-
                }
-
                Long("description")
-
                    if op == Some(OperationName::Edit) || op == Some(OperationName::Update) =>
-
                {
-
                    description = Some(parser.value()?.to_string_lossy().into());
-
                }
-
                Long("quiet") | Short('q') => {
-
                    quiet = true;
-
                }
-
                Long("no-confirm") => {
-
                    interactive = Interactive::No;
-
                }
-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "e" | "edit" => op = Some(OperationName::Edit),
-
                    "u" | "update" => op = Some(OperationName::Update),
-
                    "l" | "list" => op = Some(OperationName::List),
-
                    "s" | "show" => op = Some(OperationName::Show),
-
                    "a" | "accept" => op = Some(OperationName::Accept),
-
                    "r" | "reject" => op = Some(OperationName::Reject),
-
                    "d" | "redact" => op = Some(OperationName::Redact),
-

-
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
-
                },
-
                Long("repo") => {
-
                    let val = parser.value()?;
-
                    let val = term::args::rid(&val)?;
-

-
                    rid = Some(val);
-
                }
-
                Long("delegate") => {
-
                    let did = term::args::did(&parser.value()?)?;
-
                    delegate.push(did);
-
                }
-
                Long("rescind") => {
-
                    let did = term::args::did(&parser.value()?)?;
-
                    rescind.push(did);
-
                }
-
                Long("allow") => {
-
                    let value = parser.value()?;
-
                    let did = term::args::did(&value)?;
-
                    allow.insert(did);
-
                }
-
                Long("disallow") => {
-
                    let value = parser.value()?;
-
                    let did = term::args::did(&value)?;
-
                    disallow.insert(did);
-
                }
-
                Long("visibility") => {
-
                    let value = parser.value()?;
-
                    let value = term::args::parse_value("visibility", value)?;

-
                    visibility = Some(value);
-
                }
-
                Long("threshold") => {
-
                    threshold = Some(parser.value()?.to_string_lossy().parse()?);
-
                }
-
                Long("payload") => {
-
                    let mut values = parser.values()?;
-
                    let id = values
-
                        .next()
-
                        .ok_or(anyhow!("expected payload id, eg. `xyz.radicle.project`"))?;
-
                    let id: doc::PayloadId = term::args::parse_value("payload", id)?;
-

-
                    let key = values
-
                        .next()
-
                        .ok_or(anyhow!("expected payload key, eg. 'defaultBranch'"))?;
-
                    let key = term::args::string(&key);
-

-
                    let val = values
-
                        .next()
-
                        .ok_or(anyhow!("expected payload value, eg. '\"heartwood\"'"))?;
-
                    let val = val.to_string_lossy().to_string();
-
                    let val = json::from_str(val.as_str())
-
                        .map_err(|e| anyhow!("invalid JSON value `{val}`: {e}"))?;
-

-
                    payload.push((id, key, val));
-
                }
-
                Long("edit") => {
-
                    edit = true;
-
                }
-
                Value(val) => {
-
                    let val = term::args::rev(&val)?;
-
                    revision = Some(val);
-
                }
-
                _ => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
-

-
        let op = match op.unwrap_or_default() {
-
            OperationName::Accept => Operation::AcceptRevision {
-
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
-
            },
-
            OperationName::Reject => Operation::RejectRevision {
-
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
-
            },
-
            OperationName::Edit => Operation::EditRevision {
-
                title,
-
                description,
-
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
-
            },
-
            OperationName::Show => Operation::ShowRevision {
-
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
-
            },
-
            OperationName::List => Operation::ListRevisions,
-
            OperationName::Redact => Operation::RedactRevision {
-
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
-
            },
-
            OperationName::Update => Operation::Update {
-
                title,
-
                description,
-
                delegate,
-
                rescind,
-
                threshold,
-
                visibility,
-
                allow,
-
                disallow,
-
                payload,
-
                edit,
-
            },
-
        };
-
        Ok((
-
            Options {
-
                rid,
-
                op,
-
                interactive,
-
                quiet,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
+
pub use args::Args;
+
use args::Command;

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let storage = &profile.storage;
-
    let rid = if let Some(rid) = options.rid {
+
    let rid = if let Some(rid) = args.repo {
        rid
    } else {
        let (_, rid) = radicle::rad::cwd()?;
@@ -291,8 +39,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    let mut identity = Identity::load_mut(&repo)?;
    let current = identity.current().clone();

-
    match options.op {
-
        Operation::AcceptRevision { revision } => {
+
    let interactive = args.interactive();
+
    let command = args.command.unwrap_or(Command::List);
+

+
    match command {
+
        Command::Accept { revision } => {
            let revision = get(revision, &identity, &repo)?.clone();
            let id = revision.id;
            let signer = term::signer(&profile)?;
@@ -301,10 +52,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                anyhow::bail!("cannot vote on revision that is {}", revision.state);
            }

-
            if options
-
                .interactive
-
                .confirm(format!("Accept revision {}?", term::format::tertiary(id)))
-
            {
+
            if interactive.confirm(format!("Accept revision {}?", term::format::tertiary(id))) {
                identity.accept(&revision.id, &signer)?;

                if let Some(revision) = identity.revision(&id) {
@@ -314,14 +62,14 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                    }
                    // TODO: Different output if canonical changed?

-
                    if !options.quiet {
+
                    if !args.quiet {
                        term::success!("Revision {id} accepted");
                        print_meta(revision, &current, &profile)?;
                    }
                }
            }
        }
-
        Operation::RejectRevision { revision } => {
+
        Command::Reject { revision } => {
            let revision = get(revision, &identity, &repo)?.clone();
            let signer = term::signer(&profile)?;

@@ -329,19 +77,19 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                anyhow::bail!("cannot vote on revision that is {}", revision.state);
            }

-
            if options.interactive.confirm(format!(
+
            if interactive.confirm(format!(
                "Reject revision {}?",
                term::format::tertiary(revision.id)
            )) {
                identity.reject(revision.id, &signer)?;

-
                if !options.quiet {
+
                if !args.quiet {
                    term::success!("Revision {} rejected", revision.id);
                    print_meta(&revision, &current, &profile)?;
                }
            }
        }
-
        Operation::EditRevision {
+
        Command::Edit {
            revision,
            title,
            description,
@@ -357,11 +105,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            };
            identity.edit(revision.id, title, description, &signer)?;

-
            if !options.quiet {
+
            if !args.quiet {
                term::success!("Revision {} edited", revision.id);
            }
        }
-
        Operation::Update {
+
        Command::Update {
            title,
            description,
            delegate: delegates,
@@ -375,6 +123,9 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        } => {
            let proposal = {
                let mut proposal = current.doc.clone().edit();
+
                let allow = allow.into_iter().collect::<BTreeSet<_>>();
+
                let disallow = disallow.into_iter().collect::<BTreeSet<_>>();
+

                proposal.threshold = threshold.unwrap_or(proposal.threshold);

                let proposal = match visibility {
@@ -407,7 +158,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                    }
                };

-
                update::payload(proposal, payload)?
+
                // TODO(erikli): whenever `clap` starts supporting custom value parsers
+
                // for a series of values, we can parse into `Payload` implicitly.
+
                let payloads = args::parse_many_upserts(&payload).collect::<Result<Vec<_>, _>>()?;
+

+
                update::payload(proposal, payloads)?
            };

            // If `--edit` is specified, the document can also be edited via a text edit.
@@ -431,7 +186,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {

            let proposal = update::verify(proposal)?;
            if proposal == current.doc {
-
                if !options.quiet {
+
                if !args.quiet {
                    term::print(term::format::italic(
                        "Nothing to do. The document is up to date. See `rad inspect --identity`.",
                    ));
@@ -445,7 +200,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                // Update the canonical head to point to the latest accepted revision.
                repo.set_identity_head_to(revision.id)?;
            }
-
            if options.quiet {
+
            if args.quiet {
                term::print(revision.id);
            } else {
                term::success!(
@@ -455,7 +210,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                print(&revision, &current, &repo, &profile)?;
            }
        }
-
        Operation::ListRevisions => {
+
        Command::List => {
            let mut revisions =
                term::Table::<7, term::Label>::new(term::table::TableOptions::bordered());

@@ -489,25 +244,25 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            }
            revisions.print();
        }
-
        Operation::RedactRevision { revision } => {
+
        Command::Redact { revision } => {
            let revision = get(revision, &identity, &repo)?.clone();
            let signer = term::signer(&profile)?;

            if revision.is_accepted() {
                anyhow::bail!("cannot redact accepted revision");
            }
-
            if options.interactive.confirm(format!(
+
            if interactive.confirm(format!(
                "Redact revision {}?",
                term::format::tertiary(revision.id)
            )) {
                identity.redact(revision.id, &signer)?;

-
                if !options.quiet {
+
                if !args.quiet {
                    term::success!("Revision {} redacted", revision.id);
                }
            }
        }
-
        Operation::ShowRevision { revision } => {
+
        Command::Show { revision } => {
            let revision = get(revision, &identity, &repo)?;
            let previous = revision.parent.unwrap_or(revision.id);
            let previous = identity
added crates/radicle-cli/src/commands/id/args.rs
@@ -0,0 +1,326 @@
+
use std::io;
+
use std::str::FromStr;
+

+
use clap::{Parser, Subcommand};
+

+
use serde_json as json;
+

+
use thiserror::Error;
+

+
use radicle::cob::{Title, TypeNameParse};
+
use radicle::identity::doc::update::EditVisibility;
+
use radicle::identity::doc::update::PayloadUpsert;
+
use radicle::identity::doc::PayloadId;
+
use radicle::prelude::{Did, RepoId};
+

+
use crate::git::Rev;
+

+
use crate::terminal::Interactive;
+

+
const ABOUT: &str = "Manage repository identities";
+
const LONG_ABOUT: &str = r#"
+
The `id` command is used to manage and propose changes to the
+
identity of a Radicle repository.
+

+
See the rad-id(1) man page for more information.
+
"#;
+

+
#[derive(Debug, Error)]
+
pub enum PayloadUpsertParseError {
+
    #[error("could not parse payload id: {0}")]
+
    IdParse(#[from] TypeNameParse),
+
    #[error("could not parse json value: {0}")]
+
    Value(#[from] json::Error),
+
}
+

+
/// Parses a slice of all payload upserts as aggregated by `clap`
+
/// (see [`Command::Update::payload`]).
+
/// E.g. `["com.example.one", "name", "1", "com.example.two", "name2", "2"]`
+
/// will result in iterator over two [`PayloadUpsert`]s.
+
///
+
/// # Panics
+
///
+
/// If the length of `values` is not divisible by 3.
+
/// (To catch errors in the definition of the parser derived from
+
/// [`Command::Update`] or `clap` itself, and unexpected changes to
+
/// `clap`s behaviour in the future.)
+
pub(super) fn parse_many_upserts(
+
    values: &[String],
+
) -> impl Iterator<Item = Result<PayloadUpsert, PayloadUpsertParseError>> + use<'_> {
+
    // `clap` ensures we have 3 values per option occurrence,
+
    // so we can chunk the aggregated slice exactly.
+
    let chunks = values.chunks_exact(3);
+

+
    assert!(chunks.remainder().is_empty());
+

+
    chunks.map(|chunk| {
+
        // Slice accesses will not panic, guaranteed by `chunks_exact(3)`.
+
        Ok(PayloadUpsert {
+
            id: PayloadId::from_str(&chunk[0])?,
+
            key: chunk[1].to_owned(),
+
            value: json::from_str(&chunk[2].to_owned())?,
+
        })
+
    })
+
}
+

+
#[derive(Clone, Debug)]
+
struct EditVisibilityParser;
+

+
impl clap::builder::TypedValueParser for EditVisibilityParser {
+
    type Value = EditVisibility;
+

+
    fn parse_ref(
+
        &self,
+
        cmd: &clap::Command,
+
        arg: Option<&clap::Arg>,
+
        value: &std::ffi::OsStr,
+
    ) -> Result<Self::Value, clap::Error> {
+
        <EditVisibility as std::str::FromStr>::from_str.parse_ref(cmd, arg, value)
+
    }
+

+
    fn possible_values(
+
        &self,
+
    ) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
+
        use clap::builder::PossibleValue;
+
        Some(Box::new(
+
            [PossibleValue::new("private"), PossibleValue::new("public")].into_iter(),
+
        ))
+
    }
+
}
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    #[command(subcommand)]
+
    pub(super) command: Option<Command>,
+

+
    /// Specify the repository to operate on. Defaults to the current repository
+
    ///
+
    /// [example values: rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH, z3Tr6bC7ctEg2EHmLvknUr29mEDLH]
+
    #[arg(long)]
+
    #[arg(value_name = "RID", global = true)]
+
    pub(super) repo: Option<RepoId>,
+

+
    /// Do not ask for confirmation
+
    #[arg(long)]
+
    #[arg(global = true)]
+
    no_confirm: bool,
+

+
    /// Suppress output
+
    #[arg(long, short)]
+
    #[arg(global = true)]
+
    pub(super) quiet: bool,
+
}
+

+
impl Args {
+
    pub(super) fn interactive(&self) -> Interactive {
+
        if self.no_confirm {
+
            Interactive::No
+
        } else {
+
            Interactive::new(io::stdout())
+
        }
+
    }
+
}
+

+
#[derive(Subcommand, Debug)]
+
pub(super) enum Command {
+
    /// Accept a proposed revision to the identity document
+
    #[clap(alias("a"))]
+
    Accept {
+
        /// Proposed revision to accept
+
        #[arg(value_name = "REVISION_ID")]
+
        revision: Rev,
+
    },
+

+
    /// Reject a proposed revision to the identity document
+
    #[clap(alias("r"))]
+
    Reject {
+
        /// Proposed revision to reject
+
        #[arg(value_name = "REVISION_ID")]
+
        revision: Rev,
+
    },
+

+
    /// Edit an existing revision to the identity document
+
    #[clap(alias("e"))]
+
    Edit {
+
        /// Proposed revision to edit
+
        #[arg(value_name = "REVISION_ID")]
+
        revision: Rev,
+

+
        /// Title of the edit
+
        #[arg(long)]
+
        title: Option<Title>,
+

+
        /// Description of the edit
+
        #[arg(long)]
+
        description: Option<String>,
+
    },
+

+
    /// Propose a new revision to the identity document
+
    #[clap(alias("u"))]
+
    Update {
+
        /// Set the title for the new proposal
+
        #[arg(long)]
+
        title: Option<Title>,
+

+
        /// Set the description for the new proposal
+
        #[arg(long)]
+
        description: Option<String>,
+

+
        /// Update the identity by adding a new delegate, identified by their DID
+
        #[arg(long, short)]
+
        #[arg(value_name = "DID")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        delegate: Vec<Did>,
+

+
        /// Update the identity by removing a delegate, identified by their DID
+
        #[arg(long, short)]
+
        #[arg(value_name = "DID")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        rescind: Vec<Did>,
+

+
        /// Update the identity by setting the number of delegates required to accept a revision
+
        #[arg(long)]
+
        threshold: Option<usize>,
+

+
        /// Update the identity by setting the repository's visibility to private or public
+
        #[arg(long)]
+
        #[arg(value_parser = EditVisibilityParser)]
+
        visibility: Option<EditVisibility>,
+

+
        /// Update the identity by giving a specific DID access to a private repository
+
        #[arg(long)]
+
        #[arg(value_name = "DID")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        allow: Vec<Did>,
+

+
        /// Update the identity by removing a specific DID's access from a private repository
+
        #[arg(long)]
+
        #[arg(value_name = "DID")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        disallow: Vec<Did>,
+

+
        /// Update the identity by setting metadata in one of the identity payloads
+
        ///
+
        /// [example values: xyz.radicle.project name '"radicle-example"']
+
        // TODO(erikili:) Value parsers do not operate on series of values, yet. This will
+
        // change with clap v5, so we can hopefully use `Vec<Payload>`.
+
        // - https://github.com/clap-rs/clap/discussions/5930#discussioncomment-12315889
+
        // - https://docs.rs/clap/latest/clap/_derive/index.html#arg-types
+
        #[arg(long)]
+
        #[arg(value_names = ["TYPE", "KEY", "VALUE"], num_args = 3)]
+
        payload: Vec<String>,
+

+
        /// Opens your $EDITOR to edit the JSON contents directly
+
        #[arg(long)]
+
        edit: bool,
+
    },
+

+
    /// Lists all proposed revisions to the identity document
+
    #[clap(alias("l"))]
+
    List,
+

+
    /// Show a specific identity proposal
+
    #[clap(alias("s"))]
+
    Show {
+
        /// Proposed revision to show
+
        #[arg(value_name = "REVISION_ID")]
+
        revision: Rev,
+
    },
+

+
    /// Redact a revision
+
    #[clap(alias("d"))]
+
    Redact {
+
        /// Proposed revision to redact
+
        #[arg(value_name = "REVISION_ID")]
+
        revision: Rev,
+
    },
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use super::{parse_many_upserts, Args};
+
    use clap::error::ErrorKind;
+
    use clap::Parser;
+

+
    #[test]
+
    fn should_parse_single_payload() {
+
        let args = Args::try_parse_from(["id", "update", "--payload", "key", "name", "value"]);
+
        assert!(args.is_ok())
+
    }
+

+
    #[test]
+
    fn should_not_parse_single_payload() {
+
        let err = Args::try_parse_from(["id", "update", "--payload", "key", "name"]).unwrap_err();
+
        assert_eq!(err.kind(), ErrorKind::WrongNumberOfValues);
+
    }
+

+
    #[test]
+
    fn should_parse_multiple_payloads() {
+
        let args = Args::try_parse_from([
+
            "id",
+
            "update",
+
            "--payload",
+
            "key_1",
+
            "name_1",
+
            "value_1",
+
            "--payload",
+
            "key_2",
+
            "name_2",
+
            "value_2",
+
        ]);
+
        assert!(args.is_ok())
+
    }
+

+
    #[test]
+
    fn should_not_parse_single_payloads() {
+
        let err = Args::try_parse_from([
+
            "id",
+
            "update",
+
            "--payload",
+
            "key_1",
+
            "name_1",
+
            "value_1",
+
            "--payload",
+
            "key_2",
+
            "name_2",
+
        ])
+
        .unwrap_err();
+
        assert_eq!(err.kind(), ErrorKind::WrongNumberOfValues);
+
    }
+

+
    #[test]
+
    fn should_not_clobber_payload_args() {
+
        let err = Args::try_parse_from([
+
            "id",
+
            "update",
+
            "--payload",
+
            "key_1",
+
            "name_1",
+
            "--payload", // ensure `--payload is not treated as an argument`
+
            "key_2",
+
            "name_2",
+
            "value_2",
+
        ])
+
        .unwrap_err();
+
        assert_eq!(err.kind(), ErrorKind::WrongNumberOfValues);
+
    }
+

+
    #[test]
+
    fn should_parse_into_payload() {
+
        let payload: Result<Vec<_>, _> = parse_many_upserts(&[
+
            "xyz.radicle.project".to_string(),
+
            "name".to_string(),
+
            "{}".to_string(),
+
        ])
+
        .collect();
+
        assert!(payload.is_ok())
+
    }
+

+
    #[test]
+
    #[should_panic(expected = "assertion failed: chunks.remainder().is_empty()")]
+
    fn should_not_parse_into_payload() {
+
        let _: Result<Vec<_>, _> =
+
            parse_many_upserts(&["xyz.radicle.project".to_string(), "name".to_string()]).collect();
+
    }
+
}
modified crates/radicle-cli/src/commands/inbox.rs
@@ -1,228 +1,99 @@
-
use std::ffi::OsString;
+
mod args;
+

+
pub use args::Args;
+

use std::path::Path;
use std::process;

use anyhow::anyhow;

-
use git_ref_format::Qualified;
use localtime::LocalTime;
use radicle::cob::TypedId;
+
use radicle::git::fmt::Qualified;
+
use radicle::git::BranchName;
use radicle::identity::Identity;
use radicle::issue::cache::Issues as _;
use radicle::node::notifications;
use radicle::node::notifications::*;
use radicle::patch::cache::Patches as _;
use radicle::prelude::{NodeId, Profile, RepoId};
-
use radicle::storage::{BranchName, ReadRepository, ReadStorage};
+
use radicle::storage::{ReadRepository, ReadStorage};
use radicle::{cob, git, Storage};

use term::Element as _;

use crate::terminal as term;
-
use crate::terminal::args;
-
use crate::terminal::args::{Args, Error, Help};
-

-
pub const HELP: Help = Help {
-
    name: "inbox",
-
    description: "Manage your Radicle notifications",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad inbox [<option>...]
-
    rad inbox list [<option>...]
-
    rad inbox show <id> [<option>...]
-
    rad inbox clear <id...> [<option>...]
-

-
    By default, this command lists all items in your inbox.
-
    If your working directory is a Radicle repository, it only shows item
-
    belonging to this repository, unless `--all` is used.
-

-
    The `rad inbox show` command takes a notification ID (which can be found in
-
    the `list` command) and displays the information related to that
-
    notification. This will mark the notification as read.
-

-
    The `rad inbox clear` command will delete all notifications by their passed id
-
    or all notifications if no ids were passed.
-

-
Options
-

-
    --all                Operate on all repositories
-
    --repo <rid>         Operate on the given repository (default: rad .)
-
    --sort-by <field>    Sort by `id` or `timestamp` (default: timestamp)
-
    --reverse, -r        Reverse the list
-
    --show-unknown       Show any updates that were not recognized
-
    --help               Print help
-
"#,
-
};
-

-
#[derive(Debug, Default, PartialEq, Eq)]
-
enum Operation {
-
    #[default]
-
    List,
-
    Show,
-
    Clear,
-
}
-

-
#[derive(Default, Debug)]
-
enum Mode {
-
    #[default]
-
    Contextual,
-
    All,
-
    ById(Vec<NotificationId>),
-
    ByRepo(RepoId),
-
}
-

-
#[derive(Clone, Copy, Debug)]
-
struct SortBy {
-
    reverse: bool,
-
    field: &'static str,
-
}
-

-
pub struct Options {
-
    op: Operation,
-
    mode: Mode,
-
    sort_by: SortBy,
-
    show_unknown: bool,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut op: Option<Operation> = None;
-
        let mut mode = None;
-
        let mut ids = Vec::new();
-
        let mut reverse = None;
-
        let mut field = None;
-
        let mut show_unknown = false;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Long("all") | Short('a') if mode.is_none() => {
-
                    mode = Some(Mode::All);
-
                }
-
                Long("reverse") | Short('r') => {
-
                    reverse = Some(true);
-
                }
-
                Long("show-unknown") => {
-
                    show_unknown = true;
-
                }
-
                Long("sort-by") => {
-
                    let val = parser.value()?;
-

-
                    match term::args::string(&val).as_str() {
-
                        "timestamp" => field = Some("timestamp"),
-
                        "id" => field = Some("rowid"),
-
                        other => {
-
                            return Err(anyhow!(
-
                                "unknown sorting field `{other}`, see `rad inbox --help`"
-
                            ))
-
                        }
-
                    }
-
                }
-
                Long("repo") if mode.is_none() => {
-
                    let val = parser.value()?;
-
                    let repo = args::rid(&val)?;
+
use args::{ClearMode, Command, ListMode, SortBy};

-
                    mode = Some(Mode::ByRepo(repo));
-
                }
-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "list" => op = Some(Operation::List),
-
                    "show" => op = Some(Operation::Show),
-
                    "clear" => op = Some(Operation::Clear),
-
                    cmd => return Err(anyhow!("unknown command `{cmd}`, see `rad inbox --help`")),
-
                },
-
                Value(val) if op.is_some() && mode.is_none() => {
-
                    let id = term::args::number(&val)? as NotificationId;
-
                    ids.push(id);
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-
        let mode = if ids.is_empty() {
-
            mode.unwrap_or_default()
-
        } else {
-
            Mode::ById(ids)
-
        };
-
        let op = op.unwrap_or_default();
-

-
        let sort_by = if let Some(field) = field {
-
            SortBy {
-
                field,
-
                reverse: reverse.unwrap_or(false),
-
            }
-
        } else {
-
            SortBy {
-
                field: "timestamp",
-
                reverse: true,
-
            }
-
        };
-

-
        Ok((
-
            Options {
-
                op,
-
                mode,
-
                sort_by,
-
                show_unknown,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let storage = &profile.storage;
    let mut notifs = profile.notifications_mut()?;
-
    let Options {
-
        op,
-
        mode,
-
        sort_by,
-
        show_unknown,
-
    } = options;
-

-
    match op {
-
        Operation::List => list(
-
            mode,
-
            sort_by,
-
            show_unknown,
-
            &notifs.read_only(),
-
            storage,
-
            &profile,
-
        ),
-
        Operation::Clear => clear(mode, &mut notifs),
-
        Operation::Show => show(mode, &mut notifs, storage, &profile),
+
    let command = args
+
        .clone()
+
        .command
+
        .unwrap_or_else(|| Command::List(args.empty.into()));
+

+
    match command {
+
        Command::List(args) => {
+
            let show_unknown = args.show_unknown;
+
            let sort_by = args.sort_by;
+
            let reverse = args.reverse;
+

+
            list(
+
                &notifs.read_only(),
+
                args.into(),
+
                sort_by,
+
                reverse,
+
                show_unknown,
+
                storage,
+
                &profile,
+
            )
+
        }
+
        Command::Clear(args) => clear(&mut notifs, args.into()),
+
        Command::Show { id } => show(&mut notifs, id, storage, &profile),
    }
}

fn list(
-
    mode: Mode,
+
    notifs: &notifications::StoreReader,
+
    mode: ListMode,
    sort_by: SortBy,
+
    reverse: bool,
    show_unknown: bool,
-
    notifs: &notifications::StoreReader,
    storage: &Storage,
    profile: &Profile,
) -> anyhow::Result<()> {
    let repos: Vec<term::VStack<'_>> = match mode {
-
        Mode::Contextual => {
+
        ListMode::Contextual => {
            if let Ok((_, rid)) = radicle::rad::cwd() {
-
                list_repo(rid, sort_by, show_unknown, notifs, storage, profile)?
-
                    .into_iter()
-
                    .collect()
+
                list_repo(
+
                    notifs,
+
                    rid,
+
                    sort_by,
+
                    reverse,
+
                    show_unknown,
+
                    storage,
+
                    profile,
+
                )?
+
                .into_iter()
+
                .collect()
            } else {
-
                list_all(sort_by, show_unknown, notifs, storage, profile)?
+
                list_all(notifs, sort_by, reverse, show_unknown, storage, profile)?
            }
        }
-
        Mode::ByRepo(rid) => list_repo(rid, sort_by, show_unknown, notifs, storage, profile)?
-
            .into_iter()
-
            .collect(),
-
        Mode::All => list_all(sort_by, show_unknown, notifs, storage, profile)?,
-
        Mode::ById(_) => anyhow::bail!("the `list` command does not take IDs"),
+
        ListMode::All => list_all(notifs, sort_by, reverse, show_unknown, storage, profile)?,
+
        ListMode::ByRepo(rid) => list_repo(
+
            notifs,
+
            rid,
+
            sort_by,
+
            reverse,
+
            show_unknown,
+
            storage,
+
            profile,
+
        )?
+
        .into_iter()
+
        .collect(),
    };

    if repos.is_empty() {
@@ -236,9 +107,10 @@ fn list(
}

fn list_all<'a>(
+
    notifs: &notifications::StoreReader,
    sort_by: SortBy,
+
    reverse: bool,
    show_unknown: bool,
-
    notifs: &notifications::StoreReader,
    storage: &Storage,
    profile: &Profile,
) -> anyhow::Result<Vec<term::VStack<'a>>> {
@@ -247,27 +119,32 @@ fn list_all<'a>(

    let mut vstacks = Vec::new();
    for repo in repos {
-
        let vstack = list_repo(repo.rid, sort_by, show_unknown, notifs, storage, profile)?;
+
        let vstack = list_repo(
+
            notifs,
+
            repo.rid,
+
            sort_by,
+
            reverse,
+
            show_unknown,
+
            storage,
+
            profile,
+
        )?;
        vstacks.extend(vstack.into_iter());
    }
    Ok(vstacks)
}

fn list_repo<'a, R: ReadStorage>(
+
    notifs: &notifications::StoreReader,
    rid: RepoId,
    sort_by: SortBy,
+
    reverse: bool,
    show_unknown: bool,
-
    notifs: &notifications::StoreReader,
    storage: &R,
    profile: &Profile,
) -> anyhow::Result<Option<term::VStack<'a>>>
where
    <R as ReadStorage>::Repository: cob::Store<Namespace = NodeId>,
{
-
    let mut table = term::Table::new(term::TableOptions {
-
        spacing: 3,
-
        ..term::TableOptions::default()
-
    });
    let repo = storage.repository(rid)?;
    let (_, head) = repo.head()?;
    let doc = repo.identity_doc()?;
@@ -275,14 +152,19 @@ where
    let issues = term::cob::issues(profile, &repo)?;
    let patches = term::cob::patches(profile, &repo)?;

-
    let mut notifs = notifs.by_repo(&rid, sort_by.field)?.collect::<Vec<_>>();
-
    if !sort_by.reverse {
+
    let mut notifs = notifs
+
        .by_repo(&rid, &sort_by.to_string())?
+
        .collect::<Vec<_>>();
+
    if !reverse {
        // Notifications are returned in descendant order by default.
        notifs.reverse();
    }

-
    for n in notifs {
-
        let n: Notification = n?;
+
    let table = notifs.into_iter().flat_map(|n| {
+
        let n: Notification = match n {
+
            Err(e) => return Some(Err(anyhow::Error::from(e))),
+
            Ok(n) => n,
+
        };

        let seen = if n.status.is_read() {
            term::Label::blank()
@@ -305,26 +187,33 @@ where
            state,
            name,
        } = match &n.kind {
-
            NotificationKind::Branch { name } => NotificationRow::branch(name, head, &n, &repo)?,
+
            NotificationKind::Branch { name } => match NotificationRow::branch(name, head, &n, &repo) {
+
                Err(e) => return Some(Err(e)),
+
                Ok(b) => b,
+
            },
            NotificationKind::Cob { typed_id } => {
                match NotificationRow::cob(typed_id, &n, &issues, &patches, &repo) {
                    Ok(Some(row)) => row,
-
                    Ok(None) => continue,
+
                    Ok(None) => return None,
                    Err(e) => {
                        log::error!(target: "cli", "Error loading notification for {typed_id}: {e}");
-
                        continue;
+
                        return None
                    }
                }
            }
            NotificationKind::Unknown { refname } => {
                if show_unknown {
-
                    NotificationRow::unknown(refname, &n, &repo)?
+
                    match NotificationRow::unknown(refname, &n, &repo) {
+
                        Err(e) => return Some(Err(e)),
+
                        Ok(u) => u,
+
                    }
                } else {
-
                    continue;
+
                    return None
                }
            }
        };
-
        table.push([
+

+
        Some(Ok([
            notification_id,
            seen,
            name.into(),
@@ -333,8 +222,12 @@ where
            state.into(),
            author,
            timestamp,
-
        ]);
-
    }
+
        ]))
+
    }).collect::<Result<term::Table<8, _>, anyhow::Error>>()?
+
    .with_opts(term::TableOptions {
+
        spacing: 3,
+
        ..term::TableOptions::default()
+
    });

    if table.is_empty() {
        Ok(None)
@@ -492,20 +385,16 @@ impl NotificationRow {
    }
}

-
fn clear(mode: Mode, notifs: &mut notifications::StoreWriter) -> anyhow::Result<()> {
+
fn clear(notifs: &mut notifications::StoreWriter, mode: ClearMode) -> anyhow::Result<()> {
    let cleared = match mode {
-
        Mode::All => notifs.clear_all()?,
-
        Mode::ById(ids) => notifs.clear(&ids)?,
-
        Mode::ByRepo(rid) => notifs.clear_by_repo(&rid)?,
-
        Mode::Contextual => {
+
        ClearMode::ByNotifications(ids) => notifs.clear(&ids)?,
+
        ClearMode::ByRepo(rid) => notifs.clear_by_repo(&rid)?,
+
        ClearMode::All => notifs.clear_all()?,
+
        ClearMode::Contextual => {
            if let Ok((_, rid)) = radicle::rad::cwd() {
                notifs.clear_by_repo(&rid)?
            } else {
-
                return Err(Error::WithHint {
-
                    err: anyhow!("not a radicle repository"),
-
                    hint: "to clear all repository notifications, use the `--all` flag",
-
                }
-
                .into());
+
                return Err(anyhow!("not a radicle repository"));
            }
        }
    };
@@ -518,19 +407,11 @@ fn clear(mode: Mode, notifs: &mut notifications::StoreWriter) -> anyhow::Result<
}

fn show(
-
    mode: Mode,
    notifs: &mut notifications::StoreWriter,
+
    id: NotificationId,
    storage: &Storage,
    profile: &Profile,
) -> anyhow::Result<()> {
-
    let id = match mode {
-
        Mode::ById(ids) => match ids.as_slice() {
-
            [id] => *id,
-
            [] => anyhow::bail!("a Notification ID must be given"),
-
            _ => anyhow::bail!("too many Notification IDs given"),
-
        },
-
        _ => anyhow::bail!("a Notification ID must be given"),
-
    };
    let n = notifs.get(id)?;
    let repo = storage.repository(n.repo)?;

added crates/radicle-cli/src/commands/inbox/args.rs
@@ -0,0 +1,224 @@
+
use std::{fmt::Display, str::FromStr};
+

+
use clap::{Parser, Subcommand, ValueEnum};
+
use radicle::{node::notifications::NotificationId, prelude::RepoId};
+

+
const ABOUT: &str = "Manage your Radicle notifications";
+

+
const LONG_ABOUT: &str = r#"
+
By default, this command lists all items in your inbox.
+
If your working directory is a Radicle repository, it only shows items
+
belonging to this repository, unless `--all` is used.
+

+
The `show` subcommand takes a notification ID (which can be found in
+
the output of the `list` subcommand) and displays the information related to that
+
notification. This will mark the notification as read.
+

+
The `clear` subcommand will clear all notifications with given IDs,
+
or all notifications if no IDs are given. Cleared notifications are
+
deleted and cannot be restored.
+
"#;
+

+
#[derive(Clone, Debug, Parser)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    #[command(subcommand)]
+
    pub(super) command: Option<Command>,
+

+
    #[clap(flatten)]
+
    pub(super) empty: EmptyArgs,
+
}
+

+
#[derive(Subcommand, Clone, Debug)]
+
pub(super) enum Command {
+
    /// List all items in your inbox
+
    List(ListArgs),
+
    /// Show a notification
+
    ///
+
    /// The NOTIFICATION_ID can be found by listing the items in your inbox
+
    ///
+
    /// Showing a notification will mark that notification as read
+
    Show {
+
        /// The notification to display
+
        #[arg(value_name = "NOTIFICATION_ID")]
+
        id: NotificationId,
+
    },
+
    /// Clear notifications
+
    ///
+
    /// This will clear all given notifications
+
    ///
+
    /// If no notifications are specified then all notifications are cleared
+
    Clear(ClearArgs),
+
}
+

+
#[derive(Parser, Clone, Copy, Debug)]
+
pub(super) struct EmptyArgs {
+
    /// Sort by column
+
    #[arg(long, value_enum, default_value_t, hide = true)]
+
    sort_by: SortBy,
+

+
    /// Reverse the list
+
    #[arg(short, long, hide = true)]
+
    reverse: bool,
+

+
    /// Show any updates that were not recognized
+
    #[arg(long, hide = true)]
+
    show_unknown: bool,
+

+
    /// Operate on a given repository [default: cwd]
+
    #[arg(value_name = "RID")]
+
    #[arg(long, hide = true)]
+
    repo: Option<RepoId>,
+

+
    /// Operate on all repositories
+
    #[arg(short, long, conflicts_with = "repo", hide = true)]
+
    all: bool,
+
}
+

+
#[derive(Parser, Clone, Copy, Debug)]
+
pub(super) struct ListArgs {
+
    /// Sort by column
+
    #[arg(long, value_enum, default_value_t)]
+
    pub(super) sort_by: SortBy,
+

+
    /// Reverse the list
+
    #[arg(short, long)]
+
    pub(super) reverse: bool,
+

+
    /// Show any updates that were not recognized
+
    #[arg(long)]
+
    pub(super) show_unknown: bool,
+

+
    /// Operate on a given repository [default: cwd]
+
    #[arg(long, value_name = "RID")]
+
    pub(super) repo: Option<RepoId>,
+

+
    /// Operate on all repositories
+
    #[arg(short, long, conflicts_with = "repo")]
+
    pub(super) all: bool,
+
}
+

+
impl From<ListArgs> for ListMode {
+
    fn from(args: ListArgs) -> Self {
+
        if args.all {
+
            assert!(args.repo.is_none());
+
            return Self::All;
+
        }
+

+
        if let Some(repo) = args.repo {
+
            return Self::ByRepo(repo);
+
        }
+

+
        Self::Contextual
+
    }
+
}
+

+
impl From<EmptyArgs> for ListArgs {
+
    fn from(
+
        EmptyArgs {
+
            sort_by,
+
            reverse,
+
            show_unknown,
+
            repo,
+
            all,
+
        }: EmptyArgs,
+
    ) -> Self {
+
        Self {
+
            sort_by,
+
            reverse,
+
            show_unknown,
+
            repo,
+
            all,
+
        }
+
    }
+
}
+

+
#[derive(Parser, Clone, Debug)]
+
pub(super) struct ClearArgs {
+
    /// Operate on a given repository [default: cwd]
+
    #[arg(long, value_name = "RID")]
+
    repo: Option<RepoId>,
+

+
    /// Operate on all repositories
+
    #[arg(short, long, conflicts_with = "repo")]
+
    all: bool,
+

+
    /// A list of notifications to clear
+
    ///
+
    /// The --repo or --all options are ignored when the notification ID's are
+
    /// specified
+
    #[arg(value_name = "NOTIFICATION_ID")]
+
    ids: Option<Vec<NotificationId>>,
+
}
+

+
impl From<ClearArgs> for ClearMode {
+
    fn from(ClearArgs { repo, all, ids }: ClearArgs) -> Self {
+
        if let Some(ids) = ids {
+
            return Self::ByNotifications(ids);
+
        }
+

+
        if all {
+
            assert!(repo.is_none());
+
            return Self::All;
+
        }
+

+
        if let Some(repo) = repo {
+
            return Self::ByRepo(repo);
+
        }
+

+
        Self::Contextual
+
    }
+
}
+

+
#[derive(ValueEnum, Clone, Copy, Default, Debug)]
+
pub enum SortBy {
+
    Id,
+
    #[default]
+
    Timestamp,
+
}
+

+
impl Display for SortBy {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            Self::Id => write!(f, "rowid"),
+
            Self::Timestamp => write!(f, "timestamp"),
+
        }
+
    }
+
}
+

+
impl FromStr for SortBy {
+
    type Err = String;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        match s {
+
            "id" => Ok(Self::Id),
+
            "timestamp" => Ok(Self::Timestamp),
+
            _ => Err(format!("'{s}' is not a valid sort by column")),
+
        }
+
    }
+
}
+

+
pub(super) enum ListMode {
+
    /// List the notifications of the current repository, if in a working
+
    /// directory, otherwise all the repositories.
+
    Contextual,
+
    /// List the notifications for a all repositories.
+
    All,
+
    /// List the notifications for a specific repository.
+
    ByRepo(RepoId),
+
}
+

+
pub(super) enum ClearMode {
+
    /// Clear the specified notifications.
+
    ///
+
    /// Note that this does not require a `RepoId` since the IDs are globally
+
    /// unique due to the use of a single sqlite table.
+
    ByNotifications(Vec<NotificationId>),
+
    /// Clear the notifications of a specific repository.
+
    ByRepo(RepoId),
+
    /// Clear all notifications of all repositories.
+
    All,
+
    /// Clear the notifications of the current repository, only if in a working
+
    /// directory.
+
    Contextual,
+
}
modified crates/radicle-cli/src/commands/init.rs
@@ -1,10 +1,13 @@
#![allow(clippy::or_fun_call)]
#![allow(clippy::collapsible_else_if)]
+

+
mod args;
+

+
pub use args::Args;
+

use std::collections::HashSet;
use std::convert::TryFrom;
use std::env;
-
use std::ffi::OsString;
-
use std::path::PathBuf;
use std::str::FromStr;

use anyhow::{anyhow, bail, Context as _};
@@ -12,12 +15,12 @@ use serde_json as json;

use radicle::crypto::ssh;
use radicle::explorer::ExplorerUrl;
+
use radicle::git::fmt::RefString;
use radicle::git::raw;
-
use radicle::git::RefString;
+
use radicle::git::raw::ErrorExt as _;
use radicle::identity::project::ProjectName;
use radicle::identity::{Doc, RepoId, Visibility};
use radicle::node::events::UploadPack;
-
use radicle::node::policy::Scope;
use radicle::node::{Event, Handle, NodeId, DEFAULT_SUBSCRIBE_TIMEOUT};
use radicle::storage::ReadStorage as _;
use radicle::{profile, Node};
@@ -25,171 +28,15 @@ use radicle::{profile, Node};
use crate::commands;
use crate::git;
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
use crate::terminal::Interactive;

-
pub const HELP: Help = Help {
-
    name: "init",
-
    description: "Initialize a Radicle repository",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad init [<path>] [<option>...]
-

-
Options
-

-
        --name <string>            Name of the repository
-
        --description <string>     Description of the repository
-
        --default-branch <name>    The default branch of the repository
-
        --scope <scope>            Repository follow scope: `followed` or `all` (default: all)
-
        --private                  Set repository visibility to *private*
-
        --public                   Set repository visibility to *public*
-
        --existing <rid>           Setup repository as an existing Radicle repository
-
    -u, --set-upstream             Setup the upstream of the default branch
-
        --setup-signing            Setup the radicle key as a signing key for this repository
-
        --no-confirm               Don't ask for confirmation during setup
-
        --no-seed                  Don't seed this repository after initializing it
-
    -v, --verbose                  Verbose mode
-
        --help                     Print help
-
"#,
-
};
-

-
#[derive(Default)]
-
pub struct Options {
-
    pub path: Option<PathBuf>,
-
    pub name: Option<ProjectName>,
-
    pub description: Option<String>,
-
    pub branch: Option<String>,
-
    pub interactive: Interactive,
-
    pub visibility: Option<Visibility>,
-
    pub existing: Option<RepoId>,
-
    pub setup_signing: bool,
-
    pub scope: Scope,
-
    pub set_upstream: bool,
-
    pub verbose: bool,
-
    pub seed: bool,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut path: Option<PathBuf> = None;
-

-
        let mut name = None;
-
        let mut description = None;
-
        let mut branch = None;
-
        let mut interactive = Interactive::Yes;
-
        let mut set_upstream = false;
-
        let mut setup_signing = false;
-
        let mut scope = Scope::All;
-
        let mut existing = None;
-
        let mut seed = true;
-
        let mut verbose = false;
-
        let mut visibility = None;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("name") if name.is_none() => {
-
                    let value = parser.value()?;
-
                    let value = term::args::string(&value);
-
                    let value = ProjectName::try_from(value)?;
-

-
                    name = Some(value);
-
                }
-
                Long("description") if description.is_none() => {
-
                    let value = parser
-
                        .value()?
-
                        .to_str()
-
                        .ok_or(anyhow::anyhow!(
-
                            "invalid repository description specified with `--description`"
-
                        ))?
-
                        .to_owned();
-

-
                    description = Some(value);
-
                }
-
                Long("default-branch") if branch.is_none() => {
-
                    let value = parser
-
                        .value()?
-
                        .to_str()
-
                        .ok_or(anyhow::anyhow!(
-
                            "invalid branch specified with `--default-branch`"
-
                        ))?
-
                        .to_owned();
-

-
                    branch = Some(value);
-
                }
-
                Long("scope") => {
-
                    let value = parser.value()?;
-

-
                    scope = term::args::parse_value("scope", value)?;
-
                }
-
                Long("set-upstream") | Short('u') => {
-
                    set_upstream = true;
-
                }
-
                Long("setup-signing") => {
-
                    setup_signing = true;
-
                }
-
                Long("no-confirm") => {
-
                    interactive = Interactive::No;
-
                }
-
                Long("no-seed") => {
-
                    seed = false;
-
                }
-
                Long("private") => {
-
                    visibility = Some(Visibility::private([]));
-
                }
-
                Long("public") => {
-
                    visibility = Some(Visibility::Public);
-
                }
-
                Long("existing") if existing.is_none() => {
-
                    let val = parser.value()?;
-
                    let rid = term::args::rid(&val)?;
-

-
                    existing = Some(rid);
-
                }
-
                Long("verbose") | Short('v') => {
-
                    verbose = true;
-
                }
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Value(val) if path.is_none() => {
-
                    path = Some(val.into());
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                path,
-
                name,
-
                description,
-
                branch,
-
                scope,
-
                existing,
-
                interactive,
-
                set_upstream,
-
                setup_signing,
-
                seed,
-
                visibility,
-
                verbose,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let cwd = env::current_dir()?;
-
    let path = options.path.as_deref().unwrap_or(cwd.as_path());
+
    let path = args.path.as_deref().unwrap_or(cwd.as_path());
    let repo = match git::Repository::open(path) {
        Ok(r) => r,
-
        Err(e) if radicle::git::ext::is_not_found_err(&e) => {
+
        Err(e) if e.is_not_found() => {
            anyhow::bail!("a Git repository was not found at the given path")
        }
        Err(e) => return Err(e.into()),
@@ -200,20 +47,18 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        }
    }

-
    if let Some(rid) = options.existing {
-
        init_existing(repo, rid, options, &profile)
+
    if let Some(rid) = args.existing {
+
        init_existing(repo, rid, args, &profile)
    } else {
-
        init(repo, options, &profile)
+
        init(repo, args, &profile)
    }
}

-
pub fn init(
-
    repo: git::Repository,
-
    options: Options,
-
    profile: &profile::Profile,
-
) -> anyhow::Result<()> {
+
pub fn init(repo: git::Repository, args: Args, profile: &profile::Profile) -> anyhow::Result<()> {
    let path = dunce::canonicalize(repo.workdir().unwrap_or_else(|| repo.path()))?;
-
    let interactive = options.interactive;
+
    let interactive = args.interactive();
+
    let visibility = args.visibility();
+
    let seed = args.seed();

    let default_branch = match find_default_branch(&repo) {
        Err(err @ DefaultBranchError::Head) => {
@@ -232,44 +77,54 @@ pub fn init(

    term::headline(format!(
        "Initializing{}radicle 👾 repository in {}..",
-
        if let Some(visibility) = &options.visibility {
-
            term::format::spaced(term::format::visibility(visibility))
-
        } else {
-
            term::format::default(" ").into()
+
        match visibility {
+
            Some(ref visibility) => term::format::spaced(term::format::visibility(visibility)),
+
            None => term::format::default(" ").into(),
        },
        term::format::dim(path.display())
    ));

-
    let name: ProjectName = match options.name {
+
    let name: ProjectName = match args.name {
        Some(name) => name,
        None => {
-
            let default = path.file_name().map(|f| f.to_string_lossy().to_string());
-
            term::input(
+
            let default = path
+
                .file_name()
+
                .and_then(|f| f.to_str())
+
                .and_then(|f| ProjectName::try_from(f).ok());
+
            // TODO(finto): this is interactive without checking `interactive` –
+
            // this should check if interactive and use the default if not
+
            let name = term::input(
                "Name",
                default,
                Some("The name of your repository, eg. 'acme'"),
-
            )?
-
            .try_into()?
+
            )?;
+

+
            name.ok_or_else(|| anyhow::anyhow!("A project name is required."))?
        }
    };
-
    let description = match options.description {
+
    let description = match args.description {
        Some(desc) => desc,
-
        None => term::input("Description", None, Some("You may leave this blank"))?,
+
        None => {
+
            term::input("Description", None, Some("You may leave this blank"))?.unwrap_or_default()
+
        }
    };
-
    let branch = match options.branch {
+
    let branch = match args.branch {
        Some(branch) => branch,
        None if interactive.yes() => term::input(
            "Default branch",
            Some(default_branch),
            Some("Please specify an existing branch"),
-
        )?,
+
        )?
+
        .unwrap_or_default(),
        None => default_branch,
    };
    let branch = RefString::try_from(branch.clone())
        .map_err(|e| anyhow!("invalid branch name {:?}: {}", branch, e))?;
-
    let visibility = if let Some(v) = options.visibility {
+
    let visibility = if let Some(v) = visibility {
        v
    } else {
+
        // TODO(finto): this is interactive without checking `interactive` –
+
        // this should check if interactive and use the `private` if not
        let selected = term::select(
            "Visibility",
            &["public", "private"],
@@ -301,20 +156,20 @@ pub fn init(
            ));
            spinner.finish();

-
            if options.verbose {
+
            if args.verbose {
                term::blob(json::to_string_pretty(&proj)?);
            }
            // It's important to seed our own repositories to make sure that our node signals
            // interest for them. This ensures that messages relating to them are relayed to us.
-
            if options.seed {
-
                profile.seed(rid, options.scope, &mut node)?;
+
            if seed {
+
                profile.seed(rid, args.scope, &mut node)?;

                if doc.is_public() {
                    profile.add_inventory(rid, &mut node)?;
                }
            }

-
            if options.set_upstream || git::branch_remote(&repo, proj.default_branch()).is_err() {
+
            if args.set_upstream || git::branch_remote(&repo, proj.default_branch()).is_err() {
                // Setup eg. `master` -> `rad/master`
                radicle::git::set_upstream(
                    &repo,
@@ -326,7 +181,7 @@ pub fn init(
                push_cmd = format!("git push {} {branch}", *radicle::rad::REMOTE_NAME);
            }

-
            if options.setup_signing {
+
            if args.setup_signing {
                // Setup radicle signing key.
                self::setup_signing(profile.id(), &repo, interactive)?;
            }
@@ -371,12 +226,13 @@ pub fn init(
pub fn init_existing(
    working: git::Repository,
    rid: RepoId,
-
    options: Options,
+
    args: Args,
    profile: &profile::Profile,
) -> anyhow::Result<()> {
    let stored = profile.storage.repository(rid)?;
    let project = stored.project()?;
    let url = radicle::git::Url::from(rid);
+
    let interactive = args.interactive();

    radicle::git::configure_repository(&working)?;
    radicle::git::configure_remote(
@@ -386,7 +242,7 @@ pub fn init_existing(
        &url.clone().with_namespace(profile.public_key),
    )?;

-
    if options.set_upstream {
+
    if args.set_upstream {
        // Setup eg. `master` -> `rad/master`
        radicle::git::set_upstream(
            &working,
@@ -396,6 +252,11 @@ pub fn init_existing(
        )?;
    }

+
    if args.setup_signing {
+
        // Setup radicle signing key.
+
        self::setup_signing(profile.id(), &working, interactive)?;
+
    }
+

    term::success!(
        "Initialized existing repository {} in {}..",
        term::format::tertiary(rid),
@@ -441,7 +302,7 @@ fn sync(
    // Connect to preferred seeds in case we aren't connected.
    for seed in config.preferred_seeds.iter() {
        if !sessions.iter().any(|s| s.nid == seed.id) {
-
            commands::rad_node::control::connect(
+
            commands::node::control::connect(
                node,
                seed.id,
                seed.addr.clone(),
@@ -626,11 +487,13 @@ pub fn setup_signing(
    repo: &git::Repository,
    interactive: Interactive,
) -> anyhow::Result<()> {
-
    let repo = repo
-
        .workdir()
-
        .ok_or(anyhow!("cannot setup signing in bare repository"))?;
+
    const SIGNERS: &str = ".gitsigners";
+

+
    let path = repo.path();
+
    let config = path.join("config");
+

    let key = ssh::fmt::fingerprint(node_id);
-
    let yes = if !git::is_signing_configured(repo)? {
+
    let yes = if !git::is_signing_configured(path)? {
        term::headline(format!(
            "Configuring radicle signing key {}...",
            term::format::tertiary(key)
@@ -638,14 +501,25 @@ pub fn setup_signing(
        true
    } else if interactive.yes() {
        term::confirm(format!(
-
            "Configure radicle signing key {} in local checkout?",
+
            "Configure radicle signing key {} in {}?",
            term::format::tertiary(key),
+
            term::format::tertiary(config.display()),
        ))
    } else {
        true
    };

-
    if yes {
+
    if !yes {
+
        return Ok(());
+
    }
+

+
    git::configure_signing(path, node_id)?;
+
    term::success!(
+
        "Signing configured in {}",
+
        term::format::tertiary(config.display())
+
    );
+

+
    if let Some(repo) = repo.workdir() {
        match git::write_gitsigners(repo, [node_id]) {
            Ok(file) => {
                git::ignore(repo, file.as_path())?;
@@ -654,11 +528,11 @@ pub fn setup_signing(
            }
            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
                let ssh_key = ssh::fmt::key(node_id);
-
                let gitsigners = term::format::tertiary(".gitsigners");
+
                let gitsigners = term::format::tertiary(SIGNERS);
                term::success!("Found existing {} file", gitsigners);

                let ssh_keys =
-
                    git::read_gitsigners(repo).context("error reading .gitsigners file")?;
+
                    git::read_gitsigners(repo).context(format!("error reading {SIGNERS} file"))?;

                if ssh_keys.contains(&ssh_key) {
                    term::success!("Signing key is already in {gitsigners} file");
@@ -670,13 +544,10 @@ pub fn setup_signing(
                return Err(err.into());
            }
        }
-
        git::configure_signing(repo, node_id)?;
-

-
        term::success!(
-
            "Signing configured in {}",
-
            term::format::tertiary(".git/config")
-
        );
+
    } else {
+
        term::notice!("Not writing {SIGNERS} file.")
    }
+

    Ok(())
}

added crates/radicle-cli/src/commands/init/args.rs
@@ -0,0 +1,141 @@
+
use std::path::PathBuf;
+

+
use clap::Parser;
+
use radicle::{
+
    identity::{project::ProjectName, Visibility},
+
    node::policy::Scope,
+
    prelude::RepoId,
+
};
+
use radicle_term::Interactive;
+

+
const ABOUT: &str = "Initialize a Radicle repository";
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    /// Directory to be initialized
+
    pub(super) path: Option<PathBuf>,
+
    /// Name of the repository
+
    #[arg(long)]
+
    pub(super) name: Option<ProjectName>,
+
    /// Description of the repository
+
    #[arg(long)]
+
    pub(super) description: Option<String>,
+
    /// The default branch of the repository
+
    #[arg(long = "default-branch")]
+
    pub(super) branch: Option<String>,
+
    /// Repository follow scope
+
    #[arg(
+
        long,
+
        default_value_t = Scope::All,
+
        value_name = "SCOPE",
+
        value_parser = ScopeParser,
+
    )]
+
    pub(super) scope: Scope,
+
    /// Set repository visibility to *private*
+
    #[arg(long, conflicts_with = "public")]
+
    private: bool,
+
    /// Set repository visibility to *public*
+
    #[arg(long, conflicts_with = "private")]
+
    public: bool,
+
    /// Setup repository as an existing Radicle repository
+
    ///
+
    /// [example values: rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH, z3Tr6bC7ctEg2EHmLvknUr29mEDLH]
+
    #[arg(long, value_name = "RID")]
+
    pub(super) existing: Option<RepoId>,
+
    /// Setup the upstream of the default branch
+
    #[arg(short = 'u', long)]
+
    pub(super) set_upstream: bool,
+
    /// Setup the radicle key as a signing key for this repository
+
    #[arg(long)]
+
    pub(super) setup_signing: bool,
+
    /// Don't ask for confirmation during setup
+
    #[arg(long)]
+
    no_confirm: bool,
+
    /// Don't seed this repository after initializing it
+
    #[arg(long)]
+
    no_seed: bool,
+
    /// Verbose mode
+
    #[arg(short, long)]
+
    pub(super) verbose: bool,
+
}
+

+
impl Args {
+
    pub(super) fn interactive(&self) -> Interactive {
+
        if self.no_confirm {
+
            Interactive::No
+
        } else {
+
            Interactive::Yes
+
        }
+
    }
+

+
    pub(super) fn visibility(&self) -> Option<Visibility> {
+
        if self.private {
+
            debug_assert!(!self.public, "BUG: `private` and `public` should conflict");
+
            Some(Visibility::private([]))
+
        } else if self.public {
+
            Some(Visibility::Public)
+
        } else {
+
            None
+
        }
+
    }
+

+
    pub(super) fn seed(&self) -> bool {
+
        !self.no_seed
+
    }
+
}
+

+
// TODO(finto): this is duplicated from `clone::args`. Consolidate these once
+
// the `clap` migration has finished and we can organise the shared code.
+
#[derive(Clone, Debug)]
+
struct ScopeParser;
+

+
impl clap::builder::TypedValueParser for ScopeParser {
+
    type Value = Scope;
+

+
    fn parse_ref(
+
        &self,
+
        cmd: &clap::Command,
+
        arg: Option<&clap::Arg>,
+
        value: &std::ffi::OsStr,
+
    ) -> Result<Self::Value, clap::Error> {
+
        <Scope as std::str::FromStr>::from_str.parse_ref(cmd, arg, value)
+
    }
+

+
    fn possible_values(
+
        &self,
+
    ) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
+
        use clap::builder::PossibleValue;
+
        Some(Box::new(
+
            [PossibleValue::new("all"), PossibleValue::new("followed")].into_iter(),
+
        ))
+
    }
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use super::Args;
+
    use clap::error::ErrorKind;
+
    use clap::Parser;
+

+
    #[test]
+
    fn should_parse_rid_non_urn() {
+
        let args = Args::try_parse_from(["init", "--existing", "z3Tr6bC7ctEg2EHmLvknUr29mEDLH"]);
+
        assert!(args.is_ok())
+
    }
+

+
    #[test]
+
    fn should_parse_rid_urn() {
+
        let args =
+
            Args::try_parse_from(["init", "--existing", "rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH"]);
+
        assert!(args.is_ok())
+
    }
+

+
    #[test]
+
    fn should_not_parse_rid_url() {
+
        let err =
+
            Args::try_parse_from(["init", "--existing", "rad://z3Tr6bC7ctEg2EHmLvknUr29mEDLH"])
+
                .unwrap_err();
+
        assert_eq!(err.kind(), ErrorKind::ValueValidation);
+
    }
+
}
modified crates/radicle-cli/src/commands/inspect.rs
@@ -1,6 +1,8 @@
#![allow(clippy::or_fun_call)]
+

+
mod args;
+

use std::collections::HashMap;
-
use std::ffi::OsString;
use std::path::Path;
use std::str::FromStr;

@@ -16,134 +18,39 @@ use radicle::storage::refs::RefsAt;
use radicle::storage::{ReadRepository, ReadStorage};

use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
use crate::terminal::json;
use crate::terminal::Element;

-
pub const HELP: Help = Help {
-
    name: "inspect",
-
    description: "Inspect a Radicle repository",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad inspect <path> [<option>...]
-
    rad inspect <rid>  [<option>...]
-
    rad inspect [<option>...]
-

-
    Inspects the given path or RID. If neither is specified,
-
    the current repository is inspected.
-

-
Options
-

-
    --rid        Return the repository identifier (RID)
-
    --payload    Inspect the repository's identity payload
-
    --refs       Inspect the repository's refs on the local device
-
    --sigrefs    Inspect the values of `rad/sigrefs` for all remotes of this repository
-
    --identity   Inspect the identity document
-
    --visibility Inspect the repository's visibility
-
    --delegates  Inspect the repository's delegates
-
    --policy     Inspect the repository's seeding policy
-
    --history    Show the history of the repository identity document
-
    --help       Print help
-
"#,
-
};
-

-
#[derive(Default, Debug, Eq, PartialEq)]
-
pub enum Target {
-
    Refs,
-
    Payload,
-
    Delegates,
-
    Identity,
-
    Visibility,
-
    Sigrefs,
-
    Policy,
-
    History,
-
    #[default]
-
    RepoId,
-
}
-

-
#[derive(Default, Debug, Eq, PartialEq)]
-
pub struct Options {
-
    pub rid: Option<RepoId>,
-
    pub target: Target,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut rid: Option<RepoId> = None;
-
        let mut target = Target::default();
+
pub use args::Args;
+
use args::Target;

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Long("refs") => {
-
                    target = Target::Refs;
-
                }
-
                Long("payload") => {
-
                    target = Target::Payload;
-
                }
-
                Long("policy") => {
-
                    target = Target::Policy;
-
                }
-
                Long("delegates") => {
-
                    target = Target::Delegates;
-
                }
-
                Long("history") => {
-
                    target = Target::History;
-
                }
-
                Long("identity") => {
-
                    target = Target::Identity;
-
                }
-
                Long("sigrefs") => {
-
                    target = Target::Sigrefs;
-
                }
-
                Long("rid") => {
-
                    target = Target::RepoId;
-
                }
-
                Long("visibility") => {
-
                    target = Target::Visibility;
-
                }
-
                Value(val) if rid.is_none() => {
-
                    let val = val.to_string_lossy();
-

-
                    if let Ok(val) = RepoId::from_str(&val) {
-
                        rid = Some(val);
-
                    } else {
-
                        rid = radicle::rad::at(Path::new(val.as_ref()))
-
                            .map(|(_, id)| Some(id))
-
                            .context("Supplied argument is not a valid path")?;
-
                    }
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
+
    let rid = match args.repo {
+
        Some(rid) => {
+
            if let Ok(val) = RepoId::from_str(&rid) {
+
                val
+
            } else {
+
                radicle::rad::at(Path::new(&rid))
+
                    .map(|(_, id)| id)
+
                    .context("Supplied argument is not a valid path")?
            }
        }
-

-
        Ok((Options { rid, target }, vec![]))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
-
    let rid = match options.rid {
-
        Some(rid) => rid,
        None => radicle::rad::cwd()
            .map(|(_, rid)| rid)
            .context("Current directory is not a Radicle repository")?,
    };

-
    if options.target == Target::RepoId {
+
    let target = args.target.into();
+

+
    if matches!(target, Target::RepoId) {
        term::info!("{}", term::format::highlight(rid.urn()));
        return Ok(());
    }
+

    let profile = ctx.profile()?;
    let storage = &profile.storage;

-
    match options.target {
+
    match target {
        Target::Refs => {
            let (repo, _) = repo(rid, storage)?;
            refs(&repo)?;
added crates/radicle-cli/src/commands/inspect/args.rs
@@ -0,0 +1,97 @@
+
use clap::Parser;
+

+
const ABOUT: &str = "Inspect a Radicle repository";
+
const LONG_ABOUT: &str = r#"Inspects the given path or RID. If neither is specified,
+
the current repository is inspected.
+
"#;
+

+
#[derive(Debug, Parser)]
+
#[group(multiple = false)]
+
pub(super) struct TargetArgs {
+
    /// Inspect the repository's delegates
+
    #[arg(long)]
+
    pub(super) delegates: bool,
+

+
    /// Show the history of the repository identity document
+
    #[arg(long)]
+
    pub(super) history: bool,
+

+
    /// Inspect the identity document
+
    #[arg(long)]
+
    pub(super) identity: bool,
+

+
    /// Inspect the repository's identity payload
+
    #[arg(long)]
+
    pub(super) payload: bool,
+

+
    /// Inspect the repository's seeding policy
+
    #[arg(long)]
+
    pub(super) policy: bool,
+

+
    /// Inspect the repository's refs on the local device
+
    #[arg(long)]
+
    pub(super) refs: bool,
+

+
    /// Return the repository identifier (RID)
+
    #[arg(long)]
+
    pub(super) rid: bool,
+

+
    /// Inspect the values of `rad/sigrefs` for all remotes of this repository
+
    #[arg(long)]
+
    pub(super) sigrefs: bool,
+

+
    /// Inspect the repository's visibility
+
    #[arg(long)]
+
    pub(super) visibility: bool,
+
}
+

+
pub(super) enum Target {
+
    Delegates,
+
    History,
+
    Identity,
+
    Payload,
+
    Policy,
+
    Refs,
+
    RepoId,
+
    Sigrefs,
+
    Visibility,
+
}
+

+
impl From<TargetArgs> for Target {
+
    fn from(args: TargetArgs) -> Self {
+
        match (
+
            args.delegates,
+
            args.history,
+
            args.identity,
+
            args.payload,
+
            args.policy,
+
            args.refs,
+
            args.rid,
+
            args.sigrefs,
+
            args.visibility,
+
        ) {
+
            (true, false, false, false, false, false, false, false, false) => Target::Delegates,
+
            (false, true, false, false, false, false, false, false, false) => Target::History,
+
            (false, false, true, false, false, false, false, false, false) => Target::Identity,
+
            (false, false, false, true, false, false, false, false, false) => Target::Payload,
+
            (false, false, false, false, true, false, false, false, false) => Target::Policy,
+
            (false, false, false, false, false, true, false, false, false) => Target::Refs,
+
            (false, false, false, false, false, false, true, false, false)
+
            | (false, false, false, false, false, false, false, false, false) => Target::RepoId,
+
            (false, false, false, false, false, false, false, true, false) => Target::Sigrefs,
+
            (false, false, false, false, false, false, false, false, true) => Target::Visibility,
+
            _ => unreachable!(),
+
        }
+
    }
+
}
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    /// Repository, by RID or by path
+
    #[arg(value_name = "RID|PATH")]
+
    pub(super) repo: Option<String>,
+

+
    #[clap(flatten)]
+
    pub(super) target: TargetArgs,
+
}
modified crates/radicle-cli/src/commands/issue.rs
@@ -1,583 +1,124 @@
-
#[path = "issue/cache.rs"]
+
mod args;
mod cache;
+
mod comment;

-
use std::collections::BTreeSet;
-
use std::ffi::OsString;
-
use std::str::FromStr;
+
use anyhow::Context as _;

-
use anyhow::{anyhow, Context as _};
-

-
use radicle::cob::common::{Label, Reaction};
+
use radicle::cob::common::Label;
use radicle::cob::issue::{CloseReason, State};
-
use radicle::cob::{issue, thread, Title};
+
use radicle::cob::{issue, Title};
+

use radicle::crypto;
-
use radicle::git::Oid;
use radicle::issue::cache::Issues as _;
use radicle::node::device::Device;
use radicle::node::NodeId;
-
use radicle::prelude::{Did, RepoId};
+
use radicle::prelude::Did;
use radicle::profile;
use radicle::storage;
-
use radicle::storage::{ReadRepository, WriteRepository, WriteStorage};
+
use radicle::storage::{WriteRepository, WriteStorage};
use radicle::Profile;
use radicle::{cob, Node};

+
pub use args::Args;
+
use args::{Assigned, Command, CommentAction, StateArg};
+

use crate::git::Rev;
use crate::node;
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
+
use crate::terminal::args::Error;
use crate::terminal::format::Author;
use crate::terminal::issue::Format;
-
use crate::terminal::patch::Message;
use crate::terminal::Element;

-
pub const HELP: Help = Help {
-
    name: "issue",
-
    description: "Manage issues",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad issue [<option>...]
-
    rad issue delete <issue-id> [<option>...]
-
    rad issue edit <issue-id> [--title <title>] [--description <text>] [<option>...]
-
    rad issue list [--assigned <did>] [--all | --closed | --open | --solved] [<option>...]
-
    rad issue open [--title <title>] [--description <text>] [--label <label>] [<option>...]
-
    rad issue react <issue-id> [--emoji <char>] [--to <comment>] [<option>...]
-
    rad issue assign <issue-id> [--add <did>] [--delete <did>] [<option>...]
-
    rad issue label <issue-id> [--add <label>] [--delete <label>] [<option>...]
-
    rad issue comment <issue-id> [--message <message>] [--reply-to <comment-id>] [--edit <comment-id>] [<option>...]
-
    rad issue show <issue-id> [<option>...]
-
    rad issue state <issue-id> [--closed | --open | --solved] [<option>...]
-
    rad issue cache [<issue-id>] [--storage] [<option>...]
-

-
Assign options
-

-
    -a, --add    <did>     Add an assignee to the issue (may be specified multiple times).
-
    -d, --delete <did>     Delete an assignee from the issue (may be specified multiple times).
-

-
    Note: --add takes precedence over --delete
-

-
Label options
-

-
    -a, --add    <label>   Add a label to the issue (may be specified multiple times).
-
    -d, --delete <label>   Delete a label from the issue (may be specified multiple times).
-

-
    Note: --add takes precedence over --delete
-

-
Show options
-

-
    -v, --verbose          Show additional information about the issue
-

-
Options
-

-
        --repo <rid>       Operate on the given repository (default: cwd)
-
        --no-announce      Don't announce issue to peers
-
        --header           Show only the issue header, hiding the comments
-
    -q, --quiet            Don't print anything
-
        --help             Print help
-
"#,
-
};
-

-
#[derive(Default, Debug, PartialEq, Eq)]
-
pub enum OperationName {
-
    Assign,
-
    Edit,
-
    Open,
-
    Comment,
-
    Delete,
-
    Label,
-
    #[default]
-
    List,
-
    React,
-
    Show,
-
    State,
-
    Cache,
-
}
-

-
/// Command line Peer argument.
-
#[derive(Default, Debug, PartialEq, Eq)]
-
pub enum Assigned {
-
    #[default]
-
    Me,
-
    Peer(Did),
-
}
-

-
#[derive(Debug, PartialEq, Eq)]
-
pub enum Operation {
-
    Edit {
-
        id: Rev,
-
        title: Option<Title>,
-
        description: Option<String>,
-
    },
-
    Open {
-
        title: Option<Title>,
-
        description: Option<String>,
-
        labels: Vec<Label>,
-
        assignees: Vec<Did>,
-
    },
-
    Show {
-
        id: Rev,
-
        format: Format,
-
        verbose: bool,
-
    },
-
    CommentEdit {
-
        id: Rev,
-
        comment_id: Rev,
-
        message: Message,
-
    },
-
    Comment {
-
        id: Rev,
-
        message: Message,
-
        reply_to: Option<Rev>,
-
    },
-
    State {
-
        id: Rev,
-
        state: State,
-
    },
-
    Delete {
-
        id: Rev,
-
    },
-
    React {
-
        id: Rev,
-
        reaction: Option<Reaction>,
-
        comment_id: Option<thread::CommentId>,
-
    },
-
    Assign {
-
        id: Rev,
-
        opts: AssignOptions,
-
    },
-
    Label {
-
        id: Rev,
-
        opts: LabelOptions,
-
    },
-
    List {
-
        assigned: Option<Assigned>,
-
        state: Option<State>,
-
    },
-
    Cache {
-
        id: Option<Rev>,
-
        storage: bool,
-
    },
-
}
-

-
#[derive(Debug, Default, PartialEq, Eq)]
-
pub struct AssignOptions {
-
    pub add: BTreeSet<Did>,
-
    pub delete: BTreeSet<Did>,
-
}
-

-
#[derive(Debug, Default, PartialEq, Eq)]
-
pub struct LabelOptions {
-
    pub add: BTreeSet<Label>,
-
    pub delete: BTreeSet<Label>,
-
}
-

-
#[derive(Debug)]
-
pub struct Options {
-
    pub op: Operation,
-
    pub repo: Option<RepoId>,
-
    pub announce: bool,
-
    pub quiet: bool,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut op: Option<OperationName> = None;
-
        let mut id: Option<Rev> = None;
-
        let mut assigned: Option<Assigned> = None;
-
        let mut title: Option<Title> = None;
-
        let mut reaction: Option<Reaction> = None;
-
        let mut comment_id: Option<thread::CommentId> = None;
-
        let mut description: Option<String> = None;
-
        let mut state: Option<State> = Some(State::Open);
-
        let mut labels = Vec::new();
-
        let mut assignees = Vec::new();
-
        let mut format = Format::default();
-
        let mut message = Message::default();
-
        let mut reply_to = None;
-
        let mut edit_comment = None;
-
        let mut announce = true;
-
        let mut quiet = false;
-
        let mut verbose = false;
-
        let mut assign_opts = AssignOptions::default();
-
        let mut label_opts = LabelOptions::default();
-
        let mut repo = None;
-
        let mut cache_storage = false;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-

-
                // List options.
-
                Long("all") if op.is_none() || op == Some(OperationName::List) => {
-
                    state = None;
-
                }
-
                Long("closed") if op.is_none() || op == Some(OperationName::List) => {
-
                    state = Some(State::Closed {
-
                        reason: CloseReason::Other,
-
                    });
-
                }
-
                Long("open") if op.is_none() || op == Some(OperationName::List) => {
-
                    state = Some(State::Open);
-
                }
-
                Long("solved") if op.is_none() || op == Some(OperationName::List) => {
-
                    state = Some(State::Closed {
-
                        reason: CloseReason::Solved,
-
                    });
-
                }
-

-
                // Open/Edit options.
-
                Long("title")
-
                    if op == Some(OperationName::Open) || op == Some(OperationName::Edit) =>
-
                {
-
                    let val = parser.value()?;
-
                    title = Some(term::args::string(&val).try_into()?);
-
                }
-
                Long("description")
-
                    if op == Some(OperationName::Open) || op == Some(OperationName::Edit) =>
-
                {
-
                    description = Some(parser.value()?.to_string_lossy().into());
-
                }
-
                Short('l') | Long("label") if matches!(op, Some(OperationName::Open)) => {
-
                    let val = parser.value()?;
-
                    let name = term::args::string(&val);
-
                    let label = Label::new(name)?;
+
const ABOUT: &str = "Manage issues";

-
                    labels.push(label);
-
                }
-
                Long("assign") if op == Some(OperationName::Open) => {
-
                    let val = parser.value()?;
-
                    let did = term::args::did(&val)?;
-

-
                    assignees.push(did);
-
                }
-

-
                // State options.
-
                Long("closed") if op == Some(OperationName::State) => {
-
                    state = Some(State::Closed {
-
                        reason: CloseReason::Other,
-
                    });
-
                }
-
                Long("open") if op == Some(OperationName::State) => {
-
                    state = Some(State::Open);
-
                }
-
                Long("solved") if op == Some(OperationName::State) => {
-
                    state = Some(State::Closed {
-
                        reason: CloseReason::Solved,
-
                    });
-
                }
-

-
                // React options.
-
                Long("emoji") if op == Some(OperationName::React) => {
-
                    if let Some(emoji) = parser.value()?.to_str() {
-
                        reaction =
-
                            Some(Reaction::from_str(emoji).map_err(|_| anyhow!("invalid emoji"))?);
-
                    }
-
                }
-
                Long("to") if op == Some(OperationName::React) => {
-
                    let oid: String = parser.value()?.to_string_lossy().into();
-
                    comment_id = Some(oid.parse()?);
-
                }
-

-
                // Show options.
-
                Long("format") if op == Some(OperationName::Show) => {
-
                    let val = parser.value()?;
-
                    let val = term::args::string(&val);
-

-
                    match val.as_str() {
-
                        "header" => format = Format::Header,
-
                        "full" => format = Format::Full,
-
                        _ => anyhow::bail!("unknown format '{val}'"),
-
                    }
-
                }
-
                Long("verbose") | Short('v') if op == Some(OperationName::Show) => {
-
                    verbose = true;
-
                }
-

-
                // Comment options.
-
                Long("message") | Short('m') if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let txt = term::args::string(&val);
-

-
                    message.append(&txt);
-
                }
-
                Long("reply-to") if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    reply_to = Some(rev);
-
                }
-
                Long("edit") if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    edit_comment = Some(rev);
-
                }
-

-
                // Assign options
-
                Short('a') | Long("add") if op == Some(OperationName::Assign) => {
-
                    assign_opts.add.insert(term::args::did(&parser.value()?)?);
-
                }
-
                Short('d') | Long("delete") if op == Some(OperationName::Assign) => {
-
                    assign_opts
-
                        .delete
-
                        .insert(term::args::did(&parser.value()?)?);
-
                }
-
                Long("assigned") | Short('a') if assigned.is_none() => {
-
                    if let Ok(val) = parser.value() {
-
                        let peer = term::args::did(&val)?;
-
                        assigned = Some(Assigned::Peer(peer));
-
                    } else {
-
                        assigned = Some(Assigned::Me);
-
                    }
-
                }
-

-
                // Label options
-
                Short('a') | Long("add") if matches!(op, Some(OperationName::Label)) => {
-
                    let val = parser.value()?;
-
                    let name = term::args::string(&val);
-
                    let label = Label::new(name)?;
-

-
                    label_opts.add.insert(label);
-
                }
-
                Short('d') | Long("delete") if matches!(op, Some(OperationName::Label)) => {
-
                    let val = parser.value()?;
-
                    let name = term::args::string(&val);
-
                    let label = Label::new(name)?;
-

-
                    label_opts.delete.insert(label);
-
                }
-

-
                // Cache options.
-
                Long("storage") if matches!(op, Some(OperationName::Cache)) => {
-
                    cache_storage = true;
-
                }
-

-
                // Options.
-
                Long("no-announce") => {
-
                    announce = false;
-
                }
-
                Long("quiet") | Short('q') => {
-
                    quiet = true;
-
                }
-
                Long("repo") => {
-
                    let val = parser.value()?;
-
                    let rid = term::args::rid(&val)?;
-

-
                    repo = Some(rid);
-
                }
-

-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "c" | "comment" => op = Some(OperationName::Comment),
-
                    "w" | "show" => op = Some(OperationName::Show),
-
                    "d" | "delete" => op = Some(OperationName::Delete),
-
                    "e" | "edit" => op = Some(OperationName::Edit),
-
                    "l" | "list" => op = Some(OperationName::List),
-
                    "o" | "open" => op = Some(OperationName::Open),
-
                    "r" | "react" => op = Some(OperationName::React),
-
                    "s" | "state" => op = Some(OperationName::State),
-
                    "assign" => op = Some(OperationName::Assign),
-
                    "label" => op = Some(OperationName::Label),
-
                    "cache" => op = Some(OperationName::Cache),
-

-
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
-
                },
-
                Value(val) if op.is_some() => {
-
                    let val = term::args::rev(&val)?;
-
                    id = Some(val);
-
                }
-
                _ => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
-

-
        let op = match op.unwrap_or_default() {
-
            OperationName::Edit => Operation::Edit {
-
                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                title,
-
                description,
-
            },
-
            OperationName::Open => Operation::Open {
-
                title,
-
                description,
-
                labels,
-
                assignees,
-
            },
-
            OperationName::Comment => match (reply_to, edit_comment) {
-
                (None, None) => Operation::Comment {
-
                    id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                    message,
-
                    reply_to: None,
-
                },
-
                (None, Some(comment_id)) => Operation::CommentEdit {
-
                    id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                    comment_id,
-
                    message,
-
                },
-
                (reply_to @ Some(_), None) => Operation::Comment {
-
                    id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                    message,
-
                    reply_to,
-
                },
-
                (Some(_), Some(_)) => anyhow::bail!("you cannot use --reply-to with --edit"),
-
            },
-
            OperationName::Show => Operation::Show {
-
                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                format,
-
                verbose,
-
            },
-
            OperationName::State => Operation::State {
-
                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                state: state.ok_or_else(|| anyhow!("a state operation must be provided"))?,
-
            },
-
            OperationName::React => Operation::React {
-
                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                reaction,
-
                comment_id,
-
            },
-
            OperationName::Delete => Operation::Delete {
-
                id: id.ok_or_else(|| anyhow!("an issue to remove must be provided"))?,
-
            },
-
            OperationName::Assign => Operation::Assign {
-
                id: id.ok_or_else(|| anyhow!("an issue to label must be provided"))?,
-
                opts: assign_opts,
-
            },
-
            OperationName::Label => Operation::Label {
-
                id: id.ok_or_else(|| anyhow!("an issue to label must be provided"))?,
-
                opts: label_opts,
-
            },
-
            OperationName::List => Operation::List { assigned, state },
-
            OperationName::Cache => Operation::Cache {
-
                id,
-
                storage: cache_storage,
-
            },
-
        };
-

-
        Ok((
-
            Options {
-
                op,
-
                repo,
-
                announce,
-
                quiet,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
-
    let rid = if let Some(rid) = options.repo {
-
        rid
-
    } else {
-
        radicle::rad::cwd().map(|(_, rid)| rid)?
+
    let rid = match args.repo {
+
        Some(rid) => rid,
+
        None => radicle::rad::cwd().map(|(_, rid)| rid)?,
    };
+

    let repo = profile.storage.repository_mut(rid)?;
-
    let announce = options.announce
-
        && matches!(
-
            &options.op,
-
            Operation::Open { .. }
-
                | Operation::React { .. }
-
                | Operation::State { .. }
-
                | Operation::Delete { .. }
-
                | Operation::Assign { .. }
-
                | Operation::Label { .. }
-
                | Operation::Edit { .. }
-
                | Operation::Comment { .. }
-
        );
+

+
    // Fallback to [`Command::List`] if no subcommand is provided.
+
    // Construct it using the [`EmptyArgs`] in `args.empty`.
+
    let command = args
+
        .command
+
        .unwrap_or_else(|| Command::List(args.empty.into()));
+

+
    let announce = !args.no_announce && command.should_announce_for();
    let mut issues = term::cob::issues_mut(&profile, &repo)?;

-
    match options.op {
-
        Operation::Edit {
+
    match command {
+
        Command::Edit {
            id,
            title,
            description,
        } => {
            let signer = term::signer(&profile)?;
            let issue = edit(&mut issues, &repo, id, title, description, &signer)?;
-
            if !options.quiet {
-
                term::issue::show(&issue, issue.id(), Format::Header, false, &profile)?;
+
            if !args.quiet {
+
                term::issue::show(&issue, issue.id(), Format::Header, args.verbose, &profile)?;
            }
        }
-
        Operation::Open {
-
            title: Some(title),
-
            description: Some(description),
+
        Command::Open {
+
            title,
+
            description,
            labels,
            assignees,
        } => {
            let signer = term::signer(&profile)?;
-
            let issue = issues.create(title, description, &labels, &assignees, [], &signer)?;
-
            if !options.quiet {
-
                term::issue::show(&issue, issue.id(), Format::Header, false, &profile)?;
-
            }
+
            open(
+
                title,
+
                description,
+
                labels,
+
                assignees,
+
                args.verbose,
+
                args.quiet,
+
                &mut issues,
+
                &signer,
+
                &profile,
+
            )?;
        }
-
        Operation::Comment {
-
            id,
-
            message,
-
            reply_to,
-
        } => {
-
            let reply_to = reply_to
-
                .map(|rev| rev.resolve::<radicle::git::Oid>(repo.raw()))
-
                .transpose()?;
-

-
            let signer = term::signer(&profile)?;
-
            let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
-
            let mut issue = issues.get_mut(&issue_id)?;
-

-
            let (root_comment_id, _) = issue.root();
-
            let body = prompt_comment(message, issue.thread(), reply_to, None)?;
-
            let comment_id =
-
                issue.comment(body, reply_to.unwrap_or(*root_comment_id), vec![], &signer)?;
-

-
            if options.quiet {
-
                term::print(comment_id);
-
            } else {
-
                let comment = issue.thread().comment(&comment_id).unwrap();
-
                term::comment::widget(&comment_id, comment, &profile).print();
+
        Command::Comment(c) => match CommentAction::from(c) {
+
            CommentAction::Comment { id, message } => {
+
                comment::comment(&profile, &repo, &mut issues, id, message, None, args.quiet)?;
            }
-
        }
-
        Operation::CommentEdit {
-
            id,
-
            comment_id,
-
            message,
-
        } => {
-
            let signer = term::signer(&profile)?;
-
            let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
-
            let comment_id = comment_id.resolve(&repo.backend)?;
-
            let mut issue = issues.get_mut(&issue_id)?;
-

-
            let comment = issue
-
                .thread()
-
                .comment(&comment_id)
-
                .ok_or(anyhow::anyhow!("comment '{comment_id}' not found"))?;
-

-
            let body = prompt_comment(
+
            CommentAction::Reply {
+
                id,
                message,
-
                issue.thread(),
-
                comment.reply_to(),
-
                Some(comment.body()),
-
            )?;
-
            issue.edit_comment(comment_id, body, vec![], &signer)?;
-

-
            if options.quiet {
-
                term::print(comment_id);
+
                reply_to,
+
            } => comment::comment(
+
                &profile,
+
                &repo,
+
                &mut issues,
+
                id,
+
                message,
+
                Some(reply_to),
+
                args.quiet,
+
            )?,
+
            CommentAction::Edit {
+
                id,
+
                message,
+
                to_edit,
+
            } => comment::edit(
+
                &profile,
+
                &repo,
+
                &mut issues,
+
                id,
+
                message,
+
                to_edit,
+
                args.quiet,
+
            )?,
+
        },
+
        Command::Show { id } => {
+
            let format = if args.header {
+
                term::issue::Format::Header
            } else {
-
                let comment = issue.thread().comment(&comment_id).unwrap();
-
                term::comment::widget(&comment_id, comment, &profile).print();
-
            }
-
        }
-
        Operation::Show {
-
            id,
-
            format,
-
            verbose,
-
        } => {
+
                term::issue::Format::Full
+
            };
+

            let id = id.resolve(&repo.backend)?;
            let issue = issues
                .get(&id)
@@ -586,14 +127,17 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                    hint: "reset the cache with `rad issue cache` and try again",
                })?
                .context("No issue with the given ID exists")?;
-
            term::issue::show(&issue, &id, format, verbose, &profile)?;
+
            term::issue::show(&issue, &id, format, args.verbose, &profile)?;
        }
-
        Operation::State { id, state } => {
-
            let signer = term::signer(&profile)?;
+
        Command::State { id, target_state } => {
+
            let to: StateArg = target_state.into();
            let id = id.resolve(&repo.backend)?;
+
            let signer = term::signer(&profile)?;
            let mut issue = issues.get_mut(&id)?;
+
            let state = to.into();
            issue.lifecycle(state, &signer)?;
-
            if !options.quiet {
+

+
            if !args.quiet {
                let success =
                    |status| term::success!("Issue {} is now {status}", term::format::cob(&id));
                match state {
@@ -605,7 +149,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                };
            }
        }
-
        Operation::React {
+
        Command::React {
            id,
            reaction,
            comment_id,
@@ -614,39 +158,17 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            if let Ok(mut issue) = issues.get_mut(&id) {
                let signer = term::signer(&profile)?;
                let comment_id = match comment_id {
-
                    Some(cid) => cid,
+
                    Some(cid) => cid.resolve(&repo.backend)?,
                    None => *term::io::comment_select(&issue).map(|(cid, _)| cid)?,
                };
                let reaction = match reaction {
                    Some(reaction) => reaction,
                    None => term::io::reaction_select()?,
                };
-
                // SAFETY: reaction is never None here.
                issue.react(comment_id, reaction, true, &signer)?;
            }
        }
-
        Operation::Open {
-
            ref title,
-
            ref description,
-
            ref labels,
-
            ref assignees,
-
        } => {
-
            let signer = term::signer(&profile)?;
-
            open(
-
                title.clone(),
-
                description.clone(),
-
                labels.to_vec(),
-
                assignees.to_vec(),
-
                &options,
-
                &mut issues,
-
                &signer,
-
                &profile,
-
            )?;
-
        }
-
        Operation::Assign {
-
            id,
-
            opts: AssignOptions { add, delete },
-
        } => {
+
        Command::Assign { id, add, delete } => {
            let signer = term::signer(&profile)?;
            let id = id.resolve(&repo.backend)?;
            let Ok(mut issue) = issues.get_mut(&id) else {
@@ -660,11 +182,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                .collect::<Vec<_>>();
            issue.assign(assignees, &signer)?;
        }
-
        Operation::Label {
-
            id,
-
            opts: LabelOptions { add, delete },
-
        } => {
-
            let signer = term::signer(&profile)?;
+
        Command::Label { id, add, delete } => {
            let id = id.resolve(&repo.backend)?;
            let Ok(mut issue) = issues.get_mut(&id) else {
                anyhow::bail!("Issue `{id}` not found");
@@ -675,17 +193,24 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                .chain(add.iter())
                .cloned()
                .collect::<Vec<_>>();
+
            let signer = term::signer(&profile)?;
            issue.label(labels, &signer)?;
        }
-
        Operation::List { assigned, state } => {
-
            list(issues, &assigned, &state, &profile)?;
+
        Command::List(list_args) => {
+
            list(
+
                issues,
+
                &list_args.assigned,
+
                &((&list_args.state).into()),
+
                &profile,
+
                args.verbose,
+
            )?;
        }
-
        Operation::Delete { id } => {
-
            let signer = term::signer(&profile)?;
+
        Command::Delete { id } => {
            let id = id.resolve(&repo.backend)?;
+
            let signer = term::signer(&profile)?;
            issues.remove(&id, &signer)?;
        }
-
        Operation::Cache { id, storage } => {
+
        Command::Cache { id, storage } => {
            let mode = if storage {
                cache::CacheMode::Storage
            } else {
@@ -720,6 +245,7 @@ fn list<C>(
    assigned: &Option<Assigned>,
    state: &Option<State>,
    profile: &profile::Profile,
+
    verbose: bool,
) -> anyhow::Result<()>
where
    C: issue::cache::Issues,
@@ -783,11 +309,11 @@ where
    ]);
    table.divider();

-
    for (id, issue) in all {
+
    table.extend(all.into_iter().map(|(id, issue)| {
        let assigned: String = issue
            .assignees()
            .map(|did| {
-
                let (alias, _) = Author::new(did.as_key(), profile, false).labels();
+
                let (alias, _) = Author::new(did.as_key(), profile, verbose).labels();

                alias.content().to_owned()
            })
@@ -798,48 +324,61 @@ where
        labels.sort();

        let author = issue.author().id;
-
        let (alias, did) = Author::new(&author, profile, false).labels();
-

-
        table.push([
-
            match issue.state() {
-
                State::Open => term::format::positive("●").into(),
-
                State::Closed { .. } => term::format::negative("●").into(),
-
            },
-
            term::format::tertiary(term::format::cob(&id))
-
                .to_owned()
-
                .into(),
-
            term::format::default(issue.title().to_owned()).into(),
-
            alias.into(),
-
            did.into(),
-
            term::format::secondary(labels.join(", ")).into(),
-
            if assigned.is_empty() {
-
                term::format::dim(String::default()).into()
-
            } else {
-
                term::format::primary(assigned.to_string()).dim().into()
-
            },
-
            term::format::timestamp(issue.timestamp())
-
                .dim()
-
                .italic()
-
                .into(),
-
        ]);
-
    }
+
        let (alias, did) = Author::new(&author, profile, verbose).labels();
+

+
        mk_issue_row(id, issue, assigned, labels, alias, did)
+
    }));
+

    table.print();

    Ok(())
}

+
fn mk_issue_row(
+
    id: cob::ObjectId,
+
    issue: issue::Issue,
+
    assigned: String,
+
    labels: Vec<String>,
+
    alias: radicle_term::Label,
+
    did: radicle_term::Label,
+
) -> [radicle_term::Line; 8] {
+
    [
+
        match issue.state() {
+
            State::Open => term::format::positive("●").into(),
+
            State::Closed { .. } => term::format::negative("●").into(),
+
        },
+
        term::format::tertiary(term::format::cob(&id))
+
            .to_owned()
+
            .into(),
+
        term::format::default(issue.title().to_owned()).into(),
+
        alias.into(),
+
        did.into(),
+
        term::format::secondary(labels.join(", ")).into(),
+
        if assigned.is_empty() {
+
            term::format::dim(String::default()).into()
+
        } else {
+
            term::format::primary(assigned.to_string()).dim().into()
+
        },
+
        term::format::timestamp(issue.timestamp())
+
            .dim()
+
            .italic()
+
            .into(),
+
    ]
+
}
+

fn open<R, G>(
    title: Option<Title>,
    description: Option<String>,
    labels: Vec<Label>,
    assignees: Vec<Did>,
-
    options: &Options,
+
    verbose: bool,
+
    quiet: bool,
    cache: &mut issue::Cache<issue::Issues<'_, R>, cob::cache::StoreWriter>,
    signer: &Device<G>,
    profile: &Profile,
) -> anyhow::Result<()>
where
-
    R: ReadRepository + WriteRepository + cob::Store<Namespace = NodeId>,
+
    R: WriteRepository + cob::Store<Namespace = NodeId>,
    G: crypto::signature::Signer<crypto::Signature>,
{
    let (title, description) = if let (Some(t), Some(d)) = (title.as_ref(), description.as_ref()) {
@@ -858,8 +397,8 @@ where
        signer,
    )?;

-
    if !options.quiet {
-
        term::issue::show(&issue, issue.id(), Format::Header, false, profile)?;
+
    if !quiet {
+
        term::issue::show(&issue, issue.id(), Format::Header, verbose, profile)?;
    }
    Ok(())
}
@@ -873,7 +412,7 @@ fn edit<'a, 'g, R, G>(
    signer: &Device<G>,
) -> anyhow::Result<issue::IssueMut<'a, 'g, R, cob::cache::StoreWriter>>
where
-
    R: WriteRepository + ReadRepository + cob::Store<Namespace = NodeId>,
+
    R: WriteRepository + cob::Store<Namespace = NodeId>,
    G: crypto::signature::Signer<crypto::Signature>,
{
    let id = id.resolve(&repo.backend)?;
@@ -897,7 +436,7 @@ where

    // Editing via the editor.
    let Some((title, description)) = term::issue::get_title_description(
-
        title.and(Title::new(issue.title()).ok()),
+
        title.or_else(|| Title::new(issue.title()).ok()),
        Some(description.unwrap_or(issue.description().to_owned())),
    )?
    else {
@@ -913,94 +452,3 @@ where

    Ok(issue)
}
-

-
/// Get a comment from the user, by prompting.
-
pub fn prompt_comment(
-
    message: Message,
-
    thread: &thread::Thread,
-
    mut reply_to: Option<Oid>,
-
    edit: Option<&str>,
-
) -> anyhow::Result<String> {
-
    let (chase, missing) = {
-
        let mut chase = Vec::with_capacity(thread.len());
-
        let mut missing = None;
-

-
        while let Some(id) = reply_to {
-
            if let Some(comment) = thread.comment(&id) {
-
                chase.push(comment);
-
                reply_to = comment.reply_to();
-
            } else {
-
                missing = reply_to;
-
                break;
-
            }
-
        }
-

-
        (chase, missing)
-
    };
-

-
    let quotes = if chase.is_empty() {
-
        ""
-
    } else {
-
        "Quotes (lines starting with '>') will be preserved. Please remove those that you do not intend to keep.\n"
-
    };
-

-
    let mut buffer = term::format::html::commented(format!("HTML comments, such as this one, are deleted before posting.\n{quotes}Saving an empty file aborts the operation.").as_str());
-
    buffer.push('\n');
-

-
    for comment in chase.iter().rev() {
-
        buffer.reserve(2);
-
        buffer.push('\n');
-
        comment_quoted(comment, &mut buffer);
-
    }
-

-
    if let Some(id) = missing {
-
        buffer.push('\n');
-
        buffer.push_str(
-
            term::format::html::commented(
-
                format!("The comment with ID {id} that was replied to could not be found.")
-
                    .as_str(),
-
            )
-
            .as_str(),
-
        );
-
    }
-

-
    if let Some(edit) = edit {
-
        if !chase.is_empty() {
-
            buffer.push_str(
-
                "\n<!-- The contents of the comment you are editing follow below this line. -->\n",
-
            );
-
        }
-
        buffer.reserve(2 + edit.len());
-
        buffer.push('\n');
-
        buffer.push_str(edit);
-
    }
-

-
    let body = message.get(&buffer)?;
-

-
    if body.is_empty() {
-
        anyhow::bail!("aborting operation due to empty comment");
-
    }
-
    Ok(body)
-
}
-

-
fn comment_quoted(comment: &thread::Comment, buffer: &mut String) {
-
    let body = comment.body();
-
    let lines = body.lines();
-

-
    let hint = {
-
        let (lower, upper) = lines.size_hint();
-
        upper.unwrap_or(lower)
-
    };
-

-
    buffer.push_str(format!("{} wrote:\n", comment.author()).as_str());
-
    buffer.reserve(body.len() + hint * 2);
-

-
    for line in lines {
-
        buffer.push('>');
-
        if !line.is_empty() {
-
            buffer.push(' ');
-
        }
-
        buffer.push_str(line);
-
        buffer.push('\n');
-
    }
-
}
added crates/radicle-cli/src/commands/issue/args.rs
@@ -0,0 +1,461 @@
+
use std::str::FromStr;
+

+
use clap::{Parser, Subcommand};
+

+
use radicle::{
+
    cob::{Label, Reaction, Title},
+
    identity::{did::DidError, Did, RepoId},
+
    issue::{CloseReason, State},
+
};
+

+
use crate::{git::Rev, terminal::patch::Message};
+

+
#[derive(Default, Debug, Clone, PartialEq, Eq)]
+
pub enum Assigned {
+
    #[default]
+
    Me,
+
    Peer(Did),
+
}
+

+
#[derive(Parser, Debug)]
+
#[command(about = super::ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    #[command(subcommand)]
+
    pub(crate) command: Option<Command>,
+

+
    /// Do not print anything
+
    #[arg(short, long)]
+
    #[clap(global = true)]
+
    pub(crate) quiet: bool,
+

+
    /// Do not announce issue changes to the network
+
    #[arg(long)]
+
    #[arg(value_name = "no-announce")]
+
    #[clap(global = true)]
+
    pub(crate) no_announce: bool,
+

+
    /// Show only the issue header, hiding the comments
+
    #[arg(long)]
+
    #[clap(global = true)]
+
    pub(crate) header: bool,
+

+
    /// Operate on the given repository (default: cwd)
+
    #[arg(value_name = "RID")]
+
    #[arg(long, short)]
+
    #[clap(global = true)]
+
    pub(crate) repo: Option<RepoId>,
+

+
    /// Enable verbose output
+
    #[arg(long, short)]
+
    #[clap(global = true)]
+
    pub(crate) verbose: bool,
+

+
    /// Arguments for the empty subcommand.
+
    /// Will fall back to [`Command::List`].
+
    #[clap(flatten)]
+
    pub(crate) empty: EmptyArgs,
+
}
+

+
#[derive(Subcommand, Debug)]
+
pub(crate) enum Command {
+
    /// Add or delete assignees from an issue
+
    Assign {
+
        /// ID of the issue
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Rev,
+

+
        /// Add an assignee (may be specified multiple times, takes precedence over `--delete`)
+
        #[arg(long, short)]
+
        #[arg(value_name = "DID")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        add: Vec<Did>,
+

+
        /// Delete an assignee (may be specified multiple times)
+
        #[arg(long, short)]
+
        #[arg(value_name = "DID")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        delete: Vec<Did>,
+
    },
+
    /// Re-cache all issues that can be found in Radicle storage
+
    Cache {
+
        /// Optionally choose an issue to re-cache
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Option<Rev>,
+

+
        /// Operate on storage
+
        #[arg(long)]
+
        storage: bool,
+
    },
+
    /// Add a comment to an issue
+
    #[clap(long_about = include_str!("comment.txt"))]
+
    Comment(CommentArgs),
+
    /// Edit the title and description of an issue
+
    Edit {
+
        /// ID of the issue
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Rev,
+

+
        /// The new title to set
+
        #[arg(long, short)]
+
        title: Option<Title>,
+

+
        /// The new description to set
+
        #[arg(long, short)]
+
        description: Option<String>,
+
    },
+
    /// Delete an issue
+
    Delete {
+
        /// ID of the issue
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Rev,
+
    },
+
    /// Add or delete labels from an issue
+
    Label {
+
        /// ID of the issue
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Rev,
+

+
        /// Add a label (may be specified multiple times, takes precedence over `--delete`)
+
        #[arg(long, short)]
+
        #[arg(value_name = "label")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        add: Vec<Label>,
+

+
        /// Delete a label (may be specified multiple times)
+
        #[arg(long, short)]
+
        #[arg(value_name = "label")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        delete: Vec<Label>,
+
    },
+
    /// List issues, optionally filtering them
+
    List(ListArgs),
+
    /// Open a new issue
+
    Open {
+
        /// The title of the issue
+
        #[arg(long, short)]
+
        title: Option<Title>,
+

+
        /// The description of the issue
+
        #[arg(long, short)]
+
        description: Option<String>,
+

+
        /// A set of labels to associate with the issue
+
        #[arg(long)]
+
        labels: Vec<Label>,
+

+
        /// A set of DIDs to assign to the issue
+
        #[arg(value_name = "DID")]
+
        #[arg(long)]
+
        assignees: Vec<Did>,
+
    },
+
    /// Add a reaction emoji to an issue or comment
+
    React {
+
        /// ID of the issue
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Rev,
+

+
        /// The emoji reaction
+
        #[arg(long = "emoji")]
+
        #[arg(value_name = "CHAR")]
+
        reaction: Option<Reaction>,
+

+
        /// Optionally react to a comment
+
        #[arg(long = "to")]
+
        #[arg(value_name = "COMMENT_ID")]
+
        comment_id: Option<Rev>,
+
    },
+
    /// Show a specific issue
+
    Show {
+
        /// ID of the issue
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Rev,
+
    },
+
    /// Transition the state of an issue
+
    State {
+
        /// ID of the issue
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Rev,
+

+
        /// The desired target state
+
        #[clap(flatten)]
+
        target_state: StateArgs,
+
    },
+
}
+

+
impl Command {
+
    /// Returns `true` if the changes made by the command should announce to the
+
    /// network.
+
    pub(crate) fn should_announce_for(&self) -> bool {
+
        match self {
+
            Command::Open { .. }
+
            | Command::React { .. }
+
            | Command::State { .. }
+
            | Command::Delete { .. }
+
            | Command::Assign { .. }
+
            | Command::Label { .. }
+
            // Special handling for `--edit` will be removed in the future.
+
            | Command::Edit { .. } => true,
+
            Command::Comment(args) => !args.is_edit(),
+
            _ => false,
+
        }
+
    }
+
}
+

+
/// Arguments for the empty subcommand.
+
#[derive(Parser, Debug, Default)]
+
pub(crate) struct EmptyArgs {
+
    #[arg(long, name = "DID")]
+
    #[arg(default_missing_value = "me")]
+
    #[arg(num_args = 0..=1)]
+
    #[arg(hide = true)]
+
    pub(crate) assigned: Option<Assigned>,
+

+
    #[clap(flatten)]
+
    pub(crate) state: EmptyStateArgs,
+
}
+

+
/// Counterpart to [`ListStateArgs`] for the empty subcommand.
+
#[derive(Parser, Debug, Default)]
+
#[group(multiple = false)]
+
pub(crate) struct EmptyStateArgs {
+
    #[arg(long, hide = true)]
+
    all: bool,
+

+
    #[arg(long, hide = true)]
+
    open: bool,
+

+
    #[arg(long, hide = true)]
+
    closed: bool,
+

+
    #[arg(long, hide = true)]
+
    solved: bool,
+
}
+

+
/// Arguments for the [`Command::List`] subcommand.
+
#[derive(Parser, Debug, Default)]
+
pub(crate) struct ListArgs {
+
    /// Filter for the list of issues that are assigned to '<DID>' (default: me)
+
    #[arg(long, name = "DID")]
+
    #[arg(default_missing_value = "me")]
+
    #[arg(num_args = 0..=1)]
+
    pub(crate) assigned: Option<Assigned>,
+

+
    #[clap(flatten)]
+
    pub(crate) state: ListStateArgs,
+
}
+

+
#[derive(Parser, Debug, Default)]
+
#[group(multiple = false)]
+
pub(crate) struct ListStateArgs {
+
    /// List all issues
+
    #[arg(long)]
+
    all: bool,
+

+
    /// List only open issues (default)
+
    #[arg(long)]
+
    open: bool,
+

+
    /// List only closed issues
+
    #[arg(long)]
+
    closed: bool,
+

+
    /// List only solved issues
+
    #[arg(long)]
+
    solved: bool,
+
}
+

+
impl From<&ListStateArgs> for Option<State> {
+
    fn from(args: &ListStateArgs) -> Self {
+
        match (args.all, args.open, args.closed, args.solved) {
+
            (true, false, false, false) => None,
+
            (false, true, false, false) | (false, false, false, false) => Some(State::Open),
+
            (false, false, true, false) => Some(State::Closed {
+
                reason: CloseReason::Other,
+
            }),
+
            (false, false, false, true) => Some(State::Closed {
+
                reason: CloseReason::Solved,
+
            }),
+
            _ => unreachable!(),
+
        }
+
    }
+
}
+

+
impl From<EmptyStateArgs> for ListStateArgs {
+
    fn from(args: EmptyStateArgs) -> Self {
+
        Self {
+
            all: args.all,
+
            open: args.open,
+
            closed: args.closed,
+
            solved: args.solved,
+
        }
+
    }
+
}
+

+
impl From<EmptyArgs> for ListArgs {
+
    fn from(args: EmptyArgs) -> Self {
+
        Self {
+
            assigned: args.assigned,
+
            state: ListStateArgs::from(args.state),
+
        }
+
    }
+
}
+

+
/// Arguments for the [`Command::Comment`] subcommand.
+
#[derive(Parser, Debug)]
+
pub(crate) struct CommentArgs {
+
    /// ID of the issue
+
    #[arg(value_name = "ISSUE_ID")]
+
    id: Rev,
+

+
    /// The body of the comment
+
    #[arg(long, short)]
+
    #[arg(value_name = "MESSAGE")]
+
    message: Message,
+

+
    /// Optionally, the comment to reply to. If not specified, the comment
+
    /// will be in reply to the issue itself
+
    #[arg(long, value_name = "COMMENT_ID")]
+
    #[arg(conflicts_with = "edit")]
+
    reply_to: Option<Rev>,
+

+
    /// Edit a comment by specifying its ID
+
    #[arg(long, value_name = "COMMENT_ID")]
+
    #[arg(conflicts_with = "reply_to")]
+
    edit: Option<Rev>,
+
}
+

+
impl CommentArgs {
+
    // TODO(finto): this is only needed to avoid announcing edits for the time
+
    // being
+
    /// If the comment is editing an existing comment
+
    pub(crate) fn is_edit(&self) -> bool {
+
        self.edit.is_some()
+
    }
+
}
+

+
/// Arguments for the [`Command::State`] subcommand.
+
#[derive(Parser, Debug)]
+
#[group(required = true, multiple = false)]
+
pub(crate) struct StateArgs {
+
    /// Change the state to 'open'
+
    #[arg(long)]
+
    pub(crate) open: bool,
+

+
    /// Change the state to 'closed'
+
    #[arg(long)]
+
    pub(crate) closed: bool,
+

+
    /// Change the state to 'solved'
+
    #[arg(long)]
+
    pub(crate) solved: bool,
+
}
+

+
impl From<StateArgs> for StateArg {
+
    fn from(state: StateArgs) -> Self {
+
        // These are mutually exclusive, guaranteed by clap grouping
+
        match (state.open, state.closed, state.solved) {
+
            (true, _, _) => StateArg::Open,
+
            (_, true, _) => StateArg::Closed,
+
            (_, _, true) => StateArg::Solved,
+
            _ => unreachable!(),
+
        }
+
    }
+
}
+

+
/// Argument value for transition an issue to the given [`State`].
+
#[derive(Clone, Copy, Debug)]
+
pub(crate) enum StateArg {
+
    /// Open issues.
+
    /// Maps to [`State::Open`].
+
    Open,
+
    /// Closed issues.
+
    /// Maps to [`State::Closed`] and [`CloseReason::Other`].
+
    Closed,
+
    /// Solved issues.
+
    /// Maps to [`State::Closed`] and [`CloseReason::Solved`].
+
    Solved,
+
}
+

+
impl From<StateArg> for State {
+
    fn from(value: StateArg) -> Self {
+
        match value {
+
            StateArg::Open => Self::Open,
+
            StateArg::Closed => Self::Closed {
+
                reason: CloseReason::Other,
+
            },
+
            StateArg::Solved => Self::Closed {
+
                reason: CloseReason::Solved,
+
            },
+
        }
+
    }
+
}
+

+
impl FromStr for Assigned {
+
    type Err = DidError;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        if s == "me" {
+
            Ok(Assigned::Me)
+
        } else {
+
            let value = s.parse::<Did>()?;
+
            Ok(Assigned::Peer(value))
+
        }
+
    }
+
}
+

+
/// The action that should be performed based on the supplied [`CommentArgs`].
+
pub(crate) enum CommentAction {
+
    /// Comment to the main issue thread.
+
    Comment {
+
        /// ID of the issue
+
        id: Rev,
+
        /// The message of the comment.
+
        message: Message,
+
    },
+
    /// Reply to a specific comment in the issue.
+
    Reply {
+
        /// ID of the issue
+
        id: Rev,
+
        /// The message that is being used to reply to the comment.
+
        message: Message,
+
        /// The comment ID that is being replied to.
+
        reply_to: Rev,
+
    },
+
    /// Edit a specific comment in the issue.
+
    Edit {
+
        /// ID of the issue
+
        id: Rev,
+
        /// The message that is being used to edit the comment.
+
        message: Message,
+
        /// The comment ID that is being edited.
+
        to_edit: Rev,
+
    },
+
}
+

+
impl From<CommentArgs> for CommentAction {
+
    fn from(
+
        CommentArgs {
+
            id,
+
            message,
+
            reply_to,
+
            edit,
+
        }: CommentArgs,
+
    ) -> Self {
+
        match (reply_to, edit) {
+
            (Some(_), Some(_)) => {
+
                unreachable!("the argument '--reply-to' cannot be used with '--edit'")
+
            }
+
            (Some(reply_to), None) => Self::Reply {
+
                id,
+
                message,
+
                reply_to,
+
            },
+
            (None, Some(to_edit)) => Self::Edit {
+
                id,
+
                message,
+
                to_edit,
+
            },
+
            (None, None) => Self::Comment { id, message },
+
        }
+
    }
+
}
added crates/radicle-cli/src/commands/issue/comment.rs
@@ -0,0 +1,166 @@
+
use radicle::cob::thread;
+
use radicle::storage::WriteRepository;
+
use radicle::Profile;
+
use radicle::{cob, git, issue, storage};
+

+
use crate::git::Rev;
+
use crate::terminal as term;
+
use crate::terminal::patch::Message;
+
use crate::terminal::Element as _;
+

+
pub(super) fn comment(
+
    profile: &Profile,
+
    repo: &storage::git::Repository,
+
    issues: &mut issue::Cache<
+
        issue::Issues<'_, storage::git::Repository>,
+
        cob::cache::Store<cob::cache::Write>,
+
    >,
+
    id: Rev,
+
    message: Message,
+
    reply_to: Option<Rev>,
+
    quiet: bool,
+
) -> Result<(), anyhow::Error> {
+
    let reply_to = reply_to
+
        .map(|rev| rev.resolve::<git::Oid>(repo.raw()))
+
        .transpose()?;
+
    let signer = term::signer(profile)?;
+
    let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
+
    let mut issue = issues.get_mut(&issue_id)?;
+
    let (root_comment_id, _) = issue.root();
+
    let body = prompt_comment(message, issue.thread(), reply_to, None)?;
+
    let comment_id = issue.comment(body, reply_to.unwrap_or(*root_comment_id), vec![], &signer)?;
+
    if quiet {
+
        term::print(comment_id);
+
    } else {
+
        let comment = issue.thread().comment(&comment_id).unwrap();
+
        term::comment::widget(&comment_id, comment, profile).print();
+
    }
+
    Ok(())
+
}
+

+
pub(super) fn edit(
+
    profile: &Profile,
+
    repo: &storage::git::Repository,
+
    issues: &mut issue::Cache<
+
        issue::Issues<'_, storage::git::Repository>,
+
        cob::cache::Store<cob::cache::Write>,
+
    >,
+
    id: Rev,
+
    message: Message,
+
    comment_id: Rev,
+
    quiet: bool,
+
) -> Result<(), anyhow::Error> {
+
    let signer = term::signer(profile)?;
+
    let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
+
    let comment_id = comment_id.resolve(&repo.backend)?;
+
    let mut issue = issues.get_mut(&issue_id)?;
+
    let comment = issue
+
        .thread()
+
        .comment(&comment_id)
+
        .ok_or(anyhow::anyhow!("comment '{comment_id}' not found"))?;
+
    let body = prompt_comment(
+
        message,
+
        issue.thread(),
+
        comment.reply_to(),
+
        Some(comment.body()),
+
    )?;
+
    issue.edit_comment(comment_id, body, vec![], &signer)?;
+
    if quiet {
+
        term::print(comment_id);
+
    } else {
+
        let comment = issue.thread().comment(&comment_id).unwrap();
+
        term::comment::widget(&comment_id, comment, profile).print();
+
    }
+
    Ok(())
+
}
+

+
/// Get a comment from the user, by prompting.
+
fn prompt_comment(
+
    message: Message,
+
    thread: &thread::Thread,
+
    mut reply_to: Option<git::Oid>,
+
    edit: Option<&str>,
+
) -> anyhow::Result<String> {
+
    let (chase, missing) = {
+
        let mut chase = Vec::with_capacity(thread.len());
+
        let mut missing = None;
+
        while let Some(id) = reply_to {
+
            if let Some(comment) = thread.comment(&id) {
+
                chase.push(comment);
+
                reply_to = comment.reply_to();
+
            } else {
+
                missing = reply_to;
+
                break;
+
            }
+
        }
+

+
        (chase, missing)
+
    };
+

+
    let quotes = if chase.is_empty() {
+
        ""
+
    } else {
+
        "Quotes (lines starting with '>') will be preserved. Please remove those that you do not intend to keep.\n"
+
    };
+

+
    let mut buffer = term::format::html::commented(format!("HTML comments, such as this one, are deleted before posting.\n{quotes}Saving an empty file aborts the operation.").as_str());
+
    buffer.push('\n');
+

+
    for comment in chase.iter().rev() {
+
        buffer.reserve(2);
+
        buffer.push('\n');
+
        comment_quoted(comment, &mut buffer);
+
    }
+

+
    if let Some(id) = missing {
+
        buffer.push('\n');
+
        buffer.push_str(
+
            term::format::html::commented(
+
                format!("The comment with ID {id} that was replied to could not be found.")
+
                    .as_str(),
+
            )
+
            .as_str(),
+
        );
+
    }
+

+
    if let Some(edit) = edit {
+
        if !chase.is_empty() {
+
            buffer.push_str(
+
                "\n<!-- The contents of the comment you are editing follow below this line. -->\n",
+
            );
+
        }
+

+
        buffer.reserve(2 + edit.len());
+
        buffer.push('\n');
+
        buffer.push_str(edit);
+
    }
+

+
    let body = message.get(&buffer)?;
+
    if body.is_empty() {
+
        anyhow::bail!("aborting operation due to empty comment");
+
    }
+

+
    Ok(body)
+
}
+

+
fn comment_quoted(comment: &thread::Comment, buffer: &mut String) {
+
    let body = comment.body();
+
    let lines = body.lines();
+
    let hint = {
+
        let (lower, upper) = lines.size_hint();
+
        upper.unwrap_or(lower)
+
    };
+

+
    buffer.push_str(format!("{} wrote:\n", comment.author()).as_str());
+
    buffer.reserve(body.len() + hint * 2);
+

+
    for line in lines {
+
        buffer.push('>');
+
        if !line.is_empty() {
+
            buffer.push(' ');
+
        }
+

+
        buffer.push_str(line);
+
        buffer.push('\n');
+
    }
+
}
added crates/radicle-cli/src/commands/issue/comment.txt
@@ -0,0 +1,9 @@
+
Comment on an issue, or comment in reply to an earlier comment on the issue.
+

+
Every issue can be viewed as a tree of comments, with the initial issue description at the root.
+

+
Discussions about the issue can be organized in sub-trees, by using `--reply-to`.
+

+
As a fallback, when `--reply-to` is not used, the comment will be in response to the issue description itself.
+

+
Using `--edit` preserves this structure of replies.

\ No newline at end of file
modified crates/radicle-cli/src/commands/ls.rs
@@ -1,91 +1,14 @@
-
use std::ffi::OsString;
+
mod args;
+

+
pub use args::Args;

use radicle::storage::{ReadStorage, RepositoryInfo};

use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};

use term::Element;

-
pub const HELP: Help = Help {
-
    name: "ls",
-
    description: "List repositories",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad ls [<option>...]
-

-
    By default, this command shows you all repositories that you have forked or initialized.
-
    If you wish to see all seeded repositories, use the `--seeded` option.
-

-
Options
-

-
    --private       Show only private repositories
-
    --public        Show only public repositories
-
    --seeded, -s    Show all seeded repositories
-
    --all, -a       Show all repositories in storage
-
    --verbose, -v   Verbose output
-
    --help          Print help
-
"#,
-
};
-

-
pub struct Options {
-
    #[allow(dead_code)]
-
    verbose: bool,
-
    public: bool,
-
    private: bool,
-
    all: bool,
-
    seeded: bool,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut verbose = false;
-
        let mut private = false;
-
        let mut public = false;
-
        let mut all = false;
-
        let mut seeded = false;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Long("all") | Short('a') => {
-
                    all = true;
-
                }
-
                Long("seeded") | Short('s') => {
-
                    seeded = true;
-
                }
-
                Long("private") => {
-
                    private = true;
-
                }
-
                Long("public") => {
-
                    public = true;
-
                }
-
                Long("verbose") | Short('v') => verbose = true,
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                verbose,
-
                private,
-
                public,
-
                all,
-
                seeded,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let storage = &profile.storage;
    let repos = storage.repositories()?;
@@ -105,21 +28,21 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        ..
    } in repos
    {
-
        if doc.is_public() && options.private && !options.public {
+
        if doc.is_public() && args.private {
            continue;
        }
-
        if !doc.is_public() && !options.private && options.public {
+
        if !doc.is_public() && args.public {
            continue;
        }
-
        if refs.is_none() && !options.all && !options.seeded {
+
        if refs.is_none() && !args.all && !args.seeded {
            continue;
        }
        let seeded = policy.is_seeding(&rid)?;

-
        if !seeded && !options.all {
+
        if !seeded && !args.all {
            continue;
        }
-
        if !seeded && options.seeded {
+
        if !seeded && args.seeded {
            continue;
        }
        let proj = match doc.project() {
added crates/radicle-cli/src/commands/ls/args.rs
@@ -0,0 +1,27 @@
+
use clap::Parser;
+

+
const ABOUT: &str = "List repositories";
+
const LONG_ABOUT: &str = r#"
+
By default, this command shows you all repositories that you have forked or initialized.
+
If you wish to see all seeded repositories, use the `--seeded` option.
+
"#;
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    /// Show only private repositories
+
    #[arg(long, conflicts_with = "public")]
+
    pub(super) private: bool,
+
    /// Show only public repositories
+
    #[arg(long)]
+
    pub(super) public: bool,
+
    /// Show all seeded repositories
+
    #[arg(short, long)]
+
    pub(super) seeded: bool,
+
    /// Show all repositories in storage
+
    #[arg(short, long)]
+
    pub(super) all: bool,
+
    /// Verbose output
+
    #[arg(short, long)]
+
    pub(super) verbose: bool,
+
}
modified crates/radicle-cli/src/commands/node.rs
@@ -1,298 +1,51 @@
-
use std::ffi::OsString;
-
use std::path::PathBuf;
-
use std::str::FromStr;
-
use std::time;
+
mod args;
+
mod commands;
+
pub mod control;
+
mod events;
+
mod logs;
+
pub mod routing;

-
use anyhow::anyhow;
+
use std::{process, time};

use radicle::node::address::Store as AddressStore;
use radicle::node::config::ConnectAddress;
use radicle::node::routing::Store;
use radicle::node::Handle as _;
-
use radicle::node::{Address, Node, NodeId, PeerAddr};
-
use radicle::prelude::RepoId;
+
use radicle::node::Node;

+
use crate::commands::node::args::Only;
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
use crate::terminal::Element as _;
+
use crate::warning;

-
#[path = "node/commands.rs"]
-
mod commands;
-
#[path = "node/control.rs"]
-
pub mod control;
-
#[path = "node/events.rs"]
-
mod events;
-
#[path = "node/routing.rs"]
-
pub mod routing;
-

-
pub const HELP: Help = Help {
-
    name: "node",
-
    description: "Control and query the Radicle Node",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad node status [<option>...]
-
    rad node start [--foreground] [--verbose] [<option>...] [-- <node-option>...]
-
    rad node stop [<option>...]
-
    rad node logs [-n <lines>]
-
    rad node debug [<option>...]
-
    rad node connect <nid>[@<addr>] [<option>...]
-
    rad node routing [--rid <rid>] [--nid <nid>] [--json] [<option>...]
-
    rad node inventory [--nid <nid>] [<option>...]
-
    rad node events [--timeout <secs>] [-n <count>] [<option>...]
-
    rad node config [--addresses]
-
    rad node db <command> [<option>..]
-

-
    For `<node-option>` see `radicle-node --help`.
-

-
Start options
-

-
    --foreground         Start the node in the foreground
-
    --path <path>        Start node binary at path (default: radicle-node)
-
    --verbose, -v        Verbose output
-

-
Routing options
-

-
    --rid <rid>          Show the routing table entries for the given RID
-
    --nid <nid>          Show the routing table entries for the given NID
-
    --json               Output the routing table as json
-

-
Inventory options
-

-
    --nid <nid>          List the inventory of the given NID (default: self)
-

-
Events options
-

-
    --timeout <secs>     How long to wait to receive an event before giving up
-
    --count, -n <count>  Exit after <count> events
-

-
General options
-

-
    --help               Print help
-
"#,
-
};
-

-
pub struct Options {
-
    op: Operation,
-
}
-

-
/// Address used for the [`Operation::Connect`]
-
pub enum Addr {
-
    /// Fully-specified address of the form `<NID>@<Address>`
-
    Peer(PeerAddr<NodeId, Address>),
-
    /// Just the `NID`, to be used for address lookups.
-
    Node(NodeId),
-
}
-

-
impl FromStr for Addr {
-
    type Err = anyhow::Error;
-

-
    fn from_str(s: &str) -> Result<Self, Self::Err> {
-
        if s.contains("@") {
-
            PeerAddr::from_str(s)
-
                .map(Self::Peer)
-
                .map_err(|e| anyhow!("expected <nid> or <nid>@<addr>: {e}"))
-
        } else {
-
            NodeId::from_str(s)
-
                .map(Self::Node)
-
                .map_err(|e| anyhow!("expected <nid> or <nid>@<addr>: {e}"))
-
        }
-
    }
-
}
-

-
pub enum Operation {
-
    Connect {
-
        addr: Addr,
-
        timeout: time::Duration,
-
    },
-
    Config {
-
        addresses: bool,
-
    },
-
    Db {
-
        args: Vec<OsString>,
-
    },
-
    Events {
-
        timeout: time::Duration,
-
        count: usize,
-
    },
-
    Routing {
-
        json: bool,
-
        rid: Option<RepoId>,
-
        nid: Option<NodeId>,
-
    },
-
    Start {
-
        foreground: bool,
-
        verbose: bool,
-
        path: PathBuf,
-
        options: Vec<OsString>,
-
    },
-
    Logs {
-
        lines: usize,
-
    },
-
    Status,
-
    Inventory {
-
        nid: Option<NodeId>,
-
    },
-
    Debug,
-
    Sessions,
-
    Stop,
-
}
+
pub use args::Args;
+
use args::{Addr, Command};

-
#[derive(Default, PartialEq, Eq)]
-
pub enum OperationName {
-
    Connect,
-
    Config,
-
    Db,
-
    Events,
-
    Routing,
-
    Logs,
-
    Start,
-
    #[default]
-
    Status,
-
    Inventory,
-
    Debug,
-
    Sessions,
-
    Stop,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut foreground = false;
-
        let mut options = vec![];
-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut op: Option<OperationName> = None;
-
        let mut nid: Option<NodeId> = None;
-
        let mut rid: Option<RepoId> = None;
-
        let mut json: bool = false;
-
        let mut addr: Option<Addr> = None;
-
        let mut lines: usize = 60;
-
        let mut count: usize = usize::MAX;
-
        let mut timeout = time::Duration::MAX;
-
        let mut addresses = false;
-
        let mut path = None;
-
        let mut verbose = false;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "connect" => op = Some(OperationName::Connect),
-
                    "db" => op = Some(OperationName::Db),
-
                    "events" => op = Some(OperationName::Events),
-
                    "logs" => op = Some(OperationName::Logs),
-
                    "config" => op = Some(OperationName::Config),
-
                    "routing" => op = Some(OperationName::Routing),
-
                    "inventory" => op = Some(OperationName::Inventory),
-
                    "start" => op = Some(OperationName::Start),
-
                    "status" => op = Some(OperationName::Status),
-
                    "stop" => op = Some(OperationName::Stop),
-
                    "sessions" => op = Some(OperationName::Sessions),
-
                    "debug" => op = Some(OperationName::Debug),
-

-
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
-
                },
-
                Value(val) if matches!(op, Some(OperationName::Connect)) => {
-
                    addr = Some(val.parse()?);
-
                }
-
                Long("rid") if matches!(op, Some(OperationName::Routing)) => {
-
                    let val = parser.value()?;
-
                    rid = term::args::rid(&val).ok();
-
                }
-
                Long("nid")
-
                    if matches!(op, Some(OperationName::Routing))
-
                        || matches!(op, Some(OperationName::Inventory)) =>
-
                {
-
                    let val = parser.value()?;
-
                    nid = term::args::nid(&val).ok();
-
                }
-
                Long("json") if matches!(op, Some(OperationName::Routing)) => json = true,
-
                Long("timeout")
-
                    if op == Some(OperationName::Events) || op == Some(OperationName::Connect) =>
-
                {
-
                    let val = parser.value()?;
-
                    timeout = term::args::seconds(&val)?;
-
                }
-
                Long("count") | Short('n') if matches!(op, Some(OperationName::Events)) => {
-
                    let val = parser.value()?;
-
                    count = term::args::number(&val)?;
-
                }
-
                Long("foreground") if matches!(op, Some(OperationName::Start)) => {
-
                    foreground = true;
-
                }
-
                Long("addresses") if matches!(op, Some(OperationName::Config)) => {
-
                    addresses = true;
-
                }
-
                Long("verbose") | Short('v') if matches!(op, Some(OperationName::Start)) => {
-
                    verbose = true;
-
                }
-
                Long("path") if matches!(op, Some(OperationName::Start)) => {
-
                    let val = parser.value()?;
-
                    path = Some(PathBuf::from(val));
-
                }
-
                Short('n') if matches!(op, Some(OperationName::Logs)) => {
-
                    lines = parser.value()?.parse()?;
-
                }
-
                Value(val) if matches!(op, Some(OperationName::Start)) => {
-
                    options.push(val);
-
                }
-
                Value(val) if matches!(op, Some(OperationName::Db)) => {
-
                    options.push(val);
-
                }
-
                _ => return Err(anyhow!(arg.unexpected())),
-
            }
-
        }
-

-
        let op = match op.unwrap_or_default() {
-
            OperationName::Connect => Operation::Connect {
-
                addr: addr.ok_or_else(|| {
-
                    anyhow!("an `<nid>` or an address of the form `<nid>@<host>:<port>` must be provided")
-
                })?,
-
                timeout,
-
            },
-
            OperationName::Config => Operation::Config { addresses },
-
            OperationName::Db => Operation::Db { args: options },
-
            OperationName::Events => Operation::Events { timeout, count },
-
            OperationName::Routing => Operation::Routing { rid, nid, json },
-
            OperationName::Logs => Operation::Logs { lines },
-
            OperationName::Start => Operation::Start {
-
                foreground,
-
                verbose,
-
                options,
-
                path: path.unwrap_or(PathBuf::from("radicle-node")),
-
            },
-
            OperationName::Inventory => Operation::Inventory { nid },
-
            OperationName::Status => Operation::Status,
-
            OperationName::Debug => Operation::Debug,
-
            OperationName::Sessions => Operation::Sessions,
-
            OperationName::Stop => Operation::Stop,
-
        };
-
        Ok((Options { op }, vec![]))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let mut node = Node::new(profile.socket());

-
    match options.op {
-
        Operation::Connect { addr, timeout } => match addr {
-
            Addr::Peer(addr) => control::connect(&mut node, addr.id, addr.addr, timeout)?,
-
            Addr::Node(nid) => {
-
                let db = profile.database()?;
-
                let addresses = db
-
                    .addresses_of(&nid)?
-
                    .into_iter()
-
                    .map(|ka| ka.addr)
-
                    .collect();
-
                control::connect_many(&mut node, nid, addresses, timeout)?;
+
    let command = args.command.unwrap_or_default();
+

+
    match command {
+
        Command::Connect { addr, timeout } => {
+
            let timeout = timeout
+
                .map(time::Duration::from_secs)
+
                .unwrap_or(time::Duration::MAX);
+
            match addr {
+
                Addr::Peer(addr) => control::connect(&mut node, addr.id, addr.addr, timeout)?,
+
                Addr::Node(nid) => {
+
                    let db = profile.database()?;
+
                    let addresses = db
+
                        .addresses_of(&nid)?
+
                        .into_iter()
+
                        .map(|ka| ka.addr)
+
                        .collect();
+
                    control::connect_many(&mut node, nid, addresses, timeout)?;
+
                }
            }
-
        },
-
        Operation::Config { addresses } => {
+
        }
+
        Command::Config { addresses } => {
            if addresses {
                let cfg = node.config()?;
                for addr in cfg.external_addresses {
@@ -302,27 +55,33 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                control::config(&node)?;
            }
        }
-
        Operation::Db { args } => {
-
            commands::db(&profile, args)?;
+
        Command::Db(op) => {
+
            commands::db(&profile, op)?;
        }
-
        Operation::Debug => {
+
        Command::Debug => {
            control::debug(&mut node)?;
        }
-
        Operation::Sessions => {
+
        Command::Sessions => {
+
            warning::deprecated("rad node sessions", "rad node status");
            let sessions = control::sessions(&node)?;
            if let Some(table) = sessions {
                table.print();
            }
        }
-
        Operation::Events { timeout, count } => {
+
        Command::Events { timeout, count } => {
+
            let count = count.unwrap_or(usize::MAX);
+
            let timeout = timeout
+
                .map(time::Duration::from_secs)
+
                .unwrap_or(time::Duration::MAX);
+

            events::run(node, count, timeout)?;
        }
-
        Operation::Routing { rid, nid, json } => {
+
        Command::Routing { rid, nid, json } => {
            let store = profile.database()?;
            routing::run(&store, rid, nid, json)?;
        }
-
        Operation::Logs { lines } => control::logs(lines, Some(time::Duration::MAX), &profile)?,
-
        Operation::Start {
+
        Command::Logs { lines } => control::logs(lines, Some(time::Duration::MAX), &profile)?,
+
        Command::Start {
            foreground,
            options,
            path,
@@ -330,16 +89,25 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        } => {
            control::start(node, !foreground, verbose, options, &path, &profile)?;
        }
-
        Operation::Inventory { nid } => {
+
        Command::Inventory { nid } => {
            let nid = nid.as_ref().unwrap_or(profile.id());
            for rid in profile.routing()?.get_inventory(nid)? {
                println!("{}", term::format::tertiary(rid));
            }
        }
-
        Operation::Status => {
+
        Command::Status {
+
            only: Some(Only::Nid),
+
        } => {
+
            if node.is_running() {
+
                term::print(term::format::node_id_human(&node.nid()?));
+
            } else {
+
                process::exit(2);
+
            }
+
        }
+
        Command::Status { only: None } => {
            control::status(&node, &profile)?;
        }
-
        Operation::Stop => {
+
        Command::Stop => {
            control::stop(node, &profile);
        }
    }
added crates/radicle-cli/src/commands/node/args.rs
@@ -0,0 +1,231 @@
+
use std::ffi::OsString;
+
use std::fmt::Debug;
+
use std::path::PathBuf;
+
use std::str::FromStr;
+

+
use thiserror::Error;
+

+
use clap::{Parser, Subcommand};
+

+
use radicle::crypto::{PublicKey, PublicKeyError};
+
use radicle::node::{Address, NodeId, PeerAddr, PeerAddrParseError};
+
use radicle::prelude::RepoId;
+

+
const ABOUT: &str = "Control and query the Radicle Node";
+

+
#[derive(Parser, Debug)]
+
#[command(about = ABOUT, long_about, disable_version_flag = true)]
+
pub struct Args {
+
    #[command(subcommand)]
+
    pub(super) command: Option<Command>,
+
}
+

+
/// Address used for the [`Operation::Connect`]
+
#[derive(Clone, Debug)]
+
pub(super) enum Addr {
+
    /// Fully-specified address of the form `<NID>@<ADDR>`
+
    Peer(PeerAddr<NodeId, Address>),
+
    /// Just the `NID`, to be used for address lookups.
+
    Node(NodeId),
+
}
+

+
#[derive(Error, Debug)]
+
pub(super) enum AddrParseError {
+
    #[error("{0}, expected <NID> or <NID>@<ADDR>")]
+
    PeerAddr(#[from] PeerAddrParseError<PublicKey>),
+
    #[error(transparent)]
+
    NodeId(#[from] PublicKeyError),
+
}
+

+
impl FromStr for Addr {
+
    type Err = AddrParseError;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        if s.contains("@") {
+
            PeerAddr::from_str(s)
+
                .map(Self::Peer)
+
                .map_err(AddrParseError::PeerAddr)
+
        } else {
+
            NodeId::from_str(s)
+
                .map(Self::Node)
+
                .map_err(AddrParseError::NodeId)
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum Only {
+
    Nid,
+
}
+

+
#[derive(Error, Debug)]
+
#[error("could not parse value `{0}`")]
+
pub struct OnlyParseError(String);
+

+
impl FromStr for Only {
+
    type Err = OnlyParseError;
+

+
    fn from_str(value: &str) -> Result<Self, Self::Err> {
+
        match value {
+
            "nid" => Ok(Only::Nid),
+
            _ => Err(OnlyParseError(value.to_string())),
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
struct OnlyParser;
+

+
impl clap::builder::TypedValueParser for OnlyParser {
+
    type Value = Only;
+

+
    fn parse_ref(
+
        &self,
+
        cmd: &clap::Command,
+
        arg: Option<&clap::Arg>,
+
        value: &std::ffi::OsStr,
+
    ) -> Result<Self::Value, clap::Error> {
+
        <Only as std::str::FromStr>::from_str.parse_ref(cmd, arg, value)
+
    }
+

+
    fn possible_values(
+
        &self,
+
    ) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
+
        use clap::builder::PossibleValue;
+
        Some(Box::new([PossibleValue::new("nid")].into_iter()))
+
    }
+
}
+

+
#[derive(Subcommand, Debug)]
+
pub(super) enum Command {
+
    /// Instruct the node to connect to another node
+
    Connect {
+
        /// The Node ID, and optionally the address and port, of the node to connect to
+
        #[arg(value_name = "NID[@ADDR]")]
+
        addr: Addr,
+

+
        /// How long to wait for the connection to be established
+
        #[arg(long, value_name = "SECS")]
+
        timeout: Option<u64>,
+
    },
+

+
    /// Show the config
+
    Config {
+
        /// Only show external addresses from the node's config
+
        #[arg(long)]
+
        addresses: bool,
+
    },
+

+
    /// Interact with the node database
+
    #[command(subcommand, hide = true)]
+
    Db(DbOperation),
+

+
    /// Watch and print events.
+
    ///
+
    /// This command will connect to the node and print events to
+
    /// standard output as they occur.
+
    ///
+
    /// If no timeout or count is specified, it will run indefinitely.
+
    Events {
+
        /// How long to wait to receive an event before giving up
+
        #[arg(long, value_name = "SECS")]
+
        timeout: Option<u64>,
+

+
        /// Exit after <COUNT> events
+
        #[arg(long, short = 'n')]
+
        count: Option<usize>,
+
    },
+

+
    /// Show the routing table
+
    Routing {
+
        /// Output the routing table as json
+
        #[arg(long)]
+
        json: bool,
+

+
        /// Show the routing table entries for the given RID
+
        #[arg(long)]
+
        rid: Option<RepoId>,
+

+
        /// Show the routing table entries for the given NID
+
        #[arg(long)]
+
        nid: Option<NodeId>,
+
    },
+

+
    /// Start the node
+
    Start {
+
        /// Start the node in the foreground
+
        #[arg(long)]
+
        foreground: bool,
+

+
        /// Verbose output
+
        #[arg(long, short)]
+
        verbose: bool,
+

+
        /// Start node binary at path
+
        #[arg(long, default_value = "radicle-node")]
+
        path: PathBuf,
+

+
        /// Additional options to pass to the binary
+
        ///
+
        /// See `radicle-node --help` for additional options
+
        #[arg(value_name = "NODE_OPTIONS", last = true, num_args = 1..)]
+
        options: Vec<OsString>,
+
    },
+

+
    /// Show the log
+
    Logs {
+
        /// Only show <COUNT> lines of the log
+
        #[arg(long, value_name = "COUNT", default_value_t = 60)]
+
        lines: usize,
+
    },
+

+
    /// Show the status
+
    Status {
+
        /// If node is running, only print the Node ID and exit, otherwise exit with a non-zero exit status.
+
        #[arg(long, value_parser = OnlyParser)]
+
        only: Option<Only>,
+
    },
+

+
    /// Manage the inventory
+
    Inventory {
+
        /// List the inventory of the given NID, defaults to `self`
+
        #[arg(long)]
+
        nid: Option<NodeId>,
+
    },
+

+
    /// Show debug information related to the running node.
+
    ///
+
    /// This includes metrics fetching, peer connections, rate limiting, etc.
+
    Debug,
+

+
    /// Show the active sessions of the running node.
+
    ///
+
    /// Deprecated, use `status` instead.
+
    #[command(hide = true)]
+
    Sessions,
+

+
    /// Stop the node
+
    Stop,
+
}
+

+
impl Default for Command {
+
    fn default() -> Self {
+
        Command::Status { only: None }
+
    }
+
}
+

+
/// Operations related to the [`Command::Db`]
+
#[derive(Debug, Subcommand)]
+
pub(super) enum DbOperation {
+
    /// Execute an SQL operation on the local node database.
+
    ///
+
    /// The command only returns the number of rows that are affected by the
+
    /// query. This means that `SELECT` queries will not return their output.
+
    ///
+
    /// The command should only be used for executing queries given you know
+
    /// what you are doing.
+
    Exec {
+
        #[arg(value_name = "SQL")]
+
        query: String,
+
    },
+
}
modified crates/radicle-cli/src/commands/node/commands.rs
@@ -1,39 +1,11 @@
-
use std::ffi::OsString;
-

-
use anyhow::anyhow;
use radicle::Profile;
use radicle_term as term;

-
#[derive(PartialEq, Eq)]
-
pub enum Operation {
-
    Exec { query: String },
-
}
-

-
pub fn db(profile: &Profile, args: Vec<OsString>) -> anyhow::Result<()> {
-
    use lexopt::prelude::*;
-

-
    let mut parser = lexopt::Parser::from_args(args);
-
    let mut op: Option<Operation> = None;
-

-
    while let Some(arg) = parser.next()? {
-
        match arg {
-
            Value(cmd) if op.is_none() => match cmd.to_string_lossy().as_ref() {
-
                "exec" => {
-
                    let val = parser
-
                        .value()
-
                        .map_err(|_| anyhow!("a query to execute must be provided for `exec`"))?;
-
                    op = Some(Operation::Exec {
-
                        query: val.to_string_lossy().to_string(),
-
                    });
-
                }
-
                unknown => anyhow::bail!("unknown operation '{unknown}'"),
-
            },
-
            _ => return Err(anyhow!(arg.unexpected())),
-
        }
-
    }
+
use super::args::DbOperation;

-
    match op.ok_or_else(|| anyhow!("a command must be provided, eg. `rad node db exec`"))? {
-
        Operation::Exec { query } => {
+
pub fn db(profile: &Profile, op: DbOperation) -> anyhow::Result<()> {
+
    match op {
+
        DbOperation::Exec { query } => {
            let db = profile.database_mut()?;
            db.execute(query)?;

modified crates/radicle-cli/src/commands/node/control.rs
@@ -1,6 +1,3 @@
-
mod logs;
-
use logs::{LogRotatorFileSystem, Rotated};
-

use std::collections::HashMap;
use std::ffi::OsString;
use std::fs::File;
@@ -12,9 +9,11 @@ use localtime::LocalTime;

use radicle::node;
use radicle::node::{Address, ConnectResult, Handle as _, NodeId};
+
use radicle::profile::env::RAD_PASSPHRASE;
use radicle::Node;
use radicle::{profile, Profile};

+
use crate::commands::node::logs::{LogRotatorFileSystem, Rotated};
use crate::terminal as term;
use crate::terminal::Element as _;

@@ -39,10 +38,12 @@ pub fn start(
        let validator = term::io::PassphraseValidator::new(profile.keystore.clone());
        let passphrase = if let Some(phrase) = profile::env::passphrase() {
            phrase
-
        } else if let Ok(phrase) = term::io::passphrase(validator) {
+
        } else if let Some(phrase) = term::io::passphrase(validator)? {
            phrase
        } else {
-
            anyhow::bail!("your radicle passphrase is required to start your node");
+
            anyhow::bail!(
+
                "A passphrase is required to read your Radicle key in order to start the node. Unable to continue. Consider setting the environment variable `{RAD_PASSPHRASE}`."
+
            );
        };
        Some((profile::env::RAD_PASSPHRASE, passphrase))
    } else {
@@ -104,7 +105,7 @@ pub fn start(
    } else {
        // Write a hint to the log file, but swallow any errors.
        let mut log_file = log_file;
-
        let _ = log_file.write_all(format!("radicle-node started in foreground, no futher log messages are written to '{}' (this file).\n", log_path.display()).as_bytes());
+
        let _ = log_file.write_all(format!("radicle-node started in foreground, no further log messages are written to '{}' (this file).\n", log_path.display()).as_bytes());

        let mut child = process::Command::new(cmd)
            .args(options)
@@ -260,23 +261,7 @@ pub fn status(node: &Node, profile: &Profile) -> anyhow::Result<()> {
        term::warning(warning);
    }

-
    if node.is_running() {
-
        let listen = node
-
            .listen_addrs()?
-
            .into_iter()
-
            .map(|addr| addr.to_string())
-
            .collect::<Vec<_>>();
-

-
        if listen.is_empty() {
-
            term::success!("Node is {}.", term::format::positive("running"));
-
        } else {
-
            term::success!(
-
                "Node is {} and listening on {}.",
-
                term::format::positive("running"),
-
                listen.join(", ")
-
            );
-
        }
-
    } else {
+
    if !node.is_running() {
        term::info!("Node is {}.", term::format::negative("stopped"));
        term::info!(
            "To start it, run {}.",
@@ -285,6 +270,35 @@ pub fn status(node: &Node, profile: &Profile) -> anyhow::Result<()> {
        return Ok(());
    }

+
    let listen = node
+
        .listen_addrs()?
+
        .into_iter()
+
        .map(|addr| addr.to_string())
+
        .collect::<Vec<_>>();
+

+
    let nid = node.nid()?;
+
    let nid = if &nid == profile.id() {
+
        term::format::tertiary(term::format::node_id_human(&nid))
+
    } else {
+
        term::format::yellow(term::format::node_id_human(&nid)).bold()
+
    };
+

+
    if listen.is_empty() {
+
        term::success!(
+
            "Node is {} with Node ID {} and {} configured to listen for inbound connections.",
+
            term::format::positive("running"),
+
            nid,
+
            term::Paint::new("not").italic()
+
        );
+
    } else {
+
        term::success!(
+
            "Node is {} with Node ID {} and listening for inbound connections on {}.",
+
            term::format::positive("running"),
+
            nid,
+
            listen.join(", ")
+
        );
+
    }
+

    let sessions = sessions(node)?;
    if let Some(table) = sessions {
        term::blank();
@@ -361,7 +375,7 @@ pub fn sessions(node: &Node) -> Result<Option<term::Table<5, term::Label>>, node
    ]);
    table.divider();

-
    for sess in sessions {
+
    table.extend(sessions.into_iter().map(|sess| {
        let nid = term::format::tertiary(term::format::node_id_human(&sess.nid)).into();
        let (addr, state, time) = match sess.state {
            node::State::Initial => (
@@ -389,8 +403,10 @@ pub fn sessions(node: &Node) -> Result<Option<term::Table<5, term::Label>>, node
            node::Link::Inbound => term::Label::from(link_direction_inbound()),
            node::Link::Outbound => term::Label::from(link_direction_outbound()),
        };
-
        table.push([nid, addr, state, direction, time]);
-
    }
+

+
        [nid, addr, state, direction, time]
+
    }));
+

    Ok(Some(table))
}

modified crates/radicle-cli/src/commands/patch.rs
@@ -1,47 +1,28 @@
-
#[path = "patch/archive.rs"]
mod archive;
-
#[path = "patch/assign.rs"]
+
mod args;
mod assign;
-
#[path = "patch/cache.rs"]
mod cache;
-
#[path = "patch/checkout.rs"]
mod checkout;
-
#[path = "patch/comment.rs"]
mod comment;
-
#[path = "patch/delete.rs"]
mod delete;
-
#[path = "patch/diff.rs"]
mod diff;
-
#[path = "patch/edit.rs"]
mod edit;
-
#[path = "patch/label.rs"]
mod label;
-
#[path = "patch/list.rs"]
mod list;
-
#[path = "patch/react.rs"]
mod react;
-
#[path = "patch/ready.rs"]
mod ready;
-
#[path = "patch/redact.rs"]
mod redact;
-
#[path = "patch/resolve.rs"]
mod resolve;
-
#[path = "patch/review.rs"]
mod review;
-
#[path = "patch/show.rs"]
mod show;
-
#[path = "patch/update.rs"]
mod update;

use std::collections::BTreeSet;
-
use std::ffi::OsString;
-
use std::str::FromStr as _;

use anyhow::anyhow;

use radicle::cob::patch::PatchId;
-
use radicle::cob::{patch, Label, Reaction};
-
use radicle::git::RefString;
+
use radicle::cob::{patch, Label};
use radicle::patch::cache::Patches as _;
use radicle::storage::git::transport;
use radicle::{prelude::*, Node};
@@ -49,811 +30,14 @@ use radicle::{prelude::*, Node};
use crate::git::Rev;
use crate::node;
use crate::terminal as term;
-
use crate::terminal::args::{string, Args, Error, Help};
use crate::terminal::patch::Message;

-
pub const HELP: Help = Help {
-
    name: "patch",
-
    description: "Manage patches",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
+
pub use args::Args;

-
    rad patch [<option>...]
-
    rad patch list [--all|--merged|--open|--archived|--draft|--authored] [--author <did>]... [<option>...]
-
    rad patch show <patch-id> [<option>...]
-
    rad patch diff <patch-id> [<option>...]
-
    rad patch archive <patch-id> [--undo] [<option>...]
-
    rad patch update <patch-id> [<option>...]
-
    rad patch checkout <patch-id> [<option>...]
-
    rad patch review <patch-id> [--accept | --reject] [-m [<string>]] [-d | --delete] [<option>...]
-
    rad patch resolve <patch-id> [--review <review-id>] [--comment <comment-id>] [--unresolve] [<option>...]
-
    rad patch delete <patch-id> [<option>...]
-
    rad patch redact <revision-id> [<option>...]
-
    rad patch react <patch-id | revision-id> [--react <emoji>] [<option>...]
-
    rad patch assign <revision-id> [--add <did>] [--delete <did>] [<option>...]
-
    rad patch label <revision-id> [--add <label>] [--delete <label>] [<option>...]
-
    rad patch ready <patch-id> [--undo] [<option>...]
-
    rad patch edit <patch-id> [<option>...]
-
    rad patch set <patch-id> [<option>...]
-
    rad patch comment <patch-id | revision-id> [<option>...]
-
    rad patch cache [<patch-id>] [--storage] [<option>...]
+
use args::{AssignArgs, Command, CommentAction, LabelArgs};

-
Show options
-

-
    -p, --patch                Show the actual patch diff
-
    -v, --verbose              Show additional information about the patch
-

-
Diff options
-

-
    -r, --revision <id>        The revision to diff (default: latest)
-

-
Comment options
-

-
    -m, --message <string>     Provide a comment message via the command-line
-
        --reply-to <comment>   The comment to reply to
-
        --edit <comment>       The comment to edit (use --message to edit with the provided message)
-
        --react <comment>      The comment to react to
-
        --emoji <char>         The emoji to react with when --react is used
-
        --redact <comment>     The comment to redact
-

-
Edit options
-

-
    -m, --message [<string>]   Provide a comment message to the patch or revision (default: prompt)
-

-
Review options
-

-
    -r, --revision <id>        Review the given revision of the patch
-
    -p, --patch                Review by patch hunks
-
        --hunk <index>         Only review a specific hunk
-
        --accept               Accept a patch or set of hunks
-
        --reject               Reject a patch or set of hunks
-
    -U, --unified <n>          Generate diffs with <n> lines of context instead of the usual three
-
    -d, --delete               Delete a review draft
-
    -m, --message [<string>]   Provide a comment with the review (default: prompt)
-

-
Resolve options
-

-
    --review <id>              The review id which the comment is under
-
    --comment <id>             The comment to (un)resolve
-
    --undo                     Unresolve the comment
-

-
Assign options
-

-
    -a, --add    <did>         Add an assignee to the patch (may be specified multiple times).
-
                               Note: --add will take precedence over --delete
-

-
    -d, --delete <did>         Delete an assignee from the patch (may be specified multiple times).
-
                               Note: --add will take precedence over --delete
-

-
Archive options
-

-
        --undo                 Unarchive a patch
-

-
Label options
-

-
    -a, --add    <label>       Add a label to the patch (may be specified multiple times).
-
                               Note: --add will take precedence over --delete
-

-
    -d, --delete <label>       Delete a label from the patch (may be specified multiple times).
-
                               Note: --add will take precedence over --delete
-

-
Update options
-

-
    -b, --base <revspec>       Provide a Git revision as the base commit
-
    -m, --message [<string>]   Provide a comment message to the patch or revision (default: prompt)
-
        --no-message           Leave the patch or revision comment message blank
-

-
List options
-

-
        --all                  Show all patches, including merged and archived patches
-
        --archived             Show only archived patches
-
        --merged               Show only merged patches
-
        --open                 Show only open patches (default)
-
        --draft                Show only draft patches
-
        --authored             Show only patches that you have authored
-
        --author <did>         Show only patched where the given user is an author
-
                               (may be specified multiple times)
-

-
Ready options
-

-
        --undo                 Convert a patch back to a draft
-

-
Checkout options
-

-
        --revision <id>        Checkout the given revision of the patch
-
        --name <string>        Provide a name for the branch to checkout
-
        --remote <string>      Provide the git remote to use as the upstream
-
    -f, --force                Checkout the head of the revision, even if the branch already exists
-

-
Set options
-

-
        --remote <string>      Provide the git remote to use as the upstream
-

-
React options
-

-
        --emoji <char>         The emoji to react to the patch or revision with
-

-
Other options
-

-
        --repo <rid>           Operate on the given repository (default: cwd)
-
        --[no-]announce        Announce changes made to the network
-
    -q, --quiet                Quiet output
-
        --help                 Print help
-
"#,
-
};
-

-
#[derive(Debug, Default, PartialEq, Eq)]
-
pub enum OperationName {
-
    Assign,
-
    Show,
-
    Diff,
-
    Update,
-
    Archive,
-
    Delete,
-
    Checkout,
-
    Comment,
-
    React,
-
    Ready,
-
    Review,
-
    Resolve,
-
    Label,
-
    #[default]
-
    List,
-
    Edit,
-
    Redact,
-
    Set,
-
    Cache,
-
}
-

-
#[derive(Debug, PartialEq, Eq)]
-
pub enum CommentOperation {
-
    Edit,
-
    React,
-
    Redact,
-
}
-

-
#[derive(Debug, Default, PartialEq, Eq)]
-
pub struct AssignOptions {
-
    pub add: BTreeSet<Did>,
-
    pub delete: BTreeSet<Did>,
-
}
-

-
#[derive(Debug, Default, PartialEq, Eq)]
-
pub struct LabelOptions {
-
    pub add: BTreeSet<Label>,
-
    pub delete: BTreeSet<Label>,
-
}
-

-
#[derive(Debug)]
-
pub enum Operation {
-
    Show {
-
        patch_id: Rev,
-
        diff: bool,
-
        verbose: bool,
-
    },
-
    Diff {
-
        patch_id: Rev,
-
        revision_id: Option<Rev>,
-
    },
-
    Update {
-
        patch_id: Rev,
-
        base_id: Option<Rev>,
-
        message: Message,
-
    },
-
    Archive {
-
        patch_id: Rev,
-
        undo: bool,
-
    },
-
    Ready {
-
        patch_id: Rev,
-
        undo: bool,
-
    },
-
    Delete {
-
        patch_id: Rev,
-
    },
-
    Checkout {
-
        patch_id: Rev,
-
        revision_id: Option<Rev>,
-
        opts: checkout::Options,
-
    },
-
    Comment {
-
        revision_id: Rev,
-
        message: Message,
-
        reply_to: Option<Rev>,
-
    },
-
    CommentEdit {
-
        revision_id: Rev,
-
        comment_id: Rev,
-
        message: Message,
-
    },
-
    CommentRedact {
-
        revision_id: Rev,
-
        comment_id: Rev,
-
    },
-
    CommentReact {
-
        revision_id: Rev,
-
        comment_id: Rev,
-
        reaction: Reaction,
-
        undo: bool,
-
    },
-
    React {
-
        revision_id: Rev,
-
        reaction: Reaction,
-
        undo: bool,
-
    },
-
    Review {
-
        patch_id: Rev,
-
        revision_id: Option<Rev>,
-
        opts: review::Options,
-
    },
-
    Resolve {
-
        patch_id: Rev,
-
        review_id: Rev,
-
        comment_id: Rev,
-
        undo: bool,
-
    },
-
    Assign {
-
        patch_id: Rev,
-
        opts: AssignOptions,
-
    },
-
    Label {
-
        patch_id: Rev,
-
        opts: LabelOptions,
-
    },
-
    List {
-
        filter: Option<patch::Status>,
-
    },
-
    Edit {
-
        patch_id: Rev,
-
        revision_id: Option<Rev>,
-
        message: Message,
-
    },
-
    Redact {
-
        revision_id: Rev,
-
    },
-
    Set {
-
        patch_id: Rev,
-
        remote: Option<RefString>,
-
    },
-
    Cache {
-
        patch_id: Option<Rev>,
-
        storage: bool,
-
    },
-
}
-

-
impl Operation {
-
    fn is_announce(&self) -> bool {
-
        match self {
-
            Operation::Update { .. }
-
            | Operation::Archive { .. }
-
            | Operation::Ready { .. }
-
            | Operation::Delete { .. }
-
            | Operation::Comment { .. }
-
            | Operation::CommentEdit { .. }
-
            | Operation::CommentRedact { .. }
-
            | Operation::CommentReact { .. }
-
            | Operation::Review { .. }
-
            | Operation::Resolve { .. }
-
            | Operation::Assign { .. }
-
            | Operation::Label { .. }
-
            | Operation::Edit { .. }
-
            | Operation::Redact { .. }
-
            | Operation::React { .. }
-
            | Operation::Set { .. } => true,
-
            Operation::Show { .. }
-
            | Operation::Diff { .. }
-
            | Operation::Checkout { .. }
-
            | Operation::List { .. }
-
            | Operation::Cache { .. } => false,
-
        }
-
    }
-
}
-

-
#[derive(Debug)]
-
pub struct Options {
-
    pub op: Operation,
-
    pub repo: Option<RepoId>,
-
    pub announce: bool,
-
    pub quiet: bool,
-
    pub authored: bool,
-
    pub authors: Vec<Did>,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut op: Option<OperationName> = None;
-
        let mut verbose = false;
-
        let mut quiet = false;
-
        let mut authored = false;
-
        let mut authors = vec![];
-
        let mut announce = true;
-
        let mut patch_id = None;
-
        let mut revision_id = None;
-
        let mut review_id = None;
-
        let mut comment_id = None;
-
        let mut message = Message::default();
-
        let mut filter = Some(patch::Status::Open);
-
        let mut diff = false;
-
        let mut undo = false;
-
        let mut reaction: Option<Reaction> = None;
-
        let mut reply_to: Option<Rev> = None;
-
        let mut comment_op: Option<(CommentOperation, Rev)> = None;
-
        let mut checkout_opts = checkout::Options::default();
-
        let mut remote: Option<RefString> = None;
-
        let mut assign_opts = AssignOptions::default();
-
        let mut label_opts = LabelOptions::default();
-
        let mut review_op = review::Operation::default();
-
        let mut base_id = None;
-
        let mut repo = None;
-
        let mut cache_storage = false;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                // Options.
-
                Long("message") | Short('m') => {
-
                    if message != Message::Blank {
-
                        // We skip this code when `no-message` is specified.
-
                        let txt: String = term::args::string(&parser.value()?);
-
                        message.append(&txt);
-
                    }
-
                }
-
                Long("no-message") => {
-
                    message = Message::Blank;
-
                }
-
                Long("announce") => {
-
                    announce = true;
-
                }
-
                Long("no-announce") => {
-
                    announce = false;
-
                }
-

-
                // Show options.
-
                Long("patch") | Short('p') if op == Some(OperationName::Show) => {
-
                    diff = true;
-
                }
-
                Long("verbose") | Short('v') if op == Some(OperationName::Show) => {
-
                    verbose = true;
-
                }
-

-
                // Ready options.
-
                Long("undo") if op == Some(OperationName::Ready) => {
-
                    undo = true;
-
                }
-

-
                // Archive options.
-
                Long("undo") if op == Some(OperationName::Archive) => {
-
                    undo = true;
-
                }
-

-
                // Update options.
-
                Short('b') | Long("base") if op == Some(OperationName::Update) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    base_id = Some(rev);
-
                }
-

-
                // React options.
-
                Long("emoji") if op == Some(OperationName::React) => {
-
                    if let Some(emoji) = parser.value()?.to_str() {
-
                        reaction =
-
                            Some(Reaction::from_str(emoji).map_err(|_| anyhow!("invalid emoji"))?);
-
                    }
-
                }
-
                Long("undo") if op == Some(OperationName::React) => {
-
                    undo = true;
-
                }
-

-
                // Comment options.
-
                Long("reply-to") if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    reply_to = Some(rev);
-
                }
-

-
                Long("edit") if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    comment_op = Some((CommentOperation::Edit, rev));
-
                }
-

-
                Long("react") if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    comment_op = Some((CommentOperation::React, rev));
-
                }
-
                Long("emoji")
-
                    if op == Some(OperationName::Comment)
-
                        && matches!(comment_op, Some((CommentOperation::React, _))) =>
-
                {
-
                    if let Some(emoji) = parser.value()?.to_str() {
-
                        reaction =
-
                            Some(Reaction::from_str(emoji).map_err(|_| anyhow!("invalid emoji"))?);
-
                    }
-
                }
-
                Long("undo")
-
                    if op == Some(OperationName::Comment)
-
                        && matches!(comment_op, Some((CommentOperation::React, _))) =>
-
                {
-
                    undo = true;
-
                }
-

-
                Long("redact") if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    comment_op = Some((CommentOperation::Redact, rev));
-
                }
-

-
                // Edit options.
-
                Long("revision") | Short('r') if op == Some(OperationName::Edit) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    revision_id = Some(rev);
-
                }
-

-
                // Review/diff options.
-
                Long("revision") | Short('r')
-
                    if op == Some(OperationName::Review) || op == Some(OperationName::Diff) =>
-
                {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    revision_id = Some(rev);
-
                }
-
                Long("patch") | Short('p') if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review { by_hunk, .. } = &mut review_op {
-
                        *by_hunk = true;
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("unified") | Short('U') if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review { unified, .. } = &mut review_op {
-
                        let val = parser.value()?;
-
                        *unified = term::args::number(&val)?;
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("hunk") if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review { hunk, .. } = &mut review_op {
-
                        let val = parser.value()?;
-
                        let val = term::args::number(&val)
-
                            .map_err(|e| anyhow!("invalid hunk value: {e}"))?;
-

-
                        *hunk = Some(val);
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("delete") | Short('d') if op == Some(OperationName::Review) => {
-
                    review_op = review::Operation::Delete;
-
                }
-
                Long("accept") if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review {
-
                        verdict: verdict @ None,
-
                        ..
-
                    } = &mut review_op
-
                    {
-
                        *verdict = Some(patch::Verdict::Accept);
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("reject") if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review {
-
                        verdict: verdict @ None,
-
                        ..
-
                    } = &mut review_op
-
                    {
-
                        *verdict = Some(patch::Verdict::Reject);
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-

-
                // Resolve options
-
                Long("undo") if op == Some(OperationName::Resolve) => {
-
                    undo = true;
-
                }
-
                Long("review") if op == Some(OperationName::Resolve) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    review_id = Some(rev);
-
                }
-
                Long("comment") if op == Some(OperationName::Resolve) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    comment_id = Some(rev);
-
                }
-

-
                // Checkout options
-
                Long("revision") if op == Some(OperationName::Checkout) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    revision_id = Some(rev);
-
                }
-

-
                Long("force") | Short('f') if op == Some(OperationName::Checkout) => {
-
                    checkout_opts.force = true;
-
                }
-

-
                Long("name") if op == Some(OperationName::Checkout) => {
-
                    let val = parser.value()?;
-
                    checkout_opts.name = Some(term::args::refstring("name", val)?);
-
                }
-

-
                Long("remote") if op == Some(OperationName::Checkout) => {
-
                    let val = parser.value()?;
-
                    checkout_opts.remote = Some(term::args::refstring("remote", val)?);
-
                }
-

-
                // Assign options.
-
                Short('a') | Long("add") if matches!(op, Some(OperationName::Assign)) => {
-
                    assign_opts.add.insert(term::args::did(&parser.value()?)?);
-
                }
-

-
                Short('d') | Long("delete") if matches!(op, Some(OperationName::Assign)) => {
-
                    assign_opts
-
                        .delete
-
                        .insert(term::args::did(&parser.value()?)?);
-
                }
-

-
                // Label options.
-
                Short('a') | Long("add") if matches!(op, Some(OperationName::Label)) => {
-
                    let val = parser.value()?;
-
                    let name = term::args::string(&val);
-
                    let label = Label::new(name)?;
-

-
                    label_opts.add.insert(label);
-
                }
-

-
                Short('d') | Long("delete") if matches!(op, Some(OperationName::Label)) => {
-
                    let val = parser.value()?;
-
                    let name = term::args::string(&val);
-
                    let label = Label::new(name)?;
-

-
                    label_opts.delete.insert(label);
-
                }
-

-
                // Set options.
-
                Long("remote") if op == Some(OperationName::Set) => {
-
                    let val = parser.value()?;
-
                    remote = Some(term::args::refstring("remote", val)?);
-
                }
-

-
                // List options.
-
                Long("all") => {
-
                    filter = None;
-
                }
-
                Long("draft") => {
-
                    filter = Some(patch::Status::Draft);
-
                }
-
                Long("archived") => {
-
                    filter = Some(patch::Status::Archived);
-
                }
-
                Long("merged") => {
-
                    filter = Some(patch::Status::Merged);
-
                }
-
                Long("open") => {
-
                    filter = Some(patch::Status::Open);
-
                }
-
                Long("authored") => {
-
                    authored = true;
-
                }
-
                Long("author") if op == Some(OperationName::List) => {
-
                    authors.push(term::args::did(&parser.value()?)?);
-
                }
-

-
                // Cache options.
-
                Long("storage") if op == Some(OperationName::Cache) => {
-
                    cache_storage = true;
-
                }
-

-
                // Common.
-
                Long("quiet") | Short('q') => {
-
                    quiet = true;
-
                }
-
                Long("repo") => {
-
                    let val = parser.value()?;
-
                    let rid = term::args::rid(&val)?;
-

-
                    repo = Some(rid);
-
                }
-
                Long("help") => {
-
                    return Err(Error::HelpManual { name: "rad-patch" }.into());
-
                }
-
                Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-

-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "l" | "list" => op = Some(OperationName::List),
-
                    "s" | "show" => op = Some(OperationName::Show),
-
                    "u" | "update" => op = Some(OperationName::Update),
-
                    "d" | "delete" => op = Some(OperationName::Delete),
-
                    "c" | "checkout" => op = Some(OperationName::Checkout),
-
                    "a" | "archive" => op = Some(OperationName::Archive),
-
                    "y" | "ready" => op = Some(OperationName::Ready),
-
                    "e" | "edit" => op = Some(OperationName::Edit),
-
                    "r" | "redact" => op = Some(OperationName::Redact),
-
                    "diff" => op = Some(OperationName::Diff),
-
                    "assign" => op = Some(OperationName::Assign),
-
                    "label" => op = Some(OperationName::Label),
-
                    "comment" => op = Some(OperationName::Comment),
-
                    "review" => op = Some(OperationName::Review),
-
                    "resolve" => op = Some(OperationName::Resolve),
-
                    "set" => op = Some(OperationName::Set),
-
                    "cache" => op = Some(OperationName::Cache),
-
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
-
                },
-
                Value(val) if op == Some(OperationName::Redact) => {
-
                    let rev = term::args::rev(&val)?;
-
                    revision_id = Some(rev);
-
                }
-
                Value(val)
-
                    if patch_id.is_none()
-
                        && [
-
                            Some(OperationName::Show),
-
                            Some(OperationName::Diff),
-
                            Some(OperationName::Update),
-
                            Some(OperationName::Delete),
-
                            Some(OperationName::Archive),
-
                            Some(OperationName::Ready),
-
                            Some(OperationName::Checkout),
-
                            Some(OperationName::Comment),
-
                            Some(OperationName::Review),
-
                            Some(OperationName::Resolve),
-
                            Some(OperationName::Edit),
-
                            Some(OperationName::Set),
-
                            Some(OperationName::Assign),
-
                            Some(OperationName::Label),
-
                            Some(OperationName::Cache),
-
                        ]
-
                        .contains(&op) =>
-
                {
-
                    let val = string(&val);
-
                    patch_id = Some(Rev::from(val));
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        let op = match op.unwrap_or_default() {
-
            OperationName::List => Operation::List { filter },
-
            OperationName::Show => Operation::Show {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                diff,
-
                verbose,
-
            },
-
            OperationName::Diff => Operation::Diff {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                revision_id,
-
            },
-
            OperationName::Delete => Operation::Delete {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
            },
-
            OperationName::Update => Operation::Update {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                base_id,
-
                message,
-
            },
-
            OperationName::Archive => Operation::Archive {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch id must be provided"))?,
-
                undo,
-
            },
-
            OperationName::Checkout => Operation::Checkout {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                revision_id,
-
                opts: checkout_opts,
-
            },
-
            OperationName::Comment => match comment_op {
-
                Some((CommentOperation::Edit, comment)) => Operation::CommentEdit {
-
                    revision_id: patch_id
-
                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                    comment_id: comment,
-
                    message,
-
                },
-
                Some((CommentOperation::React, comment)) => Operation::CommentReact {
-
                    revision_id: patch_id
-
                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                    comment_id: comment,
-
                    reaction: reaction
-
                        .ok_or_else(|| anyhow!("a reaction emoji must be provided"))?,
-
                    undo,
-
                },
-
                Some((CommentOperation::Redact, comment)) => Operation::CommentRedact {
-
                    revision_id: patch_id
-
                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                    comment_id: comment,
-
                },
-
                None => Operation::Comment {
-
                    revision_id: patch_id
-
                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                    message,
-
                    reply_to,
-
                },
-
            },
-
            OperationName::React => Operation::React {
-
                revision_id: patch_id
-
                    .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                reaction: reaction.ok_or_else(|| anyhow!("a reaction emoji must be provided"))?,
-
                undo,
-
            },
-
            OperationName::Review => Operation::Review {
-
                patch_id: patch_id
-
                    .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                revision_id,
-
                opts: review::Options {
-
                    message,
-
                    op: review_op,
-
                },
-
            },
-
            OperationName::Resolve => Operation::Resolve {
-
                patch_id: patch_id
-
                    .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                review_id: review_id.ok_or_else(|| anyhow!("a review must be provided"))?,
-
                comment_id: comment_id.ok_or_else(|| anyhow!("a comment must be provided"))?,
-
                undo,
-
            },
-
            OperationName::Ready => Operation::Ready {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                undo,
-
            },
-
            OperationName::Edit => Operation::Edit {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                revision_id,
-
                message,
-
            },
-
            OperationName::Redact => Operation::Redact {
-
                revision_id: revision_id.ok_or_else(|| anyhow!("a revision must be provided"))?,
-
            },
-
            OperationName::Assign => Operation::Assign {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                opts: assign_opts,
-
            },
-
            OperationName::Label => Operation::Label {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                opts: label_opts,
-
            },
-
            OperationName::Set => Operation::Set {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                remote,
-
            },
-
            OperationName::Cache => Operation::Cache {
-
                patch_id,
-
                storage: cache_storage,
-
            },
-
        };
-

-
        Ok((
-
            Options {
-
                op,
-
                repo,
-
                quiet,
-
                announce,
-
                authored,
-
                authors,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
-
    let (workdir, rid) = if let Some(rid) = options.repo {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
+
    let (workdir, rid) = if let Some(rid) = args.repo {
        (None, rid)
    } else {
        radicle::rad::cwd()
@@ -863,51 +47,51 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {

    let profile = ctx.profile()?;
    let repository = profile.storage.repository(rid)?;
-
    let announce = options.announce && options.op.is_announce();
+

+
    // Fallback to [`Command::List`] if no subcommand is provided.
+
    // Construct it using the [`EmptyArgs`] in `args.empty`.
+
    let mut announce = args.should_announce();
+
    let command = args
+
        .command
+
        .unwrap_or_else(|| Command::List(args.empty.into()));
+
    announce &= command.should_announce();

    transport::local::register(profile.storage.clone());

-
    match options.op {
-
        Operation::List { filter } => {
-
            let mut authors: BTreeSet<Did> = options.authors.iter().cloned().collect();
-
            if options.authored {
+
    match command {
+
        Command::List(args) => {
+
            let mut authors: BTreeSet<Did> = args.authors.iter().cloned().collect();
+
            if args.authored {
                authors.insert(profile.did());
            }
-
            list::run(filter.as_ref(), authors, &repository, &profile)?;
+
            list::run((&args.state).into(), authors, &repository, &profile)?;
        }
-
        Operation::Show {
-
            patch_id,
-
            diff,
-
            verbose,
-
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
+

+
        Command::Show { id, patch, verbose } => {
+
            let patch_id = id.resolve(&repository.backend)?;
            show::run(
                &patch_id,
-
                diff,
+
                patch,
                verbose,
                &profile,
                &repository,
                workdir.as_ref(),
            )?;
        }
-
        Operation::Diff {
-
            patch_id,
-
            revision_id,
-
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            let revision_id = revision_id
+

+
        Command::Diff { id, revision } => {
+
            let patch_id = id.resolve(&repository.backend)?;
+
            let revision_id = revision
                .map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
                .transpose()?
                .map(patch::RevisionId::from);
            diff::run(&patch_id, revision_id, &repository, &profile)?;
        }
-
        Operation::Update {
-
            ref patch_id,
-
            ref base_id,
-
            ref message,
-
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            let base_id = base_id
+

+
        Command::Update { id, base, message } => {
+
            let message = Message::from(message);
+
            let patch_id = id.resolve(&repository.backend)?;
+
            let base_id = base
                .as_ref()
                .map(|base| base.resolve(&repository.backend))
                .transpose()?;
@@ -915,21 +99,16 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                "this command must be run from a repository checkout"
            ))?;

-
            update::run(
-
                patch_id,
-
                base_id,
-
                message.clone(),
-
                &profile,
-
                &repository,
-
                &workdir,
-
            )?;
+
            update::run(patch_id, base_id, message, &profile, &repository, &workdir)?;
        }
-
        Operation::Archive { ref patch_id, undo } => {
-
            let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
+

+
        Command::Archive { id, undo } => {
+
            let patch_id = id.resolve::<PatchId>(&repository.backend)?;
            archive::run(&patch_id, undo, &profile, &repository)?;
        }
-
        Operation::Ready { ref patch_id, undo } => {
-
            let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
+

+
        Command::Ready { id, undo } => {
+
            let patch_id = id.resolve::<PatchId>(&repository.backend)?;

            if !ready::run(&patch_id, undo, &profile, &repository)? {
                if undo {
@@ -939,17 +118,15 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                }
            }
        }
-
        Operation::Delete { patch_id } => {
-
            let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
+

+
        Command::Delete { id } => {
+
            let patch_id = id.resolve::<PatchId>(&repository.backend)?;
            delete::run(&patch_id, &profile, &repository)?;
        }
-
        Operation::Checkout {
-
            patch_id,
-
            revision_id,
-
            opts,
-
        } => {
-
            let patch_id = patch_id.resolve::<radicle::git::Oid>(&repository.backend)?;
-
            let revision_id = revision_id
+

+
        Command::Checkout { id, revision, opts } => {
+
            let patch_id = id.resolve::<radicle::git::Oid>(&repository.backend)?;
+
            let revision_id = revision
                .map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
                .transpose()?
                .map(patch::RevisionId::from);
@@ -962,86 +139,136 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                &repository,
                &workdir,
                &profile,
-
                opts,
+
                opts.into(),
            )?;
        }
-
        Operation::Comment {
-
            revision_id,
-
            message,
-
            reply_to,
-
        } => {
-
            comment::run(
-
                revision_id,
+

+
        Command::Comment(c) => match CommentAction::from(c) {
+
            CommentAction::Comment {
+
                revision,
                message,
                reply_to,
-
                options.quiet,
-
                &repository,
-
                &profile,
-
            )?;
-
        }
-
        Operation::Review {
-
            patch_id,
-
            revision_id,
-
            opts,
+
            } => {
+
                comment::run(
+
                    revision,
+
                    message,
+
                    reply_to,
+
                    args.quiet,
+
                    &repository,
+
                    &profile,
+
                )?;
+
            }
+
            CommentAction::Edit {
+
                revision,
+
                comment,
+
                message,
+
            } => {
+
                let comment = comment.resolve(&repository.backend)?;
+
                comment::edit::run(
+
                    revision,
+
                    comment,
+
                    message,
+
                    args.quiet,
+
                    &repository,
+
                    &profile,
+
                )?;
+
            }
+
            CommentAction::Redact { revision, comment } => {
+
                let comment = comment.resolve(&repository.backend)?;
+
                comment::redact::run(revision, comment, &repository, &profile)?;
+
            }
+
            CommentAction::React {
+
                revision,
+
                comment,
+
                emoji,
+
                undo,
+
            } => {
+
                let comment = comment.resolve(&repository.backend)?;
+
                if undo {
+
                    comment::react::run(revision, comment, emoji, false, &repository, &profile)?;
+
                } else {
+
                    comment::react::run(revision, comment, emoji, true, &repository, &profile)?;
+
                }
+
            }
+
        },
+

+
        Command::Review {
+
            id,
+
            revision,
+
            options,
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            let revision_id = revision_id
+
            let patch_id = id.resolve(&repository.backend)?;
+
            let revision_id = revision
                .map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
                .transpose()?
                .map(patch::RevisionId::from);
-
            review::run(patch_id, revision_id, opts, &profile, &repository)?;
+
            review::run(patch_id, revision_id, options.into(), &profile, &repository)?;
        }
-
        Operation::Resolve {
-
            ref patch_id,
-
            ref review_id,
-
            ref comment_id,
-
            undo,
+

+
        Command::Resolve {
+
            id,
+
            review,
+
            comment,
+
            unresolve,
        } => {
-
            let patch = patch_id.resolve(&repository.backend)?;
+
            let patch = id.resolve(&repository.backend)?;
            let review = patch::ReviewId::from(
-
                review_id.resolve::<radicle::cob::EntryId>(&repository.backend)?,
+
                review.resolve::<radicle::cob::EntryId>(&repository.backend)?,
            );
-
            let comment = comment_id.resolve(&repository.backend)?;
-
            if undo {
+
            let comment = comment.resolve(&repository.backend)?;
+
            if unresolve {
                resolve::unresolve(patch, review, comment, &repository, &profile)?;
-
                term::success!("Unresolved comment {comment_id}");
+
                term::success!("Unresolved comment {comment}");
            } else {
                resolve::resolve(patch, review, comment, &repository, &profile)?;
-
                term::success!("Resolved comment {comment_id}");
+
                term::success!("Resolved comment {comment}");
            }
        }
-
        Operation::Edit {
-
            patch_id,
-
            revision_id,
+
        Command::Edit {
+
            id,
+
            revision,
            message,
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            let revision_id = revision_id
+
            let message = Message::from(message);
+
            let patch_id = id.resolve(&repository.backend)?;
+
            let revision_id = revision
                .map(|id| id.resolve::<radicle::git::Oid>(&repository.backend))
                .transpose()?
                .map(patch::RevisionId::from);
            edit::run(&patch_id, revision_id, message, &profile, &repository)?;
        }
-
        Operation::Redact { revision_id } => {
-
            redact::run(&revision_id, &profile, &repository)?;
+
        Command::Redact { id } => {
+
            redact::run(&id, &profile, &repository)?;
        }
-
        Operation::Assign {
-
            patch_id,
-
            opts: AssignOptions { add, delete },
+
        Command::Assign {
+
            id,
+
            args: AssignArgs { add, delete },
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            assign::run(&patch_id, add, delete, &profile, &repository)?;
+
            let patch_id = id.resolve(&repository.backend)?;
+
            assign::run(
+
                &patch_id,
+
                add.into_iter().collect(),
+
                delete.into_iter().collect(),
+
                &profile,
+
                &repository,
+
            )?;
        }
-
        Operation::Label {
-
            patch_id,
-
            opts: LabelOptions { add, delete },
+
        Command::Label {
+
            id,
+
            args: LabelArgs { add, delete },
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            label::run(&patch_id, add, delete, &profile, &repository)?;
+
            let patch_id = id.resolve(&repository.backend)?;
+
            label::run(
+
                &patch_id,
+
                add.into_iter().collect(),
+
                delete.into_iter().collect(),
+
                &profile,
+
                &repository,
+
            )?;
        }
-
        Operation::Set { patch_id, remote } => {
+
        Command::Set { id, remote } => {
            let patches = term::cob::patches(&profile, &repository)?;
-
            let patch_id = patch_id.resolve(&repository.backend)?;
+
            let patch_id = id.resolve(&repository.backend)?;
            let patch = patches
                .get(&patch_id)?
                .ok_or_else(|| anyhow!("patch {patch_id} not found"))?;
@@ -1056,13 +283,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                true,
            )?;
        }
-
        Operation::Cache { patch_id, storage } => {
+
        Command::Cache { id, storage } => {
            let mode = if storage {
                cache::CacheMode::Storage
            } else {
-
                let patch_id = patch_id
-
                    .map(|id| id.resolve(&repository.backend))
-
                    .transpose()?;
+
                let patch_id = id.map(|id| id.resolve(&repository.backend)).transpose()?;
                patch_id.map_or(
                    cache::CacheMode::Repository {
                        repository: &repository,
@@ -1075,50 +300,15 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            };
            cache::run(mode, &profile)?;
        }
-
        Operation::CommentEdit {
-
            revision_id,
-
            comment_id,
-
            message,
-
        } => {
-
            let comment = comment_id.resolve(&repository.backend)?;
-
            comment::edit::run(
-
                revision_id,
-
                comment,
-
                message,
-
                options.quiet,
-
                &repository,
-
                &profile,
-
            )?;
-
        }
-
        Operation::CommentRedact {
-
            revision_id,
-
            comment_id,
-
        } => {
-
            let comment = comment_id.resolve(&repository.backend)?;
-
            comment::redact::run(revision_id, comment, &repository, &profile)?;
-
        }
-
        Operation::CommentReact {
-
            revision_id,
-
            comment_id,
-
            reaction,
-
            undo,
-
        } => {
-
            let comment = comment_id.resolve(&repository.backend)?;
-
            if undo {
-
                comment::react::run(revision_id, comment, reaction, false, &repository, &profile)?;
-
            } else {
-
                comment::react::run(revision_id, comment, reaction, true, &repository, &profile)?;
-
            }
-
        }
-
        Operation::React {
-
            revision_id,
-
            reaction,
+
        Command::React {
+
            id,
+
            emoji: react,
            undo,
        } => {
            if undo {
-
                react::run(&revision_id, reaction, false, &repository, &profile)?;
+
                react::run(&id, react, false, &repository, &profile)?;
            } else {
-
                react::run(&revision_id, reaction, true, &repository, &profile)?;
+
                react::run(&id, react, true, &repository, &profile)?;
            }
        }
    }
added crates/radicle-cli/src/commands/patch/args.rs
@@ -0,0 +1,755 @@
+
use clap::{Parser, Subcommand};
+

+
use radicle::cob::Label;
+
use radicle::git;
+
use radicle::git::fmt::RefString;
+
use radicle::patch::Status;
+
use radicle::patch::Verdict;
+
use radicle::prelude::Did;
+
use radicle::prelude::RepoId;
+

+
use crate::commands::patch::checkout;
+
use crate::commands::patch::review;
+

+
use crate::git::Rev;
+
use crate::terminal::patch::Message;
+

+
const ABOUT: &str = "Manage patches";
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    #[command(subcommand)]
+
    pub(super) command: Option<Command>,
+

+
    /// Quiet output
+
    #[arg(short, long, global = true)]
+
    pub(super) quiet: bool,
+

+
    /// Announce changes made to the network
+
    #[arg(long, global = true, conflicts_with = "no_announce")]
+
    announce: bool,
+

+
    /// Do not announce changes made to the network
+
    #[arg(long, global = true, conflicts_with = "announce")]
+
    no_announce: bool,
+

+
    /// Operate on the given repository [default: cwd]
+
    #[arg(long, global = true, value_name = "RID")]
+
    pub(super) repo: Option<RepoId>,
+

+
    /// Verbose output
+
    #[arg(long, short, global = true)]
+
    pub(super) verbose: bool,
+

+
    /// Arguments for the empty subcommand.
+
    /// Will fall back to [`Command::List`].
+
    #[clap(flatten)]
+
    pub(super) empty: EmptyArgs,
+
}
+

+
impl Args {
+
    pub(super) fn should_announce(&self) -> bool {
+
        self.announce || !self.no_announce
+
    }
+
}
+

+
/// Commands to create, view, and edit Radicle patches
+
#[derive(Subcommand, Debug)]
+
pub(super) enum Command {
+
    /// List the patches of a repository
+
    #[command(alias = "l")]
+
    List(ListArgs),
+

+
    /// Show a specific patch
+
    #[command(alias = "s")]
+
    Show {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// Show the diff of the changes in the patch
+
        #[arg(long, short)]
+
        patch: bool,
+

+
        /// Verbose output
+
        #[arg(long, short)]
+
        verbose: bool,
+
    },
+

+
    /// Show the diff of a specific patch
+
    ///
+
    /// The `git diff` of the revision's base and head will be shown
+
    Diff {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// The revision to diff
+
        ///
+
        /// If not specified, the latest revision of the original author
+
        /// will be used
+
        #[arg(long, short)]
+
        revision: Option<Rev>,
+
    },
+

+
    /// Mark a patch as archived
+
    #[command(alias = "a")]
+
    Archive {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// Unarchive a patch
+
        ///
+
        /// The patch will be marked as open
+
        #[arg(long)]
+
        undo: bool,
+
    },
+

+
    /// Update the metadata of a patch
+
    #[command(alias = "u")]
+
    Update {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// Provide a Git revision as the base commit
+
        #[arg(long, short, value_name = "REVSPEC")]
+
        base: Option<Rev>,
+

+
        /// Change the message of the original revision of the patch
+
        #[clap(flatten)]
+
        message: MessageArgs,
+
    },
+

+
    /// Checkout a Git branch pointing to the head of a patch revision
+
    ///
+
    /// If no revision is specified, the latest revision of the original author
+
    /// is chosen
+
    #[command(alias = "c")]
+
    Checkout {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// Checkout the given revision of the patch
+
        #[arg(long)]
+
        revision: Option<Rev>,
+

+
        #[clap(flatten)]
+
        opts: CheckoutArgs,
+
    },
+

+
    /// Create a review of a patch revision
+
    Review {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// The particular revision to review
+
        ///
+
        /// If none is specified, the initial revision will be reviewed
+
        #[arg(long, short)]
+
        revision: Option<Rev>,
+

+
        #[clap(flatten)]
+
        options: ReviewArgs,
+
    },
+

+
    /// Mark a comment of a review as resolved or unresolved
+
    Resolve {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// The review id which the comment is under
+
        #[arg(long, value_name = "REVIEW_ID")]
+
        review: Rev,
+

+
        /// The comment to (un)resolve
+
        #[arg(long, value_name = "COMMENT_ID")]
+
        comment: Rev,
+

+
        /// Unresolve the comment
+
        #[arg(long)]
+
        unresolve: bool,
+
    },
+

+
    /// Delete a patch
+
    ///
+
    /// This will delete any patch data associated with this user. Note that
+
    /// other user's data will remain, meaning the patch will remain until all
+
    /// other data is also deleted.
+
    #[command(alias = "d")]
+
    Delete {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+
    },
+

+
    /// Redact a patch revision
+
    #[command(alias = "r")]
+
    Redact {
+
        /// ID of the patch revision
+
        #[arg(value_name = "REVISION_ID")]
+
        id: Rev,
+
    },
+

+
    /// React to a patch or patch revision
+
    React {
+
        /// ID of the patch or patch revision
+
        #[arg(value_name = "PATCH_ID|REVISION_ID")]
+
        id: Rev,
+

+
        /// The reaction being used
+
        #[arg(long, value_name = "CHAR")]
+
        emoji: radicle::cob::Reaction,
+

+
        /// Remove the reaction
+
        #[arg(long)]
+
        undo: bool,
+
    },
+

+
    /// Add or remove assignees to/from a patch
+
    Assign {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        #[clap(flatten)]
+
        args: AssignArgs,
+
    },
+

+
    /// Add or remove labels to/from a patch
+
    Label {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        #[clap(flatten)]
+
        args: LabelArgs,
+
    },
+

+
    /// If the patch is marked as a draft, then mark it as open
+
    #[command(alias = "y")]
+
    Ready {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// Convert a patch back to a draft
+
        #[arg(long)]
+
        undo: bool,
+
    },
+

+
    #[command(alias = "e")]
+
    Edit {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// ID of the patch revision
+
        #[arg(long, value_name = "REVISION_ID")]
+
        revision: Option<Rev>,
+

+
        #[clap(flatten)]
+
        message: MessageArgs,
+
    },
+

+
    /// Set an upstream branch for a patch
+
    Set {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// Provide the git remote to use as the upstream
+
        #[arg(long, value_name = "REF", value_parser = parse_refstr)]
+
        remote: Option<RefString>,
+
    },
+

+
    /// Comment on, reply to, edit, or react to a comment
+
    Comment(CommentArgs),
+

+
    /// Re-cache the patches
+
    Cache {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Option<Rev>,
+

+
        /// Re-cache all patches in storage, as opposed to the current repository
+
        #[arg(long)]
+
        storage: bool,
+
    },
+
}
+

+
impl Command {
+
    pub(super) fn should_announce(&self) -> bool {
+
        match self {
+
            Self::Update { .. }
+
            | Self::Archive { .. }
+
            | Self::Ready { .. }
+
            | Self::Delete { .. }
+
            | Self::Comment { .. }
+
            | Self::Review { .. }
+
            | Self::Resolve { .. }
+
            | Self::Assign { .. }
+
            | Self::Label { .. }
+
            | Self::Edit { .. }
+
            | Self::Redact { .. }
+
            | Self::React { .. }
+
            | Self::Set { .. } => true,
+
            Self::Show { .. }
+
            | Self::Diff { .. }
+
            | Self::Checkout { .. }
+
            | Self::List { .. }
+
            | Self::Cache { .. } => false,
+
        }
+
    }
+
}
+

+
#[derive(Parser, Debug)]
+
pub(super) struct CommentArgs {
+
    /// ID of the revision to comment on
+
    #[arg(value_name = "REVISION_ID")]
+
    revision: Rev,
+

+
    #[clap(flatten)]
+
    message: MessageArgs,
+

+
    /// The comment to edit
+
    ///
+
    /// Use `--message` to edit with the provided message
+
    #[arg(
+
        long,
+
        value_name = "COMMENT_ID",
+
        conflicts_with = "react",
+
        conflicts_with = "redact"
+
    )]
+
    edit: Option<Rev>,
+

+
    /// The comment to react to
+
    ///
+
    /// Use `--emoji` for the character to react with
+
    ///
+
    /// Use `--undo` with `--emoji` to remove the reaction
+
    #[arg(
+
        long,
+
        value_name = "COMMENT_ID",
+
        conflicts_with = "edit",
+
        conflicts_with = "redact",
+
        requires = "emoji",
+
        group = "reaction"
+
    )]
+
    react: Option<Rev>,
+

+
    /// The comment to redact
+
    #[arg(
+
        long,
+
        value_name = "COMMENT_ID",
+
        conflicts_with = "react",
+
        conflicts_with = "edit"
+
    )]
+
    redact: Option<Rev>,
+

+
    /// The emoji to react with
+
    ///
+
    /// Requires using `--react <COMMENT_ID>`
+
    #[arg(long, requires = "reaction")]
+
    emoji: Option<radicle::cob::Reaction>,
+

+
    /// The comment to reply to
+
    #[arg(long, value_name = "COMMENT_ID")]
+
    reply_to: Option<Rev>,
+

+
    /// Remove the reaction
+
    ///
+
    /// Requires using `--react <COMMENT_ID> --emoji <EMOJI>`
+
    #[arg(long, requires = "reaction")]
+
    undo: bool,
+
}
+

+
#[derive(Debug)]
+
pub(super) enum CommentAction {
+
    Comment {
+
        revision: Rev,
+
        message: Message,
+
        reply_to: Option<Rev>,
+
    },
+
    Edit {
+
        revision: Rev,
+
        comment: Rev,
+
        message: Message,
+
    },
+
    Redact {
+
        revision: Rev,
+
        comment: Rev,
+
    },
+
    React {
+
        revision: Rev,
+
        comment: Rev,
+
        emoji: radicle::cob::Reaction,
+
        undo: bool,
+
    },
+
}
+

+
impl From<CommentArgs> for CommentAction {
+
    fn from(
+
        CommentArgs {
+
            revision,
+
            message,
+
            edit,
+
            react,
+
            redact,
+
            reply_to,
+
            emoji,
+
            undo,
+
        }: CommentArgs,
+
    ) -> Self {
+
        match (edit, react, redact) {
+
            (Some(edit), None, None) => CommentAction::Edit {
+
                revision,
+
                comment: edit,
+
                message: Message::from(message),
+
            },
+
            (None, Some(react), None) => CommentAction::React {
+
                revision,
+
                comment: react,
+
                emoji: emoji.unwrap(),
+
                undo,
+
            },
+
            (None, None, Some(redact)) => CommentAction::Redact {
+
                revision,
+
                comment: redact,
+
            },
+
            (None, None, None) => Self::Comment {
+
                revision,
+
                message: Message::from(message),
+
                reply_to,
+
            },
+
            _ => unreachable!("`--edit`, `--react`, and `--redact` cannot be used together"),
+
        }
+
    }
+
}
+

+
#[derive(Parser, Debug, Default)]
+
pub(super) struct EmptyArgs {
+
    #[arg(long, hide = true, value_name = "DID", num_args = 1.., action = clap::ArgAction::Append)]
+
    authors: Vec<Did>,
+

+
    #[arg(long, hide = true)]
+
    authored: bool,
+

+
    #[clap(flatten)]
+
    state: EmptyStateArgs,
+
}
+

+
#[derive(Parser, Debug, Default)]
+
#[group(multiple = false)]
+
pub(super) struct EmptyStateArgs {
+
    #[arg(long, hide = true)]
+
    all: bool,
+

+
    #[arg(long, hide = true)]
+
    draft: bool,
+

+
    #[arg(long, hide = true)]
+
    open: bool,
+

+
    #[arg(long, hide = true)]
+
    merged: bool,
+

+
    #[arg(long, hide = true)]
+
    archived: bool,
+
}
+

+
#[derive(Parser, Debug, Default)]
+
pub(super) struct ListArgs {
+
    /// Show only patched where the given user is an author (may be specified
+
    /// multiple times)
+
    #[arg(
+
        long = "author",
+
        value_name = "DID",
+
        num_args = 1..,
+
        action = clap::ArgAction::Append,
+
    )]
+
    pub(super) authors: Vec<Did>,
+

+
    /// Show only patches that you have authored
+
    #[arg(long)]
+
    pub(super) authored: bool,
+

+
    #[clap(flatten)]
+
    pub(super) state: ListStateArgs,
+
}
+

+
impl From<EmptyArgs> for ListArgs {
+
    fn from(args: EmptyArgs) -> Self {
+
        Self {
+
            authors: args.authors,
+
            authored: args.authored,
+
            state: ListStateArgs::from(args.state),
+
        }
+
    }
+
}
+

+
#[derive(Parser, Debug, Default)]
+
#[group(multiple = false)]
+
pub(crate) struct ListStateArgs {
+
    /// Show all patches, including draft, merged, and archived patches
+
    #[arg(long)]
+
    pub(crate) all: bool,
+

+
    /// Show only draft patches
+
    #[arg(long)]
+
    pub(crate) draft: bool,
+

+
    /// Show only open patches (default)
+
    #[arg(long)]
+
    pub(crate) open: bool,
+

+
    /// Show only merged patches
+
    #[arg(long)]
+
    pub(crate) merged: bool,
+

+
    /// Show only archived patches
+
    #[arg(long)]
+
    pub(crate) archived: bool,
+
}
+

+
impl From<EmptyStateArgs> for ListStateArgs {
+
    fn from(args: EmptyStateArgs) -> Self {
+
        Self {
+
            all: args.all,
+
            draft: args.draft,
+
            open: args.open,
+
            merged: args.merged,
+
            archived: args.archived,
+
        }
+
    }
+
}
+

+
impl From<&ListStateArgs> for Option<&Status> {
+
    fn from(args: &ListStateArgs) -> Self {
+
        match (args.all, args.draft, args.open, args.merged, args.archived) {
+
            (true, false, false, false, false) => None,
+
            (false, true, false, false, false) => Some(&Status::Draft),
+
            (false, false, true, false, false) | (false, false, false, false, false) => {
+
                Some(&Status::Open)
+
            }
+
            (false, false, false, true, false) => Some(&Status::Merged),
+
            (false, false, false, false, true) => Some(&Status::Archived),
+
            _ => unreachable!(),
+
        }
+
    }
+
}
+

+
#[derive(Debug, Parser)]
+
pub(super) struct ReviewArgs {
+
    /// Review by patch hunks
+
    ///
+
    /// This operation is obsolete
+
    #[arg(long, short, group = "by-hunk", conflicts_with = "delete")]
+
    patch: bool,
+

+
    /// Generate diffs with <N> lines of context
+
    ///
+
    /// This operation is obsolete
+
    #[arg(
+
        long,
+
        short = 'U',
+
        value_name = "N",
+
        requires = "by-hunk",
+
        default_value_t = 3
+
    )]
+
    unified: usize,
+

+
    /// Only review a specific hunk
+
    ///
+
    /// This operation is obsolete
+
    #[arg(long, value_name = "INDEX", requires = "by-hunk")]
+
    hunk: Option<usize>,
+

+
    /// Accept a patch revision
+
    #[arg(long, conflicts_with = "reject", conflicts_with = "delete")]
+
    accept: bool,
+

+
    /// Reject a patch revision
+
    #[arg(long, conflicts_with = "delete")]
+
    reject: bool,
+

+
    /// Delete a review draft
+
    ///
+
    /// This operation is obsolete
+
    #[arg(long, short)]
+
    delete: bool,
+

+
    #[clap(flatten)]
+
    message_args: MessageArgs,
+
}
+

+
impl ReviewArgs {
+
    fn as_operation(&self) -> review::Operation {
+
        let Self {
+
            patch,
+
            accept,
+
            reject,
+
            delete,
+
            ..
+
        } = self;
+

+
        if *patch {
+
            let verdict = if *accept {
+
                Some(Verdict::Accept)
+
            } else if *reject {
+
                Some(Verdict::Reject)
+
            } else {
+
                None
+
            };
+
            return review::Operation::Review(review::ReviewOptions {
+
                by_hunk: true,
+
                unified: self.unified,
+
                hunk: self.hunk,
+
                verdict,
+
            });
+
        }
+

+
        if *delete {
+
            return review::Operation::Delete;
+
        }
+

+
        if *accept {
+
            return review::Operation::Review(review::ReviewOptions {
+
                by_hunk: false,
+
                unified: 3,
+
                hunk: None,
+
                verdict: Some(Verdict::Accept),
+
            });
+
        }
+

+
        if *reject {
+
            return review::Operation::Review(review::ReviewOptions {
+
                by_hunk: false,
+
                unified: 3,
+
                hunk: None,
+
                verdict: Some(Verdict::Reject),
+
            });
+
        }
+

+
        panic!("expected one of `--patch`, `--delete`, `--accept`, or `--reject`");
+
    }
+
}
+

+
impl From<ReviewArgs> for review::Options {
+
    fn from(args: ReviewArgs) -> Self {
+
        let op = args.as_operation();
+
        Self {
+
            message: Message::from(args.message_args),
+
            op,
+
        }
+
    }
+
}
+

+
#[derive(Debug, clap::Args)]
+
#[group(required = false, multiple = false)]
+
pub(super) struct MessageArgs {
+
    /// Provide a message (default: prompt)
+
    ///
+
    /// This can be specified multiple times. This will result in newlines
+
    /// between the specified messages.
+
    #[clap(
+
        long,
+
        short,
+
        value_name = "MESSAGE",
+
        num_args = 1..,
+
        action = clap::ArgAction::Append
+
    )]
+
    pub(super) message: Option<Vec<String>>,
+

+
    /// Do not provide a message
+
    #[arg(long, conflicts_with = "message")]
+
    pub(super) no_message: bool,
+
}
+

+
impl From<MessageArgs> for Message {
+
    fn from(
+
        MessageArgs {
+
            message,
+
            no_message,
+
        }: MessageArgs,
+
    ) -> Self {
+
        if no_message {
+
            assert!(message.is_none());
+
            return Self::Blank;
+
        }
+

+
        match message {
+
            Some(messages) => messages.into_iter().fold(Self::Blank, |mut result, m| {
+
                result.append(&m);
+
                result
+
            }),
+
            None => Self::Edit,
+
        }
+
    }
+
}
+

+
#[derive(Debug, clap::Args)]
+
pub(super) struct CheckoutArgs {
+
    /// Provide a name for the branch to checkout
+
    #[arg(long, value_name = "BRANCH", value_parser = parse_refstr)]
+
    pub(super) name: Option<RefString>,
+

+
    /// Provide the git remote to use as the upstream
+
    #[arg(long, value_parser = parse_refstr)]
+
    pub(super) remote: Option<RefString>,
+

+
    /// Checkout the head of the revision, even if the branch already exists
+
    #[arg(long, short)]
+
    pub(super) force: bool,
+
}
+

+
impl From<CheckoutArgs> for checkout::Options {
+
    fn from(value: CheckoutArgs) -> Self {
+
        Self {
+
            name: value.name,
+
            remote: value.remote,
+
            force: value.force,
+
        }
+
    }
+
}
+

+
#[derive(Parser, Debug)]
+
#[group(required = true)]
+
pub(super) struct AssignArgs {
+
    /// Add an assignee to the patch (may be specified multiple times).
+
    ///
+
    /// Note: `--add` takes precedence over `--delete`
+
    #[arg(long, short, value_name = "DID", num_args = 1.., action = clap::ArgAction::Append)]
+
    pub(super) add: Vec<Did>,
+

+
    /// Remove an assignee from the patch (may be specified multiple times).
+
    ///
+
    /// Note: `--add` takes precedence over `--delete`
+
    #[clap(long, short, value_name = "DID", num_args = 1.., action = clap::ArgAction::Append)]
+
    pub(super) delete: Vec<Did>,
+
}
+

+
#[derive(Parser, Debug)]
+
#[group(required = true)]
+
pub(super) struct LabelArgs {
+
    /// Add a label to the patch (may be specified multiple times).
+
    ///
+
    /// Note: `--add` takes precedence over `--delete`
+
    #[arg(long, short, value_name = "LABEL", num_args = 1.., action = clap::ArgAction::Append)]
+
    pub(super) add: Vec<Label>,
+

+
    /// Remove a label from the patch (may be specified multiple times).
+
    ///
+
    /// Note: `--add` takes precedence over `--delete`
+
    #[clap(long, short, value_name = "LABEL", num_args = 1.., action = clap::ArgAction::Append)]
+
    pub(super) delete: Vec<Label>,
+
}
+

+
fn parse_refstr(refstr: &str) -> Result<RefString, git::fmt::Error> {
+
    RefString::try_from(refstr)
+
}
modified crates/radicle-cli/src/commands/patch/checkout.rs
@@ -1,9 +1,10 @@
use anyhow::anyhow;

-
use git_ref_format::Qualified;
use radicle::cob::patch;
use radicle::cob::patch::RevisionId;
-
use radicle::git::RefString;
+
use radicle::git::fmt::Qualified;
+
use radicle::git::fmt::RefString;
+
use radicle::git::raw::ErrorExt as _;
use radicle::patch::cache::Patches as _;
use radicle::patch::PatchId;
use radicle::storage::git::Repository;
@@ -24,7 +25,7 @@ impl Options {
            Some(refname) => Ok(Qualified::from_refstr(refname)
                .map_or_else(|| refname.clone(), |q| q.to_ref_string())),
            // SAFETY: Patch IDs are valid refstrings.
-
            None => Ok(git::refname!("patch")
+
            None => Ok(git::fmt::refname!("patch")
                .join(RefString::try_from(term::format::cob(id).item).unwrap())),
        }
    }
@@ -56,34 +57,33 @@ pub fn run(
    let mut spinner = term::spinner("Performing checkout...");
    let patch_branch = opts.branch(patch_id)?;

-
    let commit =
-
        match working.find_branch(patch_branch.as_str(), radicle::git::raw::BranchType::Local) {
-
            Ok(branch) if opts.force => {
-
                let commit = find_patch_commit(revision, stored, working)?;
-
                let mut r = branch.into_reference();
-
                r.set_target(commit.id(), &format!("force update '{patch_branch}'"))?;
-
                commit
-
            }
-
            Ok(branch) => {
-
                let head = branch.get().peel_to_commit()?;
-
                if head.id() != *revision.head() {
-
                    anyhow::bail!(
-
                        "branch '{patch_branch}' already exists (use `--force` to overwrite)"
-
                    );
-
                }
-
                head
-
            }
-
            Err(e) if radicle::git::is_not_found_err(&e) => {
-
                let commit = find_patch_commit(revision, stored, working)?;
-
                // Create patch branch and switch to it.
-
                working.branch(patch_branch.as_str(), &commit, true)?;
-
                commit
+
    let commit = match working.find_branch(patch_branch.as_str(), git::raw::BranchType::Local) {
+
        Ok(branch) if opts.force => {
+
            let commit = find_patch_commit(revision, stored, working)?;
+
            let mut r = branch.into_reference();
+
            r.set_target(commit.id(), &format!("force update '{patch_branch}'"))?;
+
            commit
+
        }
+
        Ok(branch) => {
+
            let head = branch.get().peel_to_commit()?;
+
            if revision.head() != head.id() {
+
                anyhow::bail!(
+
                    "branch '{patch_branch}' already exists (use `--force` to overwrite)"
+
                );
            }
-
            Err(e) => return Err(e.into()),
-
        };
+
            head
+
        }
+
        Err(e) if e.is_not_found() => {
+
            let commit = find_patch_commit(revision, stored, working)?;
+
            // Create patch branch and switch to it.
+
            working.branch(patch_branch.as_str(), &commit, true)?;
+
            commit
+
        }
+
        Err(e) => return Err(e.into()),
+
    };

    if opts.force {
-
        let mut builder = radicle::git::raw::build::CheckoutBuilder::new();
+
        let mut builder = git::raw::build::CheckoutBuilder::new();
        builder.force();
        working.checkout_tree(commit.as_object(), Some(&mut builder))?;
    } else {
@@ -124,15 +124,27 @@ fn find_patch_commit<'a>(
    stored: &Repository,
    working: &'a git::raw::Repository,
) -> anyhow::Result<git::raw::Commit<'a>> {
-
    let head = *revision.head();
-
    let workdir = working
-
        .workdir()
-
        .ok_or(anyhow::anyhow!("repository is a bare git repository "))?;
+
    let head = revision.head().into();

    match working.find_commit(head) {
        Ok(commit) => Ok(commit),
-
        Err(e) if git::ext::is_not_found_err(&e) => {
-
            git::process::fetch_local(workdir, stored, [head.into()])?;
+
        Err(e) if e.is_not_found() => {
+
            let output = git::process::fetch_pack(
+
                Some(working.path()),
+
                stored,
+
                [head.into()],
+
                git::Verbosity::default(),
+
            )?;
+

+
            if !output.status.success() {
+
                anyhow::bail!(
+
                    "`git fetch` exited with status {}, stderr and stdout follow:\n{}\n{}\n",
+
                    output.status,
+
                    String::from_utf8_lossy(&output.stderr),
+
                    String::from_utf8_lossy(&output.stdout)
+
                );
+
            }
+

            working.find_commit(head).map_err(|e| e.into())
        }
        Err(e) => Err(e.into()),
modified crates/radicle-cli/src/commands/patch/comment.rs
@@ -1,8 +1,5 @@
-
#[path = "comment/edit.rs"]
pub mod edit;
-
#[path = "comment/react.rs"]
pub mod react;
-
#[path = "comment/redact.rs"]
pub mod redact;

use super::*;
modified crates/radicle-cli/src/commands/patch/list.rs
@@ -14,6 +14,8 @@ use term::Element as _;
use crate::terminal as term;
use crate::terminal::patch as common;

+
use itertools::Itertools as _;
+

/// List patches.
pub fn run(
    filter: Option<&patch::Status>,
@@ -79,13 +81,14 @@ pub fn run(
        is_me.then(by_rev_time).then(by_id)
    });

-
    let mut errors = Vec::new();
-
    for (id, patch) in &mut all {
-
        match row(id, patch, repository, profile) {
-
            Ok(r) => table.push(r),
-
            Err(e) => errors.push((patch.title(), id, e.to_string())),
-
        }
-
    }
+
    let (rows, errors): (Vec<_>, Vec<_>) = all
+
        .iter()
+
        .map(|(id, patch)| {
+
            row(id, patch, repository, profile).map_err(|e| (patch.title(), id, e.to_string()))
+
        })
+
        .partition_result();
+

+
    table.extend(rows);
    table.print();

    if !errors.is_empty() {
modified crates/radicle-cli/src/commands/patch/review.rs
@@ -1,4 +1,3 @@
-
#[path = "review/builder.rs"]
mod builder;

use anyhow::{anyhow, Context};
@@ -22,19 +21,16 @@ Markdown supported.
"#;

#[derive(Debug, PartialEq, Eq)]
-
pub enum Operation {
-
    Delete,
-
    Review {
-
        by_hunk: bool,
-
        unified: usize,
-
        hunk: Option<usize>,
-
        verdict: Option<Verdict>,
-
    },
+
pub(super) struct ReviewOptions {
+
    pub(super) by_hunk: bool,
+
    pub(super) unified: usize,
+
    pub(super) hunk: Option<usize>,
+
    pub(super) verdict: Option<Verdict>,
}

-
impl Default for Operation {
+
impl Default for ReviewOptions {
    fn default() -> Self {
-
        Self::Review {
+
        Self {
            by_hunk: false,
            unified: 3,
            hunk: None,
@@ -43,6 +39,18 @@ impl Default for Operation {
    }
}

+
#[derive(Debug, PartialEq, Eq)]
+
pub(super) enum Operation {
+
    Delete,
+
    Review(ReviewOptions),
+
}
+

+
impl Default for Operation {
+
    fn default() -> Self {
+
        Operation::Review(ReviewOptions::default())
+
    }
+
}
+

#[derive(Debug, Default)]
pub struct Options {
    pub message: Message,
@@ -78,12 +86,13 @@ pub fn run(

    let patch_id_pretty = term::format::tertiary(term::format::cob(&patch_id));
    match options.op {
-
        Operation::Review {
-
            verdict,
+
        Operation::Review(ReviewOptions {
            by_hunk,
            unified,
            hunk,
-
        } if by_hunk => {
+
            verdict,
+
        }) if by_hunk => {
+
            crate::warning::obsolete("rad patch review --patch");
            let mut opts = git::raw::DiffOptions::new();
            opts.patience(true)
                .minimal(true)
@@ -94,7 +103,7 @@ pub fn run(
                .verdict(verdict)
                .run(revision, &mut opts, &signer)?;
        }
-
        Operation::Review { verdict, .. } => {
+
        Operation::Review(ReviewOptions { verdict, .. }) => {
            let message = options.message.get(REVIEW_HELP_MSG)?;
            let message = message.replace(REVIEW_HELP_MSG.trim(), "");
            let message = if message.is_empty() {
@@ -125,6 +134,7 @@ pub fn run(
            }
        }
        Operation::Delete => {
+
            crate::warning::obsolete("rad patch review --delete");
            let name = git::refs::storage::draft::review(profile.id(), &patch_id);

            match repository.backend.find_reference(&name) {
modified crates/radicle-cli/src/commands/patch/review/builder.rs
@@ -23,10 +23,10 @@ use radicle::cob::patch::{PatchId, Revision, Verdict};
use radicle::cob::{CodeLocation, CodeRange};
use radicle::crypto;
use radicle::git;
+
use radicle::git::Oid;
use radicle::node::device::Device;
use radicle::prelude::*;
use radicle::storage::git::{cob::DraftStore, Repository};
-
use radicle_git_ext::Oid;
use radicle_surf::diff::*;
use radicle_term::{Element, VStack};

@@ -196,25 +196,28 @@ impl ReviewItem {

    fn paths(&self) -> (Option<(&Path, Oid)>, Option<(&Path, Oid)>) {
        match self {
-
            Self::FileAdded { path, new, .. } => (None, Some((path, new.oid))),
-
            Self::FileDeleted { path, old, .. } => (Some((path, old.oid)), None),
+
            Self::FileAdded { path, new, .. } => (None, Some((path, Oid::from(*new.oid)))),
+
            Self::FileDeleted { path, old, .. } => (Some((path, Oid::from(*old.oid))), None),
            Self::FileMoved { moved } => (
-
                Some((&moved.old_path, moved.old.oid)),
-
                Some((&moved.new_path, moved.new.oid)),
+
                Some((&moved.old_path, Oid::from(*moved.old.oid))),
+
                Some((&moved.new_path, Oid::from(*moved.new.oid))),
            ),
            Self::FileCopied { copied } => (
-
                Some((&copied.old_path, copied.old.oid)),
-
                Some((&copied.new_path, copied.new.oid)),
+
                Some((&copied.old_path, Oid::from(*copied.old.oid))),
+
                Some((&copied.new_path, Oid::from(*copied.new.oid))),
+
            ),
+
            Self::FileModified { path, old, new, .. } => (
+
                Some((path, Oid::from(*old.oid))),
+
                Some((path, Oid::from(*new.oid))),
+
            ),
+
            Self::FileEofChanged { path, old, new, .. } => (
+
                Some((path, Oid::from(*old.oid))),
+
                Some((path, Oid::from(*new.oid))),
+
            ),
+
            Self::FileModeChanged { path, old, new, .. } => (
+
                Some((path, Oid::from(*old.oid))),
+
                Some((path, Oid::from(*new.oid))),
            ),
-
            Self::FileModified { path, old, new, .. } => {
-
                (Some((path, old.oid)), Some((path, new.oid)))
-
            }
-
            Self::FileEofChanged { path, old, new, .. } => {
-
                (Some((path, old.oid)), Some((path, new.oid)))
-
            }
-
            Self::FileModeChanged { path, old, new, .. } => {
-
                (Some((path, old.oid)), Some((path, new.oid)))
-
            }
        }
    }

@@ -452,7 +455,7 @@ impl FileReviewBuilder {
        }
    }

-
    fn item_diff(&mut self, item: ReviewItem) -> Result<git::raw::Diff, Error> {
+
    fn item_diff(&mut self, item: ReviewItem) -> Result<git::raw::Diff<'_>, Error> {
        let mut buf = Vec::new();
        let mut writer = unified_diff::Writer::new(&mut buf);
        writer.encode(&self.header)?;
@@ -479,7 +482,7 @@ impl FileReviewBuilder {
/// of changes introduced by a patch.
pub struct Brain<'a> {
    /// Where the review draft is being stored.
-
    refname: git::Namespaced<'a>,
+
    refname: git::fmt::Namespaced<'a>,
    /// The commit pointed to by the ref.
    head: git::raw::Commit<'a>,
    /// The tree of accepted changes pointed to by the head commit.
@@ -565,7 +568,7 @@ impl<'a> Brain<'a> {
    }

    /// Get the brain's refname given the patch and remote.
-
    fn refname(patch: &PatchId, remote: &NodeId) -> git::Namespaced<'a> {
+
    fn refname(patch: &PatchId, remote: &NodeId) -> git::fmt::Namespaced<'a> {
        git::refs::storage::draft::review(remote, patch)
    }
}
modified crates/radicle-cli/src/commands/patch/show.rs
@@ -5,6 +5,7 @@ use radicle::git;
use radicle::storage::git::Repository;

use crate::terminal as term;
+
use crate::terminal::Error;

use super::*;

modified crates/radicle-cli/src/commands/patch/update.rs
@@ -1,5 +1,6 @@
use radicle::cob::patch;
use radicle::git;
+
use radicle::git::Oid;
use radicle::prelude::*;
use radicle::storage::git::Repository;

@@ -9,7 +10,7 @@ use crate::terminal::patch::*;
/// Run patch update.
pub fn run(
    patch_id: patch::PatchId,
-
    base_id: Option<git::raw::Oid>,
+
    base_id: Option<Oid>,
    message: term::patch::Message,
    profile: &Profile,
    repository: &Repository,
@@ -27,22 +28,25 @@ pub fn run(
    let head_oid = branch_oid(&head_branch)?;
    let base_oid = match base_id {
        Some(oid) => oid,
-
        None => repository.backend.merge_base(*target_oid, *head_oid)?,
+
        None => repository
+
            .backend
+
            .merge_base(target_oid.into(), head_oid.into())?
+
            .into(),
    };

    // N.b. we don't update if both the head and base are the same as
    // any previous revision
    if patch
        .revisions()
-
        .any(|(_, revision)| revision.head() == head_oid && **revision.base() == base_oid)
+
        .any(|(_, revision)| revision.head() == head_oid && *revision.base() == base_oid)
    {
        return Ok(());
    }

    let (_, revision) = patch.latest();
-
    let message = term::patch::get_update_message(message, workdir, revision, &head_oid)?;
+
    let message = term::patch::get_update_message(message, workdir, revision, &head_oid.into())?;
    let signer = term::signer(profile)?;
-
    let revision = patch.update(message, base_oid, *head_oid, &signer)?;
+
    let revision = patch.update(message, base_oid, head_oid, &signer)?;

    term::print(revision);

modified crates/radicle-cli/src/commands/path.rs
@@ -1,54 +1,12 @@
-
#![allow(clippy::or_fun_call)]
-
use std::ffi::OsString;
-

-
use anyhow::anyhow;
+
mod args;

use radicle::profile;

use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
-

-
pub const HELP: Help = Help {
-
    name: "path",
-
    description: "Display the Radicle home path",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad path [<option>...]
-

-
    If no argument is specified, the Radicle home path is displayed.
-

-
Options
-

-
    --help    Print help

-
"#,
-
};
-

-
pub struct Options {}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-

-
        #[allow(clippy::never_loop)]
-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                _ => return Err(anyhow!(arg.unexpected())),
-
            }
-
        }
-

-
        Ok((Options {}, vec![]))
-
    }
-
}
+
pub use args::Args;

-
pub fn run(_options: Options, _ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(_args: Args, _ctx: impl term::Context) -> anyhow::Result<()> {
    let home = profile::home()?;

    println!("{}", home.path().display());
added crates/radicle-cli/src/commands/path/args.rs
@@ -0,0 +1,7 @@
+
use clap::Parser;
+

+
const ABOUT: &str = "Display the Radicle home path";
+

+
#[derive(Parser, Debug)]
+
#[command(about = ABOUT, disable_version_flag = true)]
+
pub struct Args {}
modified crates/radicle-cli/src/commands/publish.rs
@@ -1,75 +1,19 @@
-
use std::ffi::OsString;
+
mod args;

use anyhow::{anyhow, Context as _};

use radicle::cob;
use radicle::identity::{Identity, Visibility};
use radicle::node::Handle as _;
-
use radicle::prelude::RepoId;
use radicle::storage::{SignRepository, ValidateRepository, WriteRepository, WriteStorage};

use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};

-
pub const HELP: Help = Help {
-
    name: "publish",
-
    description: "Publish a repository to the network",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
+
pub use args::Args;

-
    rad publish [<rid>] [<option>...]
-

-
    Publishing a private repository makes it public and discoverable
-
    on the network.
-

-
    By default, this command will publish the current repository.
-
    If an `<rid>` is specified, that repository will be published instead.
-

-
    Note that this command can only be run for repositories with a
-
    single delegate. The delegate must be the currently authenticated
-
    user. For repositories with more than one delegate, the `rad id`
-
    command must be used.
-

-
Options
-

-
    --help                    Print help
-
"#,
-
};
-

-
#[derive(Default, Debug)]
-
pub struct Options {
-
    pub rid: Option<RepoId>,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut rid = None;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Value(val) if rid.is_none() => {
-
                    rid = Some(term::args::rid(&val)?);
-
                }
-
                arg => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
-

-
        Ok((Options { rid }, vec![]))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
-
    let rid = match options.rid {
+
    let rid = match args.rid {
        Some(rid) => rid,
        None => radicle::rad::cwd()
            .map(|(_, rid)| rid)
@@ -81,7 +25,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    let doc = identity.doc();

    if doc.is_public() {
-
        return Err(Error::WithHint {
+
        return Err(term::Error::WithHint {
            err: anyhow!("repository is already public"),
            hint: "to announce the repository to the network, run `rad sync --inventory`",
        }
@@ -91,7 +35,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        return Err(anyhow!("only the repository delegate can publish it"));
    }
    if doc.delegates().len() > 1 {
-
        return Err(Error::WithHint {
+
        return Err(term::Error::WithHint {
            err: anyhow!(
                "only repositories with a single delegate can be published with this command"
            ),
added crates/radicle-cli/src/commands/publish/args.rs
@@ -0,0 +1,51 @@
+
use radicle::identity::RepoId;
+

+
const ABOUT: &str = "Publish a repository to the network";
+

+
const LONG_ABOUT: &str = r#"
+
Publishing a private repository makes it public and discoverable
+
on the network.
+

+
By default, this command will publish the current repository.
+
If an `<rid>` is specified, that repository will be published instead.
+

+
Note that this command can only be run for repositories with a
+
single delegate. The delegate must be the currently authenticated
+
user. For repositories with more than one delegate, the `rad id`
+
command must be used."#;
+

+
#[derive(Debug, clap::Parser)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    /// The Repository ID of the repository to publish
+
    ///
+
    /// [example values: rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH, z3Tr6bC7ctEg2EHmLvknUr29mEDLH]
+
    #[arg(value_name = "RID")]
+
    pub(super) rid: Option<RepoId>,
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use super::Args;
+
    use clap::error::ErrorKind;
+
    use clap::Parser;
+

+
    #[test]
+
    fn should_parse_rid_non_urn() {
+
        let args = Args::try_parse_from(["publish", "z3Tr6bC7ctEg2EHmLvknUr29mEDLH"]);
+
        assert!(args.is_ok())
+
    }
+

+
    #[test]
+
    fn should_parse_rid_urn() {
+
        let args = Args::try_parse_from(["publish", "rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH"]);
+
        assert!(args.is_ok())
+
    }
+

+
    #[test]
+
    fn should_not_parse_rid_url() {
+
        let err =
+
            Args::try_parse_from(["publish", "rad://z3Tr6bC7ctEg2EHmLvknUr29mEDLH"]).unwrap_err();
+
        assert_eq!(err.kind(), ErrorKind::ValueValidation);
+
    }
+
}
modified crates/radicle-cli/src/commands/remote.rs
@@ -1,208 +1,50 @@
//! Remote Command implementation
-
#[path = "remote/add.rs"]
+

pub mod add;
-
#[path = "remote/list.rs"]
pub mod list;
-
#[path = "remote/rm.rs"]
pub mod rm;

-
use std::ffi::OsString;
+
mod args;

use anyhow::anyhow;

-
use radicle::git::RefString;
-
use radicle::prelude::NodeId;
use radicle::storage::ReadStorage;

use crate::terminal as term;
-
use crate::terminal::args;
-
use crate::terminal::{Args, Context, Help};
-

-
pub const HELP: Help = Help {
-
    name: "remote",
-
    description: "Manage a repository's remotes",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad remote [<option>...]
-
    rad remote list [--tracked | --untracked | --all] [<option>...]
-
    rad remote add (<did> | <nid>) [--name <string>] [<option>...]
-
    rad remote rm <name> [<option>...]
-

-
List options
-

-
    --tracked     Show all remotes that are listed in the working copy
-
    --untracked   Show all remotes that are listed in the Radicle storage
-
    --all         Show all remotes in both the Radicle storage and the working copy
-

-
Add options
-

-
    --name        Override the name of the remote that by default is set to the node alias
-
    --[no-]fetch  Fetch the remote from local storage (default: fetch)
-
    --[no-]sync   Sync the remote refs from the network (default: sync)
-

-
Options
-

-
    --help        Print help
-
"#,
-
};
-

-
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-
pub enum OperationName {
-
    Add,
-
    Rm,
-
    #[default]
-
    List,
-
}
+
use crate::terminal::Context;

-
#[derive(Debug)]
-
pub enum Operation {
-
    Add {
-
        id: NodeId,
-
        name: Option<RefString>,
-
        fetch: bool,
-
        sync: bool,
-
    },
-
    Rm {
-
        name: RefString,
-
    },
-
    List {
-
        option: ListOption,
-
    },
-
}
-

-
#[derive(Debug, Default)]
-
pub enum ListOption {
-
    All,
-
    #[default]
-
    Tracked,
-
    Untracked,
-
}
-

-
#[derive(Debug)]
-
pub struct Options {
-
    pub op: Operation,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut op: Option<OperationName> = None;
-
        let mut id: Option<NodeId> = None;
-
        let mut name: Option<RefString> = None;
-
        let mut list_op: ListOption = ListOption::default();
-
        let mut fetch = true;
-
        let mut sync = true;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(args::Error::Help.into());
-
                }
-
                Long("name") | Short('n') => {
-
                    let value = parser.value()?;
-
                    let value = args::refstring("name", value)?;
-

-
                    name = Some(value);
-
                }
-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "a" | "add" => op = Some(OperationName::Add),
-
                    "l" | "list" => op = Some(OperationName::List),
-
                    "r" | "rm" => op = Some(OperationName::Rm),
-
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
-
                },
+
pub use args::Args;
+
use args::{Command, ListOption};

-
                // List options
-
                Long("all") if op.unwrap_or_default() == OperationName::List => {
-
                    list_op = ListOption::All;
-
                }
-
                Long("tracked") if op.unwrap_or_default() == OperationName::List => {
-
                    list_op = ListOption::Tracked;
-
                }
-
                Long("untracked") if op.unwrap_or_default() == OperationName::List => {
-
                    list_op = ListOption::Untracked;
-
                }
-

-
                // Add options
-
                Long("sync") if op == Some(OperationName::Add) => {
-
                    sync = true;
-
                }
-
                Long("no-sync") if op == Some(OperationName::Add) => {
-
                    sync = false;
-
                }
-
                Long("fetch") if op == Some(OperationName::Add) => {
-
                    fetch = true;
-
                }
-
                Long("no-fetch") if op == Some(OperationName::Add) => {
-
                    fetch = false;
-
                }
-
                Value(val) if op == Some(OperationName::Add) && id.is_none() => {
-
                    let nid = args::pubkey(&val)?;
-
                    id = Some(nid);
-
                }
-

-
                // Remove options
-
                Value(val) if op == Some(OperationName::Rm) && name.is_none() => {
-
                    let val = args::string(&val);
-
                    let val = RefString::try_from(val)
-
                        .map_err(|e| anyhow!("invalid remote name specified: {e}"))?;
-

-
                    name = Some(val);
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        let op = match op.unwrap_or_default() {
-
            OperationName::Add => Operation::Add {
-
                id: id.ok_or(anyhow!(
-
                    "`DID` required, try running `rad remote add <did>`"
-
                ))?,
-
                name,
-
                fetch,
-
                sync,
-
            },
-
            OperationName::List => Operation::List { option: list_op },
-
            OperationName::Rm => Operation::Rm {
-
                name: name.ok_or(anyhow!("name required, see `rad remote`"))?,
-
            },
-
        };
-

-
        Ok((Options { op }, vec![]))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl Context) -> anyhow::Result<()> {
    let (working, rid) = radicle::rad::cwd()
        .map_err(|_| anyhow!("this command must be run in the context of a repository"))?;
    let profile = ctx.profile()?;
-

-
    match options.op {
-
        Operation::Add {
-
            ref id,
+
    let command = args
+
        .command
+
        .unwrap_or_else(|| Command::List(args.empty.into()));
+
    match command {
+
        Command::Add {
+
            nid,
            name,
            fetch,
            sync,
        } => {
            let proj = profile.storage.repository(rid)?.project()?;
            let branch = proj.default_branch();
-

            self::add::run(
                rid,
-
                id,
+
                &nid,
                name,
                Some(branch.clone()),
                &profile,
                &working,
-
                fetch,
-
                sync,
+
                fetch.should_fetch(),
+
                sync.should_sync(),
            )?
        }
-
        Operation::Rm { ref name } => self::rm::run(name, &working)?,
-
        Operation::List { option } => match option {
+
        Command::Rm { ref name } => self::rm::run(name, &working)?,
+
        Command::List(args) => match ListOption::from(args) {
            ListOption::All => {
                let tracked = list::tracked(&working)?;
                let untracked = list::untracked(rid, &profile, tracked.iter())?;
modified crates/radicle-cli/src/commands/remote/add.rs
@@ -1,14 +1,14 @@
use std::str::FromStr;

use radicle::git;
-
use radicle::git::RefString;
+
use radicle::git::fmt::RefString;
use radicle::prelude::*;
use radicle::Profile;
use radicle_crypto::PublicKey;

-
use crate::commands::rad_checkout as checkout;
-
use crate::commands::rad_follow as follow;
-
use crate::commands::rad_sync as sync;
+
use crate::commands::checkout;
+
use crate::commands::follow;
+
use crate::commands::sync;
use crate::node::SyncSettings;
use crate::project::SetupRemote;

added crates/radicle-cli/src/commands/remote/args.rs
@@ -0,0 +1,161 @@
+
use clap::{Parser, Subcommand};
+

+
use radicle::git;
+
use radicle::git::fmt::RefString;
+
use radicle::node::NodeId;
+

+
use crate::terminal as term;
+

+
const ABOUT: &str = "Manage a repository's remotes";
+

+
#[derive(Parser, Debug)]
+
#[command(about = ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    #[command(subcommand)]
+
    pub(super) command: Option<Command>,
+

+
    /// Arguments for the empty subcommand.
+
    /// Will fall back to [`Command::List`].
+
    #[clap(flatten)]
+
    pub(super) empty: EmptyArgs,
+
}
+

+
#[derive(Subcommand, Debug)]
+
pub(super) enum Command {
+
    /// Add a Git remote for the provided NID
+
    #[clap(alias = "a")]
+
    Add {
+
        /// The DID or NID of the remote to add
+
        #[arg(value_parser = term::args::parse_nid)]
+
        nid: NodeId,
+

+
        /// Override the name of the Git remote
+
        ///
+
        /// [default: <ALIAS>@<NID>]
+
        #[arg(long, short, value_name = "REMOTE", value_parser = parse_refstr)]
+
        name: Option<RefString>,
+

+
        #[clap(flatten)]
+
        fetch: FetchArgs,
+

+
        #[clap(flatten)]
+
        sync: SyncArgs,
+
    },
+
    /// Remove the Git remote identified by REMOTE
+
    #[clap(alias = "r")]
+
    Rm {
+
        /// The name of the remote to delete
+
        #[arg(value_name = "REMOTE", value_parser = parse_refstr)]
+
        name: RefString,
+
    },
+
    /// List the stored remotes
+
    ///
+
    /// Filter the listed remotes using the provided options
+
    #[clap(alias = "l")]
+
    List(ListArgs),
+
}
+

+
#[derive(Parser, Debug)]
+
pub(super) struct FetchArgs {
+
    /// Fetch the remote from local storage (default)
+
    #[arg(long, conflicts_with = "no_fetch")]
+
    fetch: bool,
+

+
    /// Do not fetch the remote from local storage
+
    #[arg(long)]
+
    no_fetch: bool,
+
}
+

+
impl FetchArgs {
+
    pub(super) fn should_fetch(&self) -> bool {
+
        let Self { fetch, no_fetch } = self;
+
        *fetch || !no_fetch
+
    }
+
}
+

+
#[derive(Parser, Debug)]
+
pub(super) struct SyncArgs {
+
    /// Sync the remote refs from the network (default)
+
    #[arg(long, conflicts_with = "no_sync")]
+
    sync: bool,
+

+
    /// Do not sync the remote refs from the network
+
    #[arg(long)]
+
    no_sync: bool,
+
}
+

+
impl SyncArgs {
+
    pub(super) fn should_sync(&self) -> bool {
+
        let Self { sync, no_sync } = self;
+
        *sync || !no_sync
+
    }
+
}
+

+
#[derive(Parser, Clone, Copy, Debug)]
+
#[group(multiple = false)]
+
pub struct ListArgs {
+
    /// Show all remotes in both the Radicle storage and the working copy
+
    #[arg(long)]
+
    all: bool,
+

+
    /// Show all remotes that are listed in the working copy
+
    #[arg(long)]
+
    tracked: bool,
+

+
    /// Show all remotes that are listed in the Radicle storage
+
    #[arg(long)]
+
    untracked: bool,
+
}
+

+
impl From<ListArgs> for ListOption {
+
    fn from(
+
        ListArgs {
+
            all,
+
            tracked,
+
            untracked,
+
        }: ListArgs,
+
    ) -> Self {
+
        match (all, tracked, untracked) {
+
            (true, false, false) => Self::All,
+
            (false, true, false) | (false, false, false) => Self::Tracked,
+
            (false, false, true) => Self::Untracked,
+
            _ => unreachable!(),
+
        }
+
    }
+
}
+

+
pub(super) enum ListOption {
+
    /// Show all remotes in both the Radicle storage and the working copy
+
    All,
+
    /// Show all remotes that are listed in the working copy
+
    Tracked,
+
    /// Show all remotes that are listed in the Radicle storage
+
    Untracked,
+
}
+

+
#[derive(Parser, Clone, Copy, Debug)]
+
#[group(multiple = false)]
+
pub(super) struct EmptyArgs {
+
    #[arg(long, hide = true)]
+
    all: bool,
+

+
    #[arg(long, hide = true)]
+
    tracked: bool,
+

+
    #[arg(long, hide = true)]
+
    untracked: bool,
+
}
+

+
impl From<EmptyArgs> for ListArgs {
+
    fn from(args: EmptyArgs) -> Self {
+
        Self {
+
            all: args.all,
+
            tracked: args.tracked,
+
            untracked: args.untracked,
+
        }
+
    }
+
}
+

+
fn parse_refstr(refstr: &str) -> Result<RefString, git::fmt::Error> {
+
    RefString::try_from(refstr)
+
}
modified crates/radicle-cli/src/commands/remote/list.rs
@@ -81,10 +81,9 @@ pub fn untracked<'a>(
}

pub fn print_tracked<'a>(tracked: impl Iterator<Item = &'a Tracked>) {
-
    let mut table = Table::default();
-
    for Tracked { direction, name } in tracked {
+
    Table::from_iter(tracked.into_iter().flat_map(|Tracked { direction, name }| {
        let Some(direction) = direction else {
-
            continue;
+
            return None;
        };

        let (dir, url) = match direction {
@@ -95,25 +94,24 @@ pub fn print_tracked<'a>(tracked: impl Iterator<Item = &'a Tracked>) {
            term::format::dim("(canonical upstream)".to_string()).italic(),
            |namespace| term::format::tertiary(namespace.to_string()),
        );
-
        table.push([
+
        Some([
            term::format::bold(name.clone()),
            description,
            term::format::parens(term::format::secondary(dir.to_owned())),
-
        ]);
-
    }
-
    table.print();
+
        ])
+
    }))
+
    .print();
}

pub fn print_untracked<'a>(untracked: impl Iterator<Item = &'a Untracked>) {
-
    let mut t = Table::default();
-
    for Untracked { remote, alias } in untracked {
-
        t.push([
+
    Table::from_iter(untracked.into_iter().map(|Untracked { remote, alias }| {
+
        [
            match alias {
                None => term::format::secondary("n/a".to_string()),
                Some(alias) => term::format::secondary(alias.to_string()),
            },
            term::format::highlight(Did::from(remote).to_string()),
-
        ])
-
    }
-
    t.print();
+
        ]
+
    }))
+
    .print();
}
modified crates/radicle-cli/src/commands/seed.rs
@@ -1,166 +1,39 @@
-
use std::collections::BTreeSet;
-
use std::ffi::OsString;
-
use std::time;
+
mod args;

-
use anyhow::anyhow;
-

-
use nonempty::NonEmpty;
use radicle::node::policy;
use radicle::node::policy::{Policy, Scope};
use radicle::node::Handle;
use radicle::{prelude::*, Node};
use radicle_term::Element as _;

-
use crate::commands::rad_sync as sync;
-
use crate::node::SyncSettings;
+
use crate::commands::sync;
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
-

-
pub const HELP: Help = Help {
-
    name: "seed",
-
    description: "Manage repository seeding policies",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad seed [<rid>...] [--[no-]fetch] [--from <nid>] [--scope <scope>] [<option>...]
-

-
    The `seed` command, when no Repository ID (<rid>) is provided, will list the
-
    repositories being seeded.
-

-
    When a Repository ID (<rid>) is provided it updates or creates the seeding policy for
-
    that repository. To delete a seeding policy, use the `rad unseed` command.
-

-
    When seeding a repository, a scope can be specified: this can be either `all` or
-
    `followed`. When using `all`, all remote nodes will be followed for that repository.
-
    On the other hand, with `followed`, only the repository delegates will be followed,
-
    plus any remote that is explicitly followed via `rad follow <nid>`.
-

-
Options
-

-
    --[no-]fetch           Fetch repository after updating seeding policy
-
    --from <nid>           Fetch from the given node (may be specified multiple times)
-
    --timeout <secs>       Fetch timeout in seconds (default: 9)
-
    --scope <scope>        Peer follow scope for this repository
-
    --verbose, -v          Verbose output
-
    --help                 Print help
-
"#,
-
};
-

-
#[derive(Debug)]
-
pub enum Operation {
-
    Seed {
-
        rids: NonEmpty<RepoId>,
-
        fetch: bool,
-
        seeds: BTreeSet<NodeId>,
-
        timeout: time::Duration,
-
        scope: Scope,
-
    },
-
    List,
-
}
-

-
#[derive(Debug)]
-
pub struct Options {
-
    pub op: Operation,
-
    pub verbose: bool,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut rids: Vec<RepoId> = Vec::new();
-
        let mut scope: Option<Scope> = None;
-
        let mut fetch: Option<bool> = None;
-
        let mut timeout = time::Duration::from_secs(9);
-
        let mut seeds: BTreeSet<NodeId> = BTreeSet::new();
-
        let mut verbose = false;
-

-
        while let Some(arg) = parser.next()? {
-
            match &arg {
-
                Value(val) => {
-
                    let rid = term::args::rid(val)?;
-
                    rids.push(rid);
-
                }
-
                Long("scope") => {
-
                    let val = parser.value()?;
-
                    scope = Some(term::args::parse_value("scope", val)?);
-
                }
-
                Long("fetch") => {
-
                    fetch = Some(true);
-
                }
-
                Long("no-fetch") => {
-
                    fetch = Some(false);
-
                }
-
                Long("from") => {
-
                    let val = parser.value()?;
-
                    let nid = term::args::nid(&val)?;

-
                    seeds.insert(nid);
-
                }
-
                Long("timeout") | Short('t') => {
-
                    let value = parser.value()?;
-
                    let secs = term::args::parse_value("timeout", value)?;
-

-
                    timeout = time::Duration::from_secs(secs);
-
                }
-
                Long("verbose") | Short('v') => verbose = true,
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                _ => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
-

-
        let op = match NonEmpty::from_vec(rids) {
-
            Some(rids) => Operation::Seed {
-
                rids,
-
                fetch: fetch.unwrap_or(true),
-
                scope: scope.unwrap_or(Scope::All),
-
                timeout,
-
                seeds,
-
            },
-
            None => Operation::List,
-
        };
-

-
        Ok((Options { op, verbose }, vec![]))
-
    }
-
}
+
pub use args::Args;

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let mut node = radicle::Node::new(profile.socket());

-
    match options.op {
-
        Operation::Seed {
+
    match args::Operation::from(args) {
+
        args::Operation::List => seeding(&profile)?,
+
        args::Operation::Seed {
            rids,
-
            fetch,
+
            should_fetch,
+
            settings,
            scope,
-
            timeout,
-
            seeds,
        } => {
+
            let settings = settings.with_profile(&profile);
            for rid in rids {
                update(rid, scope, &mut node, &profile)?;

-
                if fetch && node.is_running() {
-
                    if let Err(e) = sync::fetch(
-
                        rid,
-
                        SyncSettings::default()
-
                            .seeds(seeds.clone())
-
                            .timeout(timeout)
-
                            .with_profile(&profile),
-
                        &mut node,
-
                        &profile,
-
                    ) {
+
                if should_fetch && node.is_running() {
+
                    if let Err(e) = sync::fetch(rid, settings.clone(), &mut node, &profile) {
                        term::error(e);
                    }
                }
            }
        }
-
        Operation::List => seeding(&profile)?,
    }

    Ok(())
added crates/radicle-cli/src/commands/seed/args.rs
@@ -0,0 +1,104 @@
+
use std::time;
+

+
use clap::Parser;
+

+
use nonempty::NonEmpty;
+
use radicle::node::policy::Scope;
+
use radicle::prelude::*;
+

+
use crate::node::SyncSettings;
+
use crate::terminal;
+

+
const ABOUT: &str = "Manage repository seeding policies";
+

+
const LONG_ABOUT: &str = r#"
+
The `seed` command, when no Repository ID is provided, will list the
+
repositories being seeded.
+

+
When a Repository ID is provided it updates or creates the seeding policy for
+
that repository. To delete a seeding policy, use the `rad unseed` command.
+

+
When seeding a repository, a scope can be specified: this can be either `all` or
+
`followed`. When using `all`, all remote nodes will be followed for that repository.
+
On the other hand, with `followed`, only the repository delegates will be followed,
+
plus any remote that is explicitly followed via `rad follow <nid>`.
+
"#;
+

+
#[derive(Parser, Debug)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    #[arg(value_name = "RID", num_args = 1..)]
+
    pub(super) rids: Option<Vec<RepoId>>,
+

+
    /// Fetch repository after updating seeding policy
+
    #[arg(long, overrides_with("no_fetch"), hide(true))]
+
    fetch: bool,
+

+
    /// Do not fetch repository after updating seeding policy
+
    #[arg(long, overrides_with("fetch"))]
+
    no_fetch: bool,
+

+
    /// Fetch from the given node (may be specified multiple times)
+
    #[arg(long, value_name = "NID", action = clap::ArgAction::Append)]
+
    pub(super) from: Vec<NodeId>,
+

+
    /// Fetch timeout in seconds
+
    #[arg(long, short, value_name = "SECS", default_value_t = 9)]
+
    timeout: u64,
+

+
    /// Peer follow scope for this repository
+
    #[arg(
+
        long,
+
        default_value_t = Scope::All,
+
        value_parser = terminal::args::ScopeParser
+
    )]
+
    pub(super) scope: Scope,
+

+
    /// Verbose output
+
    #[arg(long, short)]
+
    pub(super) verbose: bool,
+
}
+

+
pub(super) enum Operation {
+
    List,
+
    Seed {
+
        rids: NonEmpty<RepoId>,
+
        should_fetch: bool,
+
        settings: SyncSettings,
+
        scope: Scope,
+
    },
+
}
+

+
impl From<Args> for Operation {
+
    fn from(args: Args) -> Self {
+
        let should_fetch = args.should_fetch();
+
        let timeout = args.timeout();
+
        let Args {
+
            rids, from, scope, ..
+
        } = args;
+
        match rids.and_then(NonEmpty::from_vec) {
+
            Some(rids) => Operation::Seed {
+
                rids,
+
                should_fetch,
+
                settings: SyncSettings::default().seeds(from).timeout(timeout),
+
                scope,
+
            },
+
            None => Self::List,
+
        }
+
    }
+
}
+

+
impl Args {
+
    fn timeout(&self) -> time::Duration {
+
        time::Duration::from_secs(self.timeout)
+
    }
+

+
    fn should_fetch(&self) -> bool {
+
        match (self.fetch, self.no_fetch) {
+
            (true, false) => true,
+
            (false, true) => false,
+
            // Default it to fetch
+
            (_, _) => true,
+
        }
+
    }
+
}
modified crates/radicle-cli/src/commands/self.rs
@@ -1,123 +1,40 @@
-
use std::ffi::OsString;
+
#[path = "self/args.rs"]
+
mod args;
+

+
pub use args::Args;

use radicle::crypto::ssh;
-
use radicle::Profile;
+
use radicle::node::Handle as _;
+
use radicle::{Node, Profile};

use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
use crate::terminal::Element as _;

-
pub const HELP: Help = Help {
-
    name: "self",
-
    description: "Show information about your identity and device",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad self [<option>...]
-

-
Options
-

-
    --did                Show your DID
-
    --alias              Show your Node alias
-
    --nid                Show your Node ID (NID)
-
    --home               Show your Radicle home
-
    --config             Show the location of your configuration file
-
    --ssh-key            Show your public key in OpenSSH format
-
    --ssh-fingerprint    Show your public key fingerprint in OpenSSH format
-
    --help               Show help
-
"#,
-
};
-

-
#[derive(Debug)]
-
enum Show {
-
    Alias,
-
    NodeId,
-
    Did,
-
    Home,
-
    Config,
-
    SshKey,
-
    SshFingerprint,
-
    All,
-
}
-

-
#[derive(Debug)]
-
pub struct Options {
-
    show: Show,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut show: Option<Show> = None;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("alias") if show.is_none() => {
-
                    show = Some(Show::Alias);
-
                }
-
                Long("nid") if show.is_none() => {
-
                    show = Some(Show::NodeId);
-
                }
-
                Long("did") if show.is_none() => {
-
                    show = Some(Show::Did);
-
                }
-
                Long("home") if show.is_none() => {
-
                    show = Some(Show::Home);
-
                }
-
                Long("config") if show.is_none() => {
-
                    show = Some(Show::Config);
-
                }
-
                Long("ssh-key") if show.is_none() => {
-
                    show = Some(Show::SshKey);
-
                }
-
                Long("ssh-fingerprint") if show.is_none() => {
-
                    show = Some(Show::SshFingerprint);
-
                }
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                show: show.unwrap_or(Show::All),
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;

-
    match options.show {
-
        Show::Alias => {
-
            term::print(profile.config.alias());
-
        }
-
        Show::NodeId => {
-
            term::print(profile.id());
-
        }
-
        Show::Did => {
-
            term::print(profile.did());
-
        }
-
        Show::Home => {
-
            term::print(profile.home().path().display());
-
        }
-
        Show::Config => {
-
            term::print(profile.home.config().display());
-
        }
-
        Show::SshKey => {
-
            term::print(ssh::fmt::key(profile.id()));
-
        }
-
        Show::SshFingerprint => {
-
            term::print(ssh::fmt::fingerprint(profile.id()));
-
        }
-
        Show::All => all(&profile)?,
+
    if args.did {
+
        term::print(profile.did());
+
    } else if args.alias {
+
        term::print(profile.config.alias());
+
    } else if args.home {
+
        term::print(profile.home().path().display());
+
    } else if args.ssh_key {
+
        term::print(ssh::fmt::key(profile.id()));
+
    } else if args.config {
+
        term::print(profile.home.config().display());
+
    } else if args.ssh_fingerprint {
+
        term::print(ssh::fmt::fingerprint(profile.id()));
+
    } else if args.nid {
+
        crate::warning::deprecated("rad self --nid", "rad node status --only nid");
+
        term::print(
+
            Node::new(profile.socket())
+
                .nid()
+
                .ok()
+
                .unwrap_or_else(|| *profile.id()),
+
        );
+
    } else {
+
        all(&profile)?
    }

    Ok(())
@@ -137,11 +54,13 @@ fn all(profile: &Profile) -> anyhow::Result<()> {
        term::format::tertiary(did).into(),
    ]);

-
    let node_id = profile.id();
-
    table.push([
-
        term::format::style("└╴Node ID (NID)").into(),
-
        term::format::tertiary(node_id).into(),
-
    ]);
+
    let socket = profile.socket();
+
    let node = if Node::new(&socket).is_running() {
+
        term::format::positive(format!("running ({})", socket.display()))
+
    } else {
+
        term::format::negative("not running".to_string())
+
    };
+
    table.push([term::format::style("Node").into(), node.to_string().into()]);

    let ssh_agent = match ssh::agent::Agent::connect() {
        Ok(c) => term::format::positive(format!(
@@ -158,13 +77,14 @@ fn all(profile: &Profile) -> anyhow::Result<()> {
        ssh_agent.to_string().into(),
    ]);

-
    let ssh_short = ssh::fmt::fingerprint(node_id);
+
    let id = profile.id();
+
    let ssh_short = ssh::fmt::fingerprint(id);
    table.push([
        term::format::style("├╴Key (hash)").into(),
        term::format::tertiary(ssh_short).into(),
    ]);

-
    let ssh_long = ssh::fmt::key(node_id);
+
    let ssh_long = ssh::fmt::key(id);
    table.push([
        term::format::style("└╴Key (full)").into(),
        term::format::tertiary(ssh_long).into(),
added crates/radicle-cli/src/commands/self/args.rs
@@ -0,0 +1,30 @@
+
use clap::Parser;
+

+
const ABOUT: &str = "Show information about your identity and device";
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, disable_version_flag = true)]
+
#[group(multiple = false)]
+
pub struct Args {
+
    /// Show your DID
+
    #[arg(long)]
+
    pub(super) did: bool,
+
    /// Show your Node alias
+
    #[arg(long)]
+
    pub(super) alias: bool,
+
    /// Show your Node identifier
+
    #[arg(long, hide(true))]
+
    pub(super) nid: bool,
+
    /// Show your Radicle home
+
    #[arg(long)]
+
    pub(super) home: bool,
+
    /// Show the location of your configuration file
+
    #[arg(long)]
+
    pub(super) config: bool,
+
    /// Show your public key in OpenSSH format
+
    #[arg(long)]
+
    pub(super) ssh_key: bool,
+
    /// Show your public key fingerprint in OpenSSH format
+
    #[arg(long)]
+
    pub(super) ssh_fingerprint: bool,
+
}
modified crates/radicle-cli/src/commands/stats.rs
@@ -1,4 +1,5 @@
-
use std::ffi::OsString;
+
mod args;
+

use std::path::Path;

use localtime::LocalDuration;
@@ -13,22 +14,8 @@ use radicle_term::Element;
use serde::Serialize;

use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
-

-
pub const HELP: Help = Help {
-
    name: "stats",
-
    description: "Displays aggregated repository and node metrics",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad stats [<option>...]

-
Options
-

-
    --help       Print help
-
"#,
-
};
+
pub use args::Args;

#[derive(Default, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -65,30 +52,7 @@ struct Stats {
    nodes: NodeStats,
}

-
#[derive(Default, Debug, Eq, PartialEq)]
-
pub struct Options {}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-

-
        #[allow(clippy::never_loop)]
-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        Ok((Options {}, vec![]))
-
    }
-
}
-

-
pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(_args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let storage = &profile.storage;
    let mut stats = Stats::default();
@@ -106,7 +70,7 @@ pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            let remote = remote?;
            let sigrefs = repo.reference_oid(&remote, &git::refs::storage::SIGREFS_BRANCH)?;
            let mut walk = repo.raw().revwalk()?;
-
            walk.push(*sigrefs)?;
+
            walk.push(sigrefs.into())?;

            stats.local.pushes += walk.count();
            stats.local.forks += 1;
added crates/radicle-cli/src/commands/stats/args.rs
@@ -0,0 +1,7 @@
+
use clap::Parser;
+

+
const ABOUT: &str = "Displays aggregated repository and node metrics";
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, disable_version_flag = true)]
+
pub struct Args {}
modified crates/radicle-cli/src/commands/sync.rs
@@ -1,9 +1,8 @@
+
mod args;
+

use std::cmp::Ordering;
use std::collections::BTreeMap;
-
use std::collections::BTreeSet;
use std::collections::HashSet;
-
use std::ffi::OsString;
-
use std::str::FromStr;
use std::time;

use anyhow::{anyhow, Context as _};
@@ -23,266 +22,13 @@ use radicle_term::Element;
use crate::node::SyncReporting;
use crate::node::SyncSettings;
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
use crate::terminal::format::Author;
use crate::terminal::{Table, TableOptions};

-
pub const HELP: Help = Help {
-
    name: "sync",
-
    description: "Sync repositories to the network",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad sync [--fetch | --announce] [<rid>] [<option>...]
-
    rad sync --inventory [<option>...]
-
    rad sync status [<rid>] [<option>...]
-

-
    By default, the current repository is synchronized both ways.
-
    If an <rid> is specified, that repository is synced instead.
-

-
    The process begins by fetching changes from connected seeds,
-
    followed by announcing local refs to peers, thereby prompting
-
    them to fetch from us.
-

-
    When `--fetch` is specified, any number of seeds may be given
-
    using the `--seed` option, eg. `--seed <nid>@<addr>:<port>`.
-

-
    When `--replicas` is specified, the given replication factor will try
-
    to be matched. For example, `--replicas 5` will sync with 5 seeds.
-

-
    The synchronization process can be configured using `--replicas <min>` and
-
    `--replicas-max <max>`. If these options are used independently, then the
-
    replication factor is taken as the given `<min>`/`<max>` value. If the
-
    options are used together, then the replication factor has a minimum and
-
    maximum bound.
-

-
    For fetching, the synchronization process will be considered successful if
-
    at least `<min>` seeds were fetched from *or* all preferred seeds were
-
    fetched from. If `<max>` is specified then the process will continue and
-
    attempt to sync with `<max>` seeds.
-

-
    For reference announcing, the synchronization process will be considered
-
    successful if at least `<min>` seeds were pushed to *and* all preferred
-
    seeds were pushed to.
-

-
    When `--fetch` or `--announce` are specified on their own, this command
-
    will only fetch or announce.
-

-
    If `--inventory` is specified, the node's inventory is announced to
-
    the network. This mode does not take an `<rid>`.
-

-
Commands
-

-
    status                    Display the sync status of a repository
-

-
Options
-

-
        --sort-by       <field>   Sort the table by column (options: nid, alias, status)
-
    -f, --fetch                   Turn on fetching (default: true)
-
    -a, --announce                Turn on ref announcing (default: true)
-
    -i, --inventory               Turn on inventory announcing (default: false)
-
        --timeout       <secs>    How many seconds to wait while syncing
-
        --seed          <nid>     Sync with the given node (may be specified multiple times)
-
    -r, --replicas      <count>   Sync with a specific number of seeds
-
        --replicas-max  <count>   Sync with an upper bound number of seeds
-
    -v, --verbose                 Verbose output
-
        --debug                   Print debug information afer sync
-
        --help                    Print help
-
"#,
-
};
-

-
#[derive(Debug, Clone, PartialEq, Eq, Default)]
-
pub enum Operation {
-
    Synchronize(SyncMode),
-
    #[default]
-
    Status,
-
}
-

-
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-
pub enum SortBy {
-
    Nid,
-
    Alias,
-
    #[default]
-
    Status,
-
}
-

-
impl FromStr for SortBy {
-
    type Err = &'static str;
-

-
    fn from_str(s: &str) -> Result<Self, Self::Err> {
-
        match s {
-
            "nid" => Ok(Self::Nid),
-
            "alias" => Ok(Self::Alias),
-
            "status" => Ok(Self::Status),
-
            _ => Err("invalid `--sort-by` field"),
-
        }
-
    }
-
}
-

-
#[derive(Debug, Clone, PartialEq, Eq)]
-
pub enum SyncMode {
-
    Repo {
-
        settings: SyncSettings,
-
        direction: SyncDirection,
-
    },
-
    Inventory,
-
}
-

-
impl Default for SyncMode {
-
    fn default() -> Self {
-
        Self::Repo {
-
            settings: SyncSettings::default(),
-
            direction: SyncDirection::default(),
-
        }
-
    }
-
}
-

-
#[derive(Debug, Default, PartialEq, Eq, Clone)]
-
pub enum SyncDirection {
-
    Fetch,
-
    Announce,
-
    #[default]
-
    Both,
-
}
-

-
#[derive(Default, Debug)]
-
pub struct Options {
-
    pub rid: Option<RepoId>,
-
    pub debug: bool,
-
    pub verbose: bool,
-
    pub sort_by: SortBy,
-
    pub op: Operation,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut verbose = false;
-
        let mut timeout = time::Duration::from_secs(9);
-
        let mut rid = None;
-
        let mut fetch = false;
-
        let mut announce = false;
-
        let mut inventory = false;
-
        let mut debug = false;
-
        let mut replicas = None;
-
        let mut max_replicas = None;
-
        let mut seeds = BTreeSet::new();
-
        let mut sort_by = SortBy::default();
-
        let mut op: Option<Operation> = None;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("debug") => {
-
                    debug = true;
-
                }
-
                Long("verbose") | Short('v') => {
-
                    verbose = true;
-
                }
-
                Long("fetch") | Short('f') => {
-
                    fetch = true;
-
                }
-
                Long("replicas") | Short('r') => {
-
                    let val = parser.value()?;
-
                    let count = term::args::number(&val)?;
-

-
                    if count == 0 {
-
                        anyhow::bail!("value for `--replicas` must be greater than zero");
-
                    }
-
                    replicas = Some(count);
-
                }
-
                Long("replicas-max") => {
-
                    let val = parser.value()?;
-
                    let count = term::args::number(&val)?;
-

-
                    if count == 0 {
-
                        anyhow::bail!("value for `--replicas-max` must be greater than zero");
-
                    }
-
                    max_replicas = Some(count);
-
                }
-
                Long("seed") => {
-
                    let val = parser.value()?;
-
                    let nid = term::args::nid(&val)?;
-

-
                    seeds.insert(nid);
-
                }
-
                Long("announce") | Short('a') => {
-
                    announce = true;
-
                }
-
                Long("inventory") | Short('i') => {
-
                    inventory = true;
-
                }
-
                Long("sort-by") if matches!(op, Some(Operation::Status)) => {
-
                    let value = parser.value()?;
-
                    sort_by = value.parse()?;
-
                }
-
                Long("timeout") | Short('t') => {
-
                    let value = parser.value()?;
-
                    let secs = term::args::parse_value("timeout", value)?;
-

-
                    timeout = time::Duration::from_secs(secs);
-
                }
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Value(val) if rid.is_none() => match val.to_string_lossy().as_ref() {
-
                    "s" | "status" => {
-
                        op = Some(Operation::Status);
-
                    }
-
                    _ => {
-
                        rid = Some(term::args::rid(&val)?);
-
                    }
-
                },
-
                arg => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
+
pub use args::Args;
+
use args::{Command, SortBy, SyncDirection, SyncMode};

-
        let sync = if inventory && fetch {
-
            anyhow::bail!("`--inventory` cannot be used with `--fetch`");
-
        } else if inventory {
-
            SyncMode::Inventory
-
        } else {
-
            let direction = match (fetch, announce) {
-
                (true, true) | (false, false) => SyncDirection::Both,
-
                (true, false) => SyncDirection::Fetch,
-
                (false, true) => SyncDirection::Announce,
-
            };
-
            let mut settings = SyncSettings::default().timeout(timeout);
-

-
            let replicas = match (replicas, max_replicas) {
-
                (None, None) => sync::ReplicationFactor::default(),
-
                (None, Some(min)) => sync::ReplicationFactor::must_reach(min),
-
                (Some(min), None) => sync::ReplicationFactor::must_reach(min),
-
                (Some(min), Some(max)) => sync::ReplicationFactor::range(min, max),
-
            };
-
            settings.replicas = replicas;
-
            if !seeds.is_empty() {
-
                settings.seeds = seeds;
-
            }
-
            SyncMode::Repo {
-
                settings,
-
                direction,
-
            }
-
        };
-

-
        Ok((
-
            Options {
-
                rid,
-
                debug,
-
                verbose,
-
                sort_by,
-
                op: op.unwrap_or(Operation::Synchronize(sync)),
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let mut node = radicle::Node::new(profile.socket());
    if !node.is_running() {
@@ -290,10 +36,12 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            "to sync a repository, your node must be running. To start it, run `rad node start`"
        );
    }
+
    let verbose = args.verbose;
+
    let debug = args.verbose;

-
    match &options.op {
-
        Operation::Status => {
-
            let rid = match options.rid {
+
    match args.command {
+
        Some(Command::Status { rid, sort_by }) => {
+
            let rid = match rid {
                Some(rid) => rid,
                None => {
                    let (_, rid) = radicle::rad::cwd()
@@ -301,37 +49,41 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                    rid
                }
            };
-
            sync_status(rid, &mut node, &profile, &options)?;
+
            sync_status(rid, &mut node, &profile, &sort_by, verbose)?;
        }
-
        Operation::Synchronize(SyncMode::Repo {
-
            settings,
-
            direction,
-
        }) => {
-
            let rid = match options.rid {
-
                Some(rid) => rid,
-
                None => {
-
                    let (_, rid) = radicle::rad::cwd()
-
                        .context("Current directory is not a Radicle repository")?;
-
                    rid
-
                }
-
            };
-
            let settings = settings.clone().with_profile(&profile);
+
        None => match SyncMode::from(args.sync) {
+
            SyncMode::Repo {
+
                rid,
+
                settings,
+
                direction,
+
            } => {
+
                let rid = match rid {
+
                    Some(rid) => rid,
+
                    None => {
+
                        let (_, rid) = radicle::rad::cwd()
+
                            .context("Current directory is not a Radicle repository")?;
+
                        rid
+
                    }
+
                };
+
                let settings = settings.clone().with_profile(&profile);

-
            if [SyncDirection::Fetch, SyncDirection::Both].contains(direction) {
-
                if !profile.policies()?.is_seeding(&rid)? {
-
                    anyhow::bail!("repository {rid} is not seeded");
+
                if matches!(direction, SyncDirection::Fetch | SyncDirection::Both) {
+
                    if !profile.policies()?.is_seeding(&rid)? {
+
                        anyhow::bail!("repository {rid} is not seeded");
+
                    }
+
                    let result = fetch(rid, settings.clone(), &mut node, &profile)?;
+
                    display_fetch_result(&result, verbose)
+
                }
+
                if matches!(direction, SyncDirection::Announce | SyncDirection::Both) {
+
                    announce_refs(rid, settings, &mut node, &profile, verbose, debug)?;
                }
-
                let result = fetch(rid, settings.clone(), &mut node, &profile)?;
-
                display_fetch_result(&result, options.verbose)
            }
-
            if [SyncDirection::Announce, SyncDirection::Both].contains(direction) {
-
                announce_refs(rid, settings, &mut node, &profile, &options)?;
+
            SyncMode::Inventory => {
+
                announce_inventory(node)?;
            }
-
        }
-
        Operation::Synchronize(SyncMode::Inventory) => {
-
            announce_inventory(node)?;
-
        }
+
        },
    }
+

    Ok(())
}

@@ -339,13 +91,14 @@ fn sync_status(
    rid: RepoId,
    node: &mut Node,
    profile: &Profile,
-
    options: &Options,
+
    sort_by: &SortBy,
+
    verbose: bool,
) -> anyhow::Result<()> {
    const SYMBOL_STATE: &str = "?";
    const SYMBOL_STATE_UNKNOWN: &str = "•";

    let mut table = Table::<5, term::Label>::new(TableOptions::bordered());
-
    let mut seeds: Vec<_> = node.seeds(rid)?.into();
+
    let mut seeds: Vec<_> = node.seeds_for(rid, [*profile.did()])?.into();
    let local_nid = node.nid()?;
    let aliases = profile.aliases();

@@ -358,9 +111,9 @@ fn sync_status(
    ]);
    table.divider();

-
    sort_seeds_by(local_nid, &mut seeds, &aliases, &options.sort_by);
+
    sort_seeds_by(local_nid, &mut seeds, &aliases, sort_by);

-
    for seed in seeds {
+
    let seeds = seeds.into_iter().flat_map(|seed| {
        let (status, head, time) = match seed.sync {
            Some(SyncStatus::Synced {
                at: SyncedAt { oid, timestamp },
@@ -386,24 +139,26 @@ fn sync_status(
                term::format::oid(oid),
                term::format::timestamp(timestamp),
            ),
-
            None if options.verbose => (
+
            None if verbose => (
                term::format::dim(SYMBOL_STATE_UNKNOWN),
                term::paint(String::new()),
                term::paint(String::new()),
            ),
-
            None => continue,
+
            None => return None,
        };

-
        let (alias, nid) = Author::new(&seed.nid, profile, options.verbose).labels();
+
        let (alias, nid) = Author::new(&seed.nid, profile, verbose).labels();

-
        table.push([
+
        Some([
            nid,
            alias,
            status.into(),
            term::format::secondary(head).into(),
            time.dim().italic().into(),
-
        ]);
-
    }
+
        ])
+
    });
+

+
    table.extend(seeds);
    table.print();

    if profile.hints() {
@@ -446,7 +201,8 @@ fn announce_refs(
    settings: SyncSettings,
    node: &mut Node,
    profile: &Profile,
-
    options: &Options,
+
    verbose: bool,
+
    debug: bool,
) -> anyhow::Result<()> {
    let Ok(repo) = profile.storage.repository(rid) else {
        return Err(anyhow!(
@@ -468,14 +224,14 @@ fn announce_refs(
        &repo,
        settings,
        SyncReporting {
-
            debug: options.debug,
+
            debug,
            ..SyncReporting::default()
        },
        node,
        profile,
    )?;
    if let Some(result) = result {
-
        print_announcer_result(&result, options.verbose)
+
        print_announcer_result(&result, verbose)
    }

    Ok(())
@@ -520,12 +276,15 @@ pub fn fetch(
        None => {
            // We push nodes that are in our seed list in attempt to fulfill the
            // replicas, if needed.
-
            let seeds = node.seeds(rid)?;
+
            let seeds = node.seeds_for(rid, [*profile.did()])?;
            let (connected, disconnected) = seeds.partition();
            let candidates = connected
                .into_iter()
                .map(|seed| seed.nid)
-
                .chain(disconnected.into_iter().map(|seed| seed.nid))
+
                .chain(disconnected.into_iter().filter_map(|seed| {
+
                    // Only consider seeds that have at least one known address.
+
                    (!seed.addrs.is_empty()).then_some(seed.nid)
+
                }))
                .map(sync::fetch::Candidate::new);
            sync::FetcherConfig::public(settings.seeds.clone(), settings.replicas, *local)
                .with_candidates(candidates)
added crates/radicle-cli/src/commands/sync/args.rs
@@ -0,0 +1,253 @@
+
use std::str::FromStr;
+
use std::time;
+

+
use clap::{Parser, Subcommand, ValueEnum};
+

+
use radicle::{
+
    node::{sync, NodeId},
+
    prelude::RepoId,
+
};
+

+
use crate::node::SyncSettings;
+

+
const ABOUT: &str = "Sync repositories to the network";
+

+
const LONG_ABOUT: &str = r#"
+
By default, the current repository is synchronized both ways.
+
If an <RID> is specified, that repository is synced instead.
+

+
The process begins by fetching changes from connected seeds,
+
followed by announcing local refs to peers, thereby prompting
+
them to fetch from us.
+

+
When `--fetch` is specified, any number of seeds may be given
+
using the `--seed` option, eg. `--seed <NID>@<ADDR>:<PORT>`.
+

+
When `--replicas` is specified, the given replication factor will try
+
to be matched. For example, `--replicas 5` will sync with 5 seeds.
+

+
The synchronization process can be configured using `--replicas <MIN>` and
+
`--replicas-max <MAX>`. If these options are used independently, then the
+
replication factor is taken as the given `<MIN>`/`<MAX>` value. If the
+
options are used together, then the replication factor has a minimum and
+
maximum bound.
+

+
For fetching, the synchronization process will be considered successful if
+
at least `<MIN>` seeds were fetched from *or* all preferred seeds were
+
fetched from. If `<MAX>` is specified then the process will continue and
+
attempt to sync with `<MAX>` seeds.
+

+
For reference announcing, the synchronization process will be considered
+
successful if at least `<MIN>` seeds were pushed to *and* all preferred
+
seeds were pushed to.
+

+
When `--fetch` or `--announce` are specified on their own, this command
+
will only fetch or announce.
+

+
If `--inventory` is specified, the node's inventory is announced to
+
the network. This mode does not take an `<RID>`.
+
"#;
+

+
#[derive(Parser, Debug)]
+
#[clap(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    #[clap(subcommand)]
+
    pub(super) command: Option<Command>,
+

+
    #[clap(flatten)]
+
    pub(super) sync: SyncArgs,
+

+
    /// Enable debug information when synchronizing
+
    #[arg(long)]
+
    pub(super) debug: bool,
+

+
    /// Enable verbose information when synchronizing
+
    #[arg(long, short)]
+
    pub(super) verbose: bool,
+
}
+

+
#[derive(Parser, Debug)]
+
pub(super) struct SyncArgs {
+
    /// Enable fetching [default: true]
+
    ///
+
    /// Providing `--announce` without `--fetch` will disable fetching
+
    #[arg(long, short, conflicts_with = "inventory")]
+
    fetch: bool,
+

+
    /// Enable announcing [default: true]
+
    ///
+
    /// Providing `--fetch` without `--announce` will disable announcing
+
    #[arg(long, short, conflicts_with = "inventory")]
+
    announce: bool,
+

+
    /// Synchronize with the given node (may be specified multiple times)
+
    #[arg(
+
        long = "seed",
+
        value_name = "NID",
+
        action = clap::ArgAction::Append,
+
        conflicts_with = "inventory",
+
    )]
+
    seeds: Vec<NodeId>,
+

+
    /// How many seconds to wait while synchronizing
+
    #[arg(
+
        long,
+
        short,
+
        default_value_t = 9,
+
        value_name = "SECS",
+
        conflicts_with = "inventory"
+
    )]
+
    timeout: u64,
+

+
    /// The repository to perform the synchronizing for [default: cwd]
+
    rid: Option<RepoId>,
+

+
    /// Synchronize with a specific number of seeds
+
    ///
+
    /// The value must be greater than zero
+
    #[arg(
+
        long,
+
        short,
+
        value_name = "COUNT",
+
        value_parser = replicas_non_zero,
+
        conflicts_with = "inventory",
+
        default_value_t = radicle::node::sync::DEFAULT_REPLICATION_FACTOR,
+
    )]
+
    replicas: usize,
+

+
    /// Synchronize with an upper bound number of seeds
+
    ///
+
    /// The value must be greater than zero
+
    #[arg(
+
        long,
+
        value_name = "COUNT",
+
        value_parser = replicas_non_zero,
+
        conflicts_with = "inventory",
+
    )]
+
    max_replicas: Option<usize>,
+

+
    /// Enable announcing inventory [default: false]
+
    ///
+
    /// `--inventory` is a standalone mode and is not compatible with the other
+
    /// options
+
    ///
+
    /// <RID> is ignored with `--inventory`
+
    #[arg(long, short)]
+
    inventory: bool,
+
}
+

+
impl SyncArgs {
+
    fn direction(&self) -> SyncDirection {
+
        match (self.fetch, self.announce) {
+
            (true, true) | (false, false) => SyncDirection::Both,
+
            (true, false) => SyncDirection::Fetch,
+
            (false, true) => SyncDirection::Announce,
+
        }
+
    }
+

+
    fn timeout(&self) -> time::Duration {
+
        time::Duration::from_secs(self.timeout)
+
    }
+

+
    fn replication(&self) -> sync::ReplicationFactor {
+
        match (self.replicas, self.max_replicas) {
+
            (min, None) => sync::ReplicationFactor::must_reach(min),
+
            (min, Some(max)) => sync::ReplicationFactor::range(min, max),
+
        }
+
    }
+
}
+

+
#[derive(Subcommand, Debug)]
+
pub(super) enum Command {
+
    /// Display the sync status of a repository
+
    #[clap(alias = "s")]
+
    Status {
+
        /// The repository to display the status for [default: cwd]
+
        rid: Option<RepoId>,
+
        /// Sort the table by column
+
        #[arg(long, value_name = "FIELD", value_enum, default_value_t)]
+
        sort_by: SortBy,
+
    },
+
}
+

+
/// Sort the status table by the provided field
+
#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
+
pub(super) enum SortBy {
+
    /// The NID of the entry
+
    Nid,
+
    /// The alias of the entry
+
    Alias,
+
    /// The status of the entry
+
    #[default]
+
    Status,
+
}
+

+
impl FromStr for SortBy {
+
    type Err = &'static str;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        match s {
+
            "nid" => Ok(Self::Nid),
+
            "alias" => Ok(Self::Alias),
+
            "status" => Ok(Self::Status),
+
            _ => Err("invalid `--sort-by` field"),
+
        }
+
    }
+
}
+

+
/// Whether we are performing a fetch/announce of a repository or only
+
/// announcing the node's inventory
+
pub(super) enum SyncMode {
+
    /// Fetch and/or announce a repositories references
+
    Repo {
+
        /// The repository being synchronized
+
        rid: Option<RepoId>,
+
        /// The settings for fetch/announce
+
        settings: SyncSettings,
+
        /// The direction of the synchronization
+
        direction: SyncDirection,
+
    },
+
    /// Announce the node's inventory
+
    Inventory,
+
}
+

+
impl From<SyncArgs> for SyncMode {
+
    fn from(args: SyncArgs) -> Self {
+
        if args.inventory {
+
            Self::Inventory
+
        } else {
+
            assert!(!args.inventory);
+
            let direction = args.direction();
+
            let mut settings = SyncSettings::default()
+
                .timeout(args.timeout())
+
                .replicas(args.replication());
+
            if !args.seeds.is_empty() {
+
                settings.seeds = args.seeds.into_iter().collect();
+
            }
+
            Self::Repo {
+
                rid: args.rid,
+
                settings,
+
                direction,
+
            }
+
        }
+
    }
+
}
+

+
/// The direction of the [`SyncMode`]
+
#[derive(Debug, PartialEq, Eq)]
+
pub(super) enum SyncDirection {
+
    /// Only fetching
+
    Fetch,
+
    /// Only announcing
+
    Announce,
+
    /// Both fetching and announcing
+
    Both,
+
}
+

+
fn replicas_non_zero(s: &str) -> Result<usize, String> {
+
    let r = usize::from_str(s).map_err(|_| format!("{s} is not a number"))?;
+
    if r == 0 {
+
        return Err(format!("{s} must be a value greater than zero"));
+
    }
+
    Ok(r)
+
}
modified crates/radicle-cli/src/commands/unblock.rs
@@ -1,98 +1,24 @@
-
use std::ffi::OsString;
-

-
use radicle::prelude::{NodeId, RepoId};
+
mod args;

use crate::terminal as term;
-
use crate::terminal::args;
-
use crate::terminal::args::{Args, Error, Help};
-

-
pub const HELP: Help = Help {
-
    name: "unblock",
-
    description: "Unblock repositories or nodes to allow them to be seeded or followed",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad unblock <rid> [<option>...]
-
    rad unblock <nid> [<option>...]
-

-
    Unblock a repository or remote to allow it to be seeded or followed.
-

-
Options

-
    --help          Print help
-
"#,
-
};
+
use term::args::BlockTarget;

-
enum Target {
-
    Node(NodeId),
-
    Repo(RepoId),
-
}
-

-
impl std::fmt::Display for Target {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        match self {
-
            Self::Node(nid) => nid.fmt(f),
-
            Self::Repo(rid) => rid.fmt(f),
-
        }
-
    }
-
}
-

-
pub struct Options {
-
    target: Target,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut target = None;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Value(val) if target.is_none() => {
-
                    if let Ok(rid) = args::rid(&val) {
-
                        target = Some(Target::Repo(rid));
-
                    } else if let Ok(nid) = args::nid(&val) {
-
                        target = Some(Target::Node(nid));
-
                    } else {
-
                        anyhow::bail!(
-
                            "invalid repository or remote specified, see `rad unblock --help`"
-
                        )
-
                    }
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                target: target.ok_or(anyhow::anyhow!(
-
                    "a repository or remote to unblock must be specified, see `rad unblock --help`"
-
                ))?,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
+
pub use args::Args;

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let mut policies = profile.policies_mut()?;

-
    let updated = match options.target {
-
        Target::Node(nid) => policies.unblock_nid(&nid)?,
-
        Target::Repo(rid) => policies.unblock_rid(&rid)?,
+
    let updated = match args.target {
+
        BlockTarget::Node(nid) => policies.unblock_nid(&nid)?,
+
        BlockTarget::Repo(rid) => policies.unblock_rid(&rid)?,
    };

    if updated {
-
        term::success!("The 'block' policy for {} is removed", options.target);
+
        term::success!("The 'block' policy for {} is removed", args.target);
    } else {
-
        term::info!("No 'block' policy exists for {}", options.target)
+
        term::info!("No 'block' policy exists for {}", args.target)
    }
    Ok(())
}
added crates/radicle-cli/src/commands/unblock/args.rs
@@ -0,0 +1,15 @@
+
use clap::Parser;
+

+
use crate::terminal::args::BlockTarget;
+

+
const ABOUT: &str = "Unblock repositories or nodes to allow them to be seeded or followed";
+

+
#[derive(Parser, Debug)]
+
#[command(about = ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    /// A Repository ID or Node ID to allow to be seeded or followed
+
    ///
+
    /// [example values: rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH, z6MkiswaKJ85vafhffCGBu2gdBsYoDAyHVBWRxL3j297fwS9]
+
    #[arg(value_name = "RID|NID")]
+
    pub(super) target: BlockTarget,
+
}
modified crates/radicle-cli/src/commands/unfollow.rs
@@ -1,80 +1,15 @@
-
use std::ffi::OsString;
+
mod args;

-
use anyhow::anyhow;
-

-
use radicle::node::{Handle, NodeId};
+
use radicle::node::Handle;

use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
-

-
pub const HELP: Help = Help {
-
    name: "unfollow",
-
    description: "Unfollow a peer",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad unfollow <nid> [<option>...]
-

-
    The `unfollow` command takes a Node ID (<nid>), optionally in DID format,
-
    and removes the follow policy for that peer.
-

-
Options
-

-
    --verbose, -v          Verbose output
-
    --help                 Print help
-
"#,
-
};
-

-
#[derive(Debug)]
-
pub struct Options {
-
    pub nid: NodeId,
-
    pub verbose: bool,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut nid: Option<NodeId> = None;
-
        let mut verbose = false;
-

-
        while let Some(arg) = parser.next()? {
-
            match &arg {
-
                Value(val) if nid.is_none() => {
-
                    if let Ok(did) = term::args::did(val) {
-
                        nid = Some(did.into());
-
                    } else if let Ok(val) = term::args::nid(val) {
-
                        nid = Some(val);
-
                    } else {
-
                        anyhow::bail!("invalid Node ID `{}` specified", val.to_string_lossy());
-
                    }
-
                }
-
                Long("verbose") | Short('v') => verbose = true,
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                _ => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                nid: nid.ok_or_else(|| anyhow!("a Node ID must be specified"))?,
-
                verbose,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
+
pub use args::Args;

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let mut node = radicle::Node::new(profile.socket());
-
    let nid = options.nid;
+
    let nid = args.nid;

    let unfollowed = match node.unfollow(nid) {
        Ok(updated) => updated,
added crates/radicle-cli/src/commands/unfollow/args.rs
@@ -0,0 +1,23 @@
+
use clap::Parser;
+

+
use radicle::node::NodeId;
+

+
use crate::terminal as term;
+

+
const ABOUT: &str = "Unfollow a peer";
+

+
const LONG_ABOUT: &str = r#"
+
The `unfollow` command takes a Node ID, optionally in DID format,
+
and removes the follow policy for that peer."#;
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    /// Node ID (optionally in DID format) of the peer to unfollow
+
    #[arg(value_name = "NID", value_parser = term::args::parse_nid)]
+
    pub(super) nid: NodeId,
+

+
    /// Verbose output
+
    #[arg(short, long)]
+
    pub(super) verbose: bool,
+
}
modified crates/radicle-cli/src/commands/unseed.rs
@@ -1,74 +1,16 @@
-
use std::ffi::OsString;
-

-
use anyhow::anyhow;
-
use nonempty::NonEmpty;
+
pub mod args;

use radicle::{prelude::*, Node};

use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
-

-
pub const HELP: Help = Help {
-
    name: "unseed",
-
    description: "Remove repository seeding policies",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad unseed <rid>... [<option>...]
-

-
    The `unseed` command removes the seeding policy, if found,
-
    for the given repositories.

-
Options
-

-
    --help      Print help
-
"#,
-
};
-

-
#[derive(Debug)]
-
pub struct Options {
-
    rids: NonEmpty<RepoId>,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut rids: Vec<RepoId> = Vec::new();
-

-
        while let Some(arg) = parser.next()? {
-
            match &arg {
-
                Value(val) => {
-
                    let rid = term::args::rid(val)?;
-
                    rids.push(rid);
-
                }
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                _ => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                rids: NonEmpty::from_vec(rids).ok_or(anyhow!(
-
                    "At least one Repository ID must be provided; see `rad unseed --help`"
-
                ))?,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
+
pub use args::Args;

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let mut node = radicle::Node::new(profile.socket());

-
    for rid in options.rids {
+
    for rid in args.rids {
        delete(rid, &mut node, &profile)?;
    }

added crates/radicle-cli/src/commands/unseed/args.rs
@@ -0,0 +1,16 @@
+
use clap::Parser;
+
use radicle::prelude::RepoId;
+

+
const ABOUT: &str = "Remove repository seeding policies";
+

+
const LONG_ABOUT: &str = r#"
+
The `unseed` command removes the seeding policy, if found,
+
for the given repositories."#;
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    /// ID of the repository to remove the seeding policy for (may be repeated)
+
    #[arg(value_name = "RID", required = true, action = clap::ArgAction::Append)]
+
    pub rids: Vec<RepoId>,
+
}
modified crates/radicle-cli/src/commands/watch.rs
@@ -1,132 +1,27 @@
-
use std::ffi::OsString;
+
mod args;
+

use std::{thread, time};

use anyhow::{anyhow, Context as _};

use radicle::git;
-
use radicle::prelude::{NodeId, RepoId};
+
use radicle::git::raw::ErrorExt as _;
+
use radicle::prelude::NodeId;
use radicle::storage::{ReadRepository, ReadStorage};

use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
-

-
pub const HELP: Help = Help {
-
    name: "wait",
-
    description: "Wait for some state to be updated",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad watch -r <ref> [-t <oid>] [--repo <rid>] [<option>...]
-

-
    Watches a Git reference, and optionally exits when it reaches a target value.
-
    If no target value is passed, exits when the target changes.
-

-
Options
-

-
        --repo      <rid>       The repository to watch (default: `rad .`)
-
        --node      <nid>       The namespace under which this reference exists
-
                                (default: `rad self --nid`)
-
    -r, --ref       <ref>       The fully-qualified Git reference (branch, tag, etc.) to watch,
-
                                eg. 'refs/heads/master'
-
    -t, --target    <oid>       The target OID (commit hash) that when reached,
-
                                will cause the command to exit
-
    -i, --interval  <millis>    How often, in milliseconds, to check the reference target
-
                                (default: 1000)
-
        --timeout   <millis>    Timeout, in milliseconds (default: none)
-
    -h, --help                  Print help
-
"#,
-
};
-

-
pub struct Options {
-
    rid: Option<RepoId>,
-
    refstr: git::RefString,
-
    target: Option<git::Oid>,
-
    nid: Option<NodeId>,
-
    interval: time::Duration,
-
    timeout: time::Duration,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut rid = None;
-
        let mut nid: Option<NodeId> = None;
-
        let mut target: Option<git::Oid> = None;
-
        let mut refstr: Option<git::RefString> = None;
-
        let mut interval: Option<time::Duration> = None;
-
        let mut timeout: time::Duration = time::Duration::MAX;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("repo") => {
-
                    let value = parser.value()?;
-
                    let value = term::args::rid(&value)?;

-
                    rid = Some(value);
-
                }
-
                Long("node") => {
-
                    let value = parser.value()?;
-
                    let value = term::args::nid(&value)?;
-

-
                    nid = Some(value);
-
                }
-
                Long("ref") | Short('r') => {
-
                    let value = parser.value()?;
-
                    let value = term::args::refstring("ref", value)?;
-

-
                    refstr = Some(value);
-
                }
-
                Long("target") | Short('t') => {
-
                    let value = parser.value()?;
-
                    let value = term::args::oid(&value)?;
-

-
                    target = Some(value);
-
                }
-
                Long("interval") | Short('i') => {
-
                    let value = parser.value()?;
-
                    let value = term::args::milliseconds(&value)?;
-

-
                    interval = Some(value);
-
                }
-
                Long("timeout") => {
-
                    let value = parser.value()?;
-
                    let value = term::args::milliseconds(&value)?;
-

-
                    timeout = value;
-
                }
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                rid,
-
                refstr: refstr.ok_or_else(|| anyhow!("a reference must be provided"))?,
-
                nid,
-
                target,
-
                interval: interval.unwrap_or(time::Duration::from_secs(1)),
-
                timeout,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
+
pub use args::Args;

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let storage = &profile.storage;
-
    let qualified = options
+
    let qualified = args
        .refstr
        .qualified()
        .ok_or_else(|| anyhow!("reference must be fully-qualified, eg. 'refs/heads/master'"))?;
-
    let nid = options.nid.unwrap_or(profile.public_key);
-
    let rid = match options.rid {
+
    let nid = args.node.unwrap_or(profile.public_key);
+
    let rid = match args.repo {
        Some(rid) => rid,
        None => {
            let (_, rid) =
@@ -136,26 +31,28 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    };
    let repo = storage.repository(rid)?;
    let now = time::SystemTime::now();
+
    let timeout = args.timeout();
+
    let interval = args.interval();

-
    if let Some(target) = options.target {
+
    if let Some(target) = args.target {
        while reference(&repo, &nid, &qualified)? != Some(target) {
-
            thread::sleep(options.interval);
-
            if now.elapsed()? >= options.timeout {
-
                anyhow::bail!("timed out after {}ms", options.timeout.as_millis());
+
            thread::sleep(interval);
+
            if now.elapsed()? >= timeout {
+
                anyhow::bail!("timed out after {}ms", timeout.as_millis());
            }
        }
    } else {
        let initial = reference(&repo, &nid, &qualified)?;

        loop {
-
            thread::sleep(options.interval);
+
            thread::sleep(interval);
            let oid = reference(&repo, &nid, &qualified)?;
            if oid != initial {
                term::info!("{}", oid.unwrap_or(git::raw::Oid::zero().into()));
                break;
            }
-
            if now.elapsed()? >= options.timeout {
-
                anyhow::bail!("timed out after {}ms", options.timeout.as_millis());
+
            if now.elapsed()? >= timeout {
+
                anyhow::bail!("timed out after {}ms", timeout.as_millis());
            }
        }
    }
@@ -165,11 +62,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
fn reference<R: ReadRepository>(
    repo: &R,
    nid: &NodeId,
-
    qual: &git::Qualified,
+
    qual: &git::fmt::Qualified,
) -> Result<Option<git::Oid>, git::raw::Error> {
    match repo.reference_oid(nid, qual) {
        Ok(oid) => Ok(Some(oid)),
-
        Err(e) if git::ext::is_not_found_err(&e) => Ok(None),
+
        Err(e) if e.is_not_found() => Ok(None),
        Err(e) => Err(e),
    }
}
added crates/radicle-cli/src/commands/watch/args.rs
@@ -0,0 +1,72 @@
+
use std::time;
+

+
#[allow(rustdoc::broken_intra_doc_links)]
+
use clap::Parser;
+

+
use radicle::git;
+
use radicle::git::fmt::RefString;
+
use radicle::prelude::{NodeId, RepoId};
+

+
const ABOUT: &str = "Wait for some state to be updated";
+

+
const LONG_ABOUT: &str = r#"
+
Watches a Git reference, and optionally exits when it reaches a target value.
+
If no target value is passed, exits when the target changes."#;
+

+
fn parse_refstr(refstr: &str) -> Result<RefString, git::fmt::Error> {
+
    RefString::try_from(refstr)
+
}
+

+
#[derive(Parser, Debug)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT,disable_version_flag = true)]
+
pub struct Args {
+
    /// The repository to watch, defaults to `rad .`
+
    #[arg(long)]
+
    pub(super) repo: Option<RepoId>,
+

+
    /// The fully-qualified Git reference (branch, tag, etc.) to watch
+
    ///
+
    /// [example value: 'refs/heads/master']
+
    #[arg(long, short, alias = "ref", value_name = "REF", value_parser = parse_refstr)]
+
    pub(super) refstr: git::fmt::RefString,
+

+
    /// The target OID (commit hash) that when reached, will cause the command to exit
+
    #[arg(long, short, value_name = "OID")]
+
    pub(super) target: Option<git::Oid>,
+

+
    /// The namespace under which this reference exists, defaults to the profiles' NID
+
    #[arg(long, short, value_name = "NID")]
+
    pub(super) node: Option<NodeId>,
+

+
    /// How often, in milliseconds, to check the reference target
+
    #[arg(long, short, value_name = "MILLIS", default_value_t = 1000)]
+
    interval: u64,
+

+
    /// Timeout, in milliseconds
+
    #[arg(long, value_name = "MILLIS")]
+
    timeout: Option<u64>,
+
}
+

+
impl Args {
+
    /// Provide the interval duration in milliseconds.
+
    pub(super) fn interval(&self) -> time::Duration {
+
        time::Duration::from_millis(self.interval)
+
    }
+

+
    /// Provide the timeout duration in milliseconds.
+
    pub(super) fn timeout(&self) -> time::Duration {
+
        time::Duration::from_millis(self.timeout.unwrap_or(u64::MAX))
+
    }
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use super::Args;
+
    use clap::Parser;
+

+
    #[test]
+
    fn should_parse_ref_str() {
+
        let args = Args::try_parse_from(["watch", "--ref", "refs/heads/master"]);
+
        assert!(args.is_ok())
+
    }
+
}
modified crates/radicle-cli/src/git.rs
@@ -20,14 +20,15 @@ use thiserror::Error;

use radicle::crypto::ssh;
use radicle::git;
-
use radicle::git::raw as git2;
use radicle::git::{Version, VERSION_REQUIRED};
use radicle::prelude::{NodeId, RepoId};
use radicle::storage::git::transport;

+
pub use radicle::git::Oid;
+

pub use radicle::git::raw::{
-
    build::CheckoutBuilder, AnnotatedCommit, Commit, Direction, ErrorCode, MergeAnalysis,
-
    MergeOptions, Oid, Reference, Repository, Signature,
+
    build::CheckoutBuilder, AnnotatedCommit, Commit, Direction, ErrorCode, ErrorExt as _,
+
    MergeAnalysis, MergeOptions, Reference, Repository, Signature,
};

pub const CONFIG_COMMIT_GPG_SIGN: &str = "commit.gpgsign";
@@ -46,10 +47,10 @@ impl Rev {
        &self.0
    }

-
    /// Resolve the revision to an [`From<git2::Oid>`].
-
    pub fn resolve<T>(&self, repo: &git2::Repository) -> Result<T, git2::Error>
+
    /// Resolve the revision to an [`From<git::raw::Oid>`].
+
    pub fn resolve<T>(&self, repo: &Repository) -> Result<T, git::raw::Error>
    where
-
        T: From<git2::Oid>,
+
        T: From<git::raw::Oid>,
    {
        let object = repo.revparse_single(self.as_str())?;
        Ok(object.id().into())
@@ -84,13 +85,13 @@ pub struct Remote<'a> {
    pub url: radicle::git::Url,
    pub pushurl: Option<radicle::git::Url>,

-
    inner: git2::Remote<'a>,
+
    inner: git::raw::Remote<'a>,
}

-
impl<'a> TryFrom<git2::Remote<'a>> for Remote<'a> {
+
impl<'a> TryFrom<git::raw::Remote<'a>> for Remote<'a> {
    type Error = RemoteError;

-
    fn try_from(value: git2::Remote<'a>) -> Result<Self, Self::Error> {
+
    fn try_from(value: git::raw::Remote<'a>) -> Result<Self, Self::Error> {
        let url = value.url().map_or(Err(RemoteError::MissingUrl), |url| {
            Ok(radicle::git::Url::from_str(url)?)
        })?;
@@ -110,7 +111,7 @@ impl<'a> TryFrom<git2::Remote<'a>> for Remote<'a> {
}

impl<'a> Deref for Remote<'a> {
-
    type Target = git2::Remote<'a>;
+
    type Target = git::raw::Remote<'a>;

    fn deref(&self) -> &Self::Target {
        &self.inner
@@ -132,11 +133,23 @@ pub fn repository() -> Result<Repository, anyhow::Error> {
}

/// Execute a git command by spawning a child process.
+
/// Returns [`Result::Ok`] if the command *exited successfully*.
pub fn git<S: AsRef<std::ffi::OsStr>>(
    repo: &std::path::Path,
    args: impl IntoIterator<Item = S>,
-
) -> Result<String, io::Error> {
-
    radicle::git::run::<_, _, &str, &str>(repo, args, [])
+
) -> anyhow::Result<std::process::Output> {
+
    let output = radicle::git::run(Some(repo), args)?;
+

+
    if !output.status.success() {
+
        anyhow::bail!(
+
            "`git` exited with status {}, stderr and stdout follow:\n{}\n{}\n",
+
            output.status,
+
            String::from_utf8_lossy(&output.stderr),
+
            String::from_utf8_lossy(&output.stdout),
+
        )
+
    }
+

+
    Ok(output)
}

/// Configure SSH signing in the given git repo, for the given peer.
@@ -238,7 +251,7 @@ pub fn is_signing_configured(repo: &Path) -> Result<bool, anyhow::Error> {
}

/// Return the list of radicle remotes for the given repository.
-
pub fn rad_remotes(repo: &git2::Repository) -> anyhow::Result<Vec<Remote>> {
+
pub fn rad_remotes(repo: &Repository) -> anyhow::Result<Vec<Remote<'_>>> {
    let remotes: Vec<_> = repo
        .remotes()?
        .iter()
@@ -251,16 +264,16 @@ pub fn rad_remotes(repo: &git2::Repository) -> anyhow::Result<Vec<Remote>> {
}

/// Check if the git remote is configured for the `Repository`.
-
pub fn is_remote(repo: &git2::Repository, alias: &str) -> anyhow::Result<bool> {
+
pub fn is_remote(repo: &Repository, alias: &str) -> anyhow::Result<bool> {
    match repo.find_remote(alias) {
        Ok(_) => Ok(true),
-
        Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(false),
+
        Err(err) if err.is_not_found() => Ok(false),
        Err(err) => Err(err.into()),
    }
}

/// Get the repository's "rad" remote.
-
pub fn rad_remote(repo: &Repository) -> anyhow::Result<(git2::Remote, RepoId)> {
+
pub fn rad_remote(repo: &Repository) -> anyhow::Result<(git::raw::Remote<'_>, RepoId)> {
    match radicle::rad::remote(repo) {
        Ok((remote, id)) => Ok((remote, id)),
        Err(radicle::rad::RemoteError::NotFound(_)) => Err(anyhow!(
@@ -339,35 +352,13 @@ pub fn parse_remote(refspec: &str) -> Option<(NodeId, &str)> {
        .and_then(|(peer, r)| NodeId::from_str(peer).ok().map(|p| (p, r)))
}

-
pub fn view_diff(
-
    repo: &git2::Repository,
-
    left: &git2::Oid,
-
    right: &git2::Oid,
-
) -> anyhow::Result<()> {
-
    // TODO(erikli): Replace with repo.diff()
-
    let workdir = repo
-
        .workdir()
-
        .ok_or_else(|| anyhow!("Could not get workdir current repository."))?;
-

-
    let left = format!("{:.7}", left.to_string());
-
    let right = format!("{:.7}", right.to_string());
-

-
    let mut git = Command::new("git")
-
        .current_dir(workdir)
-
        .args(["diff", &left, &right])
-
        .spawn()?;
-
    git.wait()?;
-

-
    Ok(())
-
}
-

pub fn add_tag(
-
    repo: &git2::Repository,
+
    repo: &Repository,
    message: &str,
    patch_tag_name: &str,
-
) -> anyhow::Result<git2::Oid> {
+
) -> anyhow::Result<git::raw::Oid> {
    let head = repo.head()?;
-
    let commit = head.peel(git2::ObjectType::Commit).unwrap();
+
    let commit = head.peel(git::raw::ObjectType::Commit).unwrap();
    let oid = repo.tag(patch_tag_name, &commit, &repo.signature()?, message, false)?;

    Ok(oid)
modified crates/radicle-cli/src/git/ddiff.rs
@@ -41,7 +41,7 @@
//! +snuffing
//! omitting
//! ```
-
//! The `DDiff` will show the what changes are being made, overlayed on to the original diff and
+
//! The `DDiff` will show the what changes are being made, overlaid on to the original diff and
//! the diff's original file as context.
//!
//! ```text
modified crates/radicle-cli/src/git/pretty_diff.rs
@@ -2,7 +2,7 @@ use std::fs;
use std::path::{Path, PathBuf};

use radicle::git;
-
use radicle_git_ext::Oid;
+
use radicle::git::Oid;
use radicle_surf::diff;
use radicle_surf::diff::{Added, Copied, Deleted, FileStats, Hunks, Modified, Moved};
use radicle_surf::diff::{Diff, DiffContent, FileDiff, Hunk, Modification};
@@ -33,7 +33,7 @@ pub trait Repo {

impl Repo for git::raw::Repository {
    fn blob(&self, oid: git::Oid) -> Result<Blob, git::raw::Error> {
-
        let blob = self.find_blob(*oid)?;
+
        let blob = self.find_blob(oid.into())?;

        if blob.is_binary() {
            Ok(Blob::Binary)
@@ -338,7 +338,7 @@ impl ToPretty for Added {
        repo: &R,
    ) -> Self::Output {
        let old = None;
-
        let new = Some((self.path.as_path(), self.new.oid));
+
        let new = Some((self.path.as_path(), Oid::from(*self.new.oid)));

        pretty_modification(header, &self.diff, old, new, repo, hi)
    }
@@ -354,7 +354,7 @@ impl ToPretty for Deleted {
        header: &Self::Context,
        repo: &R,
    ) -> Self::Output {
-
        let old = Some((self.path.as_path(), self.old.oid));
+
        let old = Some((self.path.as_path(), Oid::from(*self.old.oid)));
        let new = None;

        pretty_modification(header, &self.diff, old, new, repo, hi)
@@ -371,8 +371,8 @@ impl ToPretty for Modified {
        header: &Self::Context,
        repo: &R,
    ) -> Self::Output {
-
        let old = Some((self.path.as_path(), self.old.oid));
-
        let new = Some((self.path.as_path(), self.new.oid));
+
        let old = Some((self.path.as_path(), Oid::from(*self.old.oid)));
+
        let new = Some((self.path.as_path(), Oid::from(*self.new.oid)));

        pretty_modification(header, &self.diff, old, new, repo, hi)
    }
@@ -444,78 +444,86 @@ impl ToPretty for Hunk<Modification> {
        if let Ok(header) = HunkHeader::from_bytes(self.header.as_bytes()) {
            vstack.push(header.pretty(hi, &(), repo));
        }
-
        for line in &self.lines {
-
            match line {
-
                Modification::Addition(a) => {
-
                    table.push([
-
                        term::Label::space()
-
                            .pad(5)
-
                            .bg(theme.color("positive"))
-
                            .to_line()
-
                            .filled(theme.color("positive")),
-
                        term::label(a.line_no.to_string())
-
                            .pad(5)
-
                            .fg(theme.color("positive.light"))
-
                            .to_line()
-
                            .filled(theme.color("positive")),
-
                        term::label(" + ")
-
                            .fg(theme.color("positive.light"))
-
                            .to_line()
-
                            .filled(theme.color("positive.dark")),
-
                        line.pretty(hi, blobs, repo)
-
                            .filled(theme.color("positive.dark")),
-
                        term::Line::blank().filled(term::Color::default()),
-
                    ]);
-
                }
-
                Modification::Deletion(a) => {
-
                    table.push([
-
                        term::label(a.line_no.to_string())
-
                            .pad(5)
-
                            .fg(theme.color("negative.light"))
-
                            .to_line()
-
                            .filled(theme.color("negative")),
-
                        term::Label::space()
-
                            .pad(5)
-
                            .fg(theme.color("dim"))
-
                            .to_line()
-
                            .filled(theme.color("negative")),
-
                        term::label(" - ")
-
                            .fg(theme.color("negative.light"))
-
                            .to_line()
-
                            .filled(theme.color("negative.dark")),
-
                        line.pretty(hi, blobs, repo)
-
                            .filled(theme.color("negative.dark")),
-
                        term::Line::blank().filled(term::Color::default()),
-
                    ]);
-
                }
-
                Modification::Context {
-
                    line_no_old,
-
                    line_no_new,
-
                    ..
-
                } => {
-
                    table.push([
-
                        term::label(line_no_old.to_string())
-
                            .pad(5)
-
                            .fg(theme.color("dim"))
-
                            .to_line()
-
                            .filled(theme.color("faint")),
-
                        term::label(line_no_new.to_string())
-
                            .pad(5)
-
                            .fg(theme.color("dim"))
-
                            .to_line()
-
                            .filled(theme.color("faint")),
-
                        term::label("   ").to_line().filled(term::Color::default()),
-
                        line.pretty(hi, blobs, repo).filled(term::Color::default()),
-
                        term::Line::blank().filled(term::Color::default()),
-
                    ]);
-
                }
-
            }
-
        }
+

+
        table.extend(
+
            self.lines
+
                .iter()
+
                .map(|line| line_to_table_row(hi, blobs, repo, &theme, line)),
+
        );
+

        vstack.push(table);
        vstack
    }
}

+
fn line_to_table_row<R: Repo>(
+
    hi: &mut Highlighter,
+
    blobs: &Blobs<Vec<radicle_term::Line>>,
+
    repo: &R,
+
    theme: &Theme,
+
    line: &Modification,
+
) -> [radicle_term::Filled<radicle_term::Line>; 5] {
+
    match line {
+
        Modification::Addition(a) => [
+
            term::Label::space()
+
                .pad(5)
+
                .bg(theme.color("positive"))
+
                .to_line()
+
                .filled(theme.color("positive")),
+
            term::label(a.line_no.to_string())
+
                .pad(5)
+
                .fg(theme.color("positive.light"))
+
                .to_line()
+
                .filled(theme.color("positive")),
+
            term::label(" + ")
+
                .fg(theme.color("positive.light"))
+
                .to_line()
+
                .filled(theme.color("positive.dark")),
+
            line.pretty(hi, blobs, repo)
+
                .filled(theme.color("positive.dark")),
+
            term::Line::blank().filled(term::Color::default()),
+
        ],
+
        Modification::Deletion(a) => [
+
            term::label(a.line_no.to_string())
+
                .pad(5)
+
                .fg(theme.color("negative.light"))
+
                .to_line()
+
                .filled(theme.color("negative")),
+
            term::Label::space()
+
                .pad(5)
+
                .fg(theme.color("dim"))
+
                .to_line()
+
                .filled(theme.color("negative")),
+
            term::label(" - ")
+
                .fg(theme.color("negative.light"))
+
                .to_line()
+
                .filled(theme.color("negative.dark")),
+
            line.pretty(hi, blobs, repo)
+
                .filled(theme.color("negative.dark")),
+
            term::Line::blank().filled(term::Color::default()),
+
        ],
+
        Modification::Context {
+
            line_no_old,
+
            line_no_new,
+
            ..
+
        } => [
+
            term::label(line_no_old.to_string())
+
                .pad(5)
+
                .fg(theme.color("dim"))
+
                .to_line()
+
                .filled(theme.color("faint")),
+
            term::label(line_no_new.to_string())
+
                .pad(5)
+
                .fg(theme.color("dim"))
+
                .to_line()
+
                .filled(theme.color("faint")),
+
            term::label("   ").to_line().filled(term::Color::default()),
+
            line.pretty(hi, blobs, repo).filled(term::Color::default()),
+
            term::Line::blank().filled(term::Color::default()),
+
        ],
+
    }
+
}
+

impl ToPretty for Modification {
    type Output = term::Line;
    type Context = Blobs<Vec<term::Line>>;
@@ -587,8 +595,8 @@ mod test {
    use term::Element;

    use super::*;
-
    use radicle::git::raw::RepositoryOpenFlags;
-
    use radicle::git::raw::{Oid, Repository};
+
    use git::raw::RepositoryOpenFlags;
+
    use git::raw::{Oid, Repository};

    #[test]
    #[ignore]
modified crates/radicle-cli/src/git/unified_diff.rs
@@ -7,7 +7,6 @@ use radicle_surf::diff::FileStats;
use thiserror::Error;

use radicle::git;
-
use radicle::git::raw::Oid;
use radicle_surf::diff;
use radicle_surf::diff::{Diff, DiffContent, DiffFile, FileDiff, Hunk, Hunks, Line, Modification};

@@ -307,8 +306,8 @@ impl Encode for FileHeader {
                if old.mode == new.mode {
                    w.meta(format!(
                        "index {}..{} {:o}",
-
                        term::format::oid(old.oid),
-
                        term::format::oid(new.oid),
+
                        term::format::oid(*old.oid),
+
                        term::format::oid(*new.oid),
                        u32::from(old.mode.clone()),
                    ))?;
                } else {
@@ -316,8 +315,8 @@ impl Encode for FileHeader {
                    w.meta(format!("new mode {:o}", u32::from(new.mode.clone())))?;
                    w.meta(format!(
                        "index {}..{}",
-
                        term::format::oid(old.oid),
-
                        term::format::oid(new.oid)
+
                        term::format::oid(*old.oid),
+
                        term::format::oid(*new.oid)
                    ))?;
                }

@@ -334,8 +333,8 @@ impl Encode for FileHeader {
                w.meta(format!("new file mode {:o}", u32::from(new.mode.clone())))?;
                w.meta(format!(
                    "index {}..{}",
-
                    term::format::oid(Oid::zero()),
-
                    term::format::oid(new.oid),
+
                    term::format::oid(git::Oid::sha1_zero()),
+
                    term::format::oid(*new.oid),
                ))?;

                w.meta("--- /dev/null")?;
@@ -355,8 +354,8 @@ impl Encode for FileHeader {
                ))?;
                w.meta(format!(
                    "index {}..{}",
-
                    term::format::oid(old.oid),
-
                    term::format::oid(Oid::zero())
+
                    term::format::oid(*old.oid),
+
                    term::format::oid(git::Oid::sha1_zero())
                ))?;

                w.meta(format!("--- a/{}", path.display()))?;
@@ -586,10 +585,20 @@ impl<'a> Writer<'a> {
    }

    pub fn write(&mut self, s: impl fmt::Display, style: term::Style) -> io::Result<()> {
+
        #[cfg(windows)]
+
        const EOL: &str = "\r\n";
+

+
        #[cfg(not(windows))]
+
        const EOL: &str = "\n";
+

        if self.styled {
-
            writeln!(self.stream, "{}", term::Paint::new(s).with_style(style))
+
            write!(
+
                self.stream,
+
                "{}{EOL}",
+
                term::Paint::new(s).with_style(style)
+
            )
        } else {
-
            writeln!(self.stream, "{s}")
+
            write!(self.stream, "{s}{EOL}")
        }
    }

modified crates/radicle-cli/src/node.rs
@@ -1,6 +1,5 @@
use core::time;
use std::collections::BTreeSet;
-
use std::io;
use std::io::Write;

use radicle::node::sync;
@@ -93,51 +92,12 @@ impl SyncError {
    }
}

-
/// Writes sync output.
-
#[derive(Debug)]
-
pub enum SyncWriter {
-
    /// Write to standard out.
-
    Stdout(io::Stdout),
-
    /// Write to standard error.
-
    Stderr(io::Stderr),
-
    /// Discard output, like [`std::io::sink`].
-
    Sink,
-
}
-

-
impl Clone for SyncWriter {
-
    fn clone(&self) -> Self {
-
        match self {
-
            Self::Stdout(_) => Self::Stdout(io::stdout()),
-
            Self::Stderr(_) => Self::Stderr(io::stderr()),
-
            Self::Sink => Self::Sink,
-
        }
-
    }
-
}
-

-
impl io::Write for SyncWriter {
-
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
-
        match self {
-
            Self::Stdout(stdout) => stdout.write(buf),
-
            Self::Stderr(stderr) => stderr.write(buf),
-
            Self::Sink => Ok(buf.len()),
-
        }
-
    }
-

-
    fn flush(&mut self) -> io::Result<()> {
-
        match self {
-
            Self::Stdout(stdout) => stdout.flush(),
-
            Self::Stderr(stderr) => stderr.flush(),
-
            Self::Sink => Ok(()),
-
        }
-
    }
-
}
-

/// Configures how sync progress is reported.
pub struct SyncReporting {
    /// Progress messages or animations.
-
    pub progress: SyncWriter,
+
    pub progress: term::PaintTarget,
    /// Completion messages.
-
    pub completion: SyncWriter,
+
    pub completion: term::PaintTarget,
    /// Debug output.
    pub debug: bool,
}
@@ -145,8 +105,8 @@ pub struct SyncReporting {
impl Default for SyncReporting {
    fn default() -> Self {
        Self {
-
            progress: SyncWriter::Stderr(io::stderr()),
-
            completion: SyncWriter::Stdout(io::stdout()),
+
            progress: term::PaintTarget::Stderr,
+
            completion: term::PaintTarget::Stdout,
            debug: false,
        }
    }
@@ -173,7 +133,7 @@ pub fn announce<R: ReadRepository>(
fn announce_<R>(
    repo: &R,
    settings: SyncSettings,
-
    mut reporting: SyncReporting,
+
    reporting: SyncReporting,
    node: &mut Node,
    profile: &Profile,
) -> Result<Option<sync::AnnouncerResult>, SyncError>
@@ -189,7 +149,7 @@ where

    let config = match sync::PrivateNetwork::private_repo(&doc) {
        None => {
-
            let (synced, unsynced) = node.seeds(rid)?.iter().fold(
+
            let (synced, unsynced) = node.seeds_for(rid, [*me])?.iter().fold(
                (BTreeSet::new(), BTreeSet::new()),
                |(mut synced, mut unsynced), seed| {
                    if seed.is_synced() {
@@ -214,7 +174,7 @@ where
        Err(err) => match err {
            sync::AnnouncerError::AlreadySynced(result) => {
                term::success!(
-
                    &mut reporting.completion;
+
                    &mut reporting.completion.writer();
                    "Nothing to announce, already in sync with {} seed(s) (see `rad sync status`)",
                    term::format::positive(result.synced()),
                );
@@ -222,7 +182,7 @@ where
            }
            sync::AnnouncerError::NoSeeds => {
                term::info!(
-
                    &mut reporting.completion;
+
                    &mut reporting.completion.writer();
                    "{}",
                    term::format::yellow(format!("No seeds found for {rid}."))
                );
@@ -235,22 +195,28 @@ where
    let min_replicas = target.replicas().lower_bound();
    let mut spinner = term::spinner_to(
        format!("Found {} seed(s)..", announcer.progress().unsynced()),
-
        reporting.completion.clone(),
        reporting.progress.clone(),
+
        reporting.completion.clone(),
    );

-
    match node.announce(rid, settings.timeout, announcer, |node, progress| {
-
        spinner.message(format!(
-
            "Synced with {}, {} of {} preferred seeds, and {} of at least {} replica(s).",
-
            term::format::node_id_human_compact(node),
-
            term::format::secondary(progress.preferred()),
-
            term::format::secondary(n_preferred_seeds),
-
            term::format::secondary(progress.synced()),
-
            // N.b. the number of replicas could exceed the target if we're
-
            // waiting for preferred seeds
-
            term::format::secondary(min_replicas.max(progress.synced())),
-
        ));
-
    }) {
+
    match node.announce(
+
        rid,
+
        [profile.did().into()],
+
        settings.timeout,
+
        announcer,
+
        |node, progress| {
+
            spinner.message(format!(
+
                "Synced with {}, {} of {} preferred seeds, and {} of at least {} replica(s).",
+
                term::format::node_id_human_compact(node),
+
                term::format::secondary(progress.preferred()),
+
                term::format::secondary(n_preferred_seeds),
+
                term::format::secondary(progress.synced()),
+
                // N.b. the number of replicas could exceed the target if we're
+
                // waiting for preferred seeds
+
                term::format::secondary(min_replicas.max(progress.synced())),
+
            ));
+
        },
+
    ) {
        Ok(result) => {
            spinner.message(format!(
                "Synced with {} seed(s)",
modified crates/radicle-cli/src/project.rs
@@ -1,7 +1,7 @@
use radicle::prelude::*;

use crate::git;
-
use radicle::git::RefStr;
+
use radicle::git::fmt::RefStr;
use radicle::node::NodeId;

/// Setup a repository remote and tracking branch.
@@ -22,7 +22,7 @@ impl SetupRemote<'_> {
        &self,
        name: impl AsRef<RefStr>,
        node: NodeId,
-
    ) -> anyhow::Result<(git::Remote, Option<BranchName>)> {
+
    ) -> anyhow::Result<(git::Remote<'_>, Option<BranchName>)> {
        let remote_url = radicle::git::Url::from(self.rid).with_namespace(node);
        let remote_name = name.as_ref();

modified crates/radicle-cli/src/terminal.rs
@@ -1,5 +1,6 @@
-
pub mod args;
-
pub use args::{Args, Error, Help};
+
pub(crate) mod args;
+
pub(crate) use args::Error;
+

pub mod format;
pub mod io;
pub use io::signer;
@@ -11,15 +12,10 @@ pub mod json;
pub mod patch;
pub mod upload_pack;

-
use std::ffi::OsString;
-
use std::process;
-

pub use radicle_term::*;

use radicle::profile::{Home, Profile};

-
use crate::terminal;
-

/// Context passed to all commands.
pub trait Context {
    /// Return the currently active profile, or an error if no profile is active.
@@ -38,89 +34,6 @@ impl Context for Profile {
    }
}

-
/// A command that can be run.
-
pub trait Command<A: Args, C: Context> {
-
    /// Run the command, given arguments and a context.
-
    fn run(self, args: A, context: C) -> anyhow::Result<()>;
-
}
-

-
impl<F, A: Args, C: Context> Command<A, C> for F
-
where
-
    F: FnOnce(A, C) -> anyhow::Result<()>,
-
{
-
    fn run(self, args: A, context: C) -> anyhow::Result<()> {
-
        self(args, context)
-
    }
-
}
-

-
pub fn run_command<A, C>(help: Help, cmd: C) -> !
-
where
-
    A: Args,
-
    C: Command<A, DefaultContext>,
-
{
-
    let args = std::env::args_os().skip(1).collect();
-

-
    run_command_args(help, cmd, args)
-
}
-

-
pub fn run_command_args<A, C>(help: Help, cmd: C, args: Vec<OsString>) -> !
-
where
-
    A: Args,
-
    C: Command<A, DefaultContext>,
-
{
-
    use io as term;
-

-
    let options = match A::from_args(args) {
-
        Ok((opts, unparsed)) => {
-
            if let Err(err) = args::finish(unparsed) {
-
                term::error(err);
-
                process::exit(1);
-
            }
-
            opts
-
        }
-
        Err(err) => {
-
            let hint = match err.downcast_ref::<Error>() {
-
                Some(Error::Help) => {
-
                    help.print();
-
                    process::exit(0);
-
                }
-
                // Print the manual, or the regular help if there's an error.
-
                Some(Error::HelpManual { name }) => {
-
                    let Ok(status) = term::manual(name) else {
-
                        help.print();
-
                        process::exit(0);
-
                    };
-
                    if !status.success() {
-
                        help.print();
-
                        process::exit(0);
-
                    }
-
                    process::exit(status.code().unwrap_or(0));
-
                }
-
                Some(Error::Usage) => {
-
                    term::usage(help.name, help.usage);
-
                    process::exit(1);
-
                }
-
                Some(Error::WithHint { hint, .. }) => Some(hint),
-
                None => None,
-
            };
-
            io::error(format!("rad {}: {err}", help.name));
-

-
            if let Some(hint) = hint {
-
                io::hint(hint);
-
            }
-
            process::exit(1);
-
        }
-
    };
-

-
    match cmd.run(options, DefaultContext) {
-
        Ok(()) => process::exit(0),
-
        Err(err) => {
-
            terminal::fail(help.name, &err);
-
            process::exit(1);
-
        }
-
    }
-
}
-

/// Gets the default profile. Fails if there is no profile.
pub struct DefaultContext;

@@ -143,7 +56,7 @@ impl Context for DefaultContext {
    }
}

-
pub fn fail(_name: &str, error: &anyhow::Error) {
+
pub fn fail(error: &anyhow::Error) {
    let err = error.to_string();
    let err = err.trim_end();

modified crates/radicle-cli/src/terminal/args.rs
@@ -1,30 +1,11 @@
-
use std::ffi::OsString;
-
use std::net::SocketAddr;
-
use std::str::FromStr;
-
use std::time;
+
use clap::builder::TypedValueParser;
+
use thiserror::Error;

-
use anyhow::anyhow;
-

-
use radicle::cob::{self, issue, patch};
-
use radicle::crypto;
-
use radicle::git::{Oid, RefString};
-
use radicle::node::{Address, Alias};
+
use radicle::node::policy::Scope;
use radicle::prelude::{Did, NodeId, RepoId};

-
use crate::git::Rev;
-
use crate::terminal as term;
-

#[derive(thiserror::Error, Debug)]
-
pub enum Error {
-
    /// If this error is returned from argument parsing, help is displayed.
-
    #[error("help invoked")]
-
    Help,
-
    /// If this error is returned from argument parsing, the manual page is displayed.
-
    #[error("help manual invoked")]
-
    HelpManual { name: &'static str },
-
    /// If this error is returned from argument parsing, usage is displayed.
-
    #[error("usage invoked")]
-
    Usage,
+
pub(crate) enum Error {
    /// An error with a hint.
    #[error("{err}")]
    WithHint {
@@ -33,173 +14,105 @@ pub enum Error {
    },
}

-
pub struct Help {
-
    pub name: &'static str,
-
    pub description: &'static str,
-
    pub version: &'static str,
-
    pub usage: &'static str,
+
/// Targets used in the `block` and `unblock` commands
+
#[derive(Clone, Debug)]
+
pub(crate) enum BlockTarget {
+
    Node(NodeId),
+
    Repo(RepoId),
}

-
impl Help {
-
    /// Print help to stdout.
-
    pub fn print(&self) {
-
        term::help(self.name, self.version, self.description, self.usage);
-
    }
+
#[derive(Debug, Error)]
+
#[error("invalid repository or node specified (RID parsing failed with: '{repo}', NID parsing failed with: '{node}'))")]
+
pub(crate) struct BlockTargetParseError {
+
    repo: radicle::identity::IdError,
+
    node: radicle::crypto::PublicKeyError,
}

-
pub trait Args: Sized {
-
    fn from_env() -> anyhow::Result<Self> {
-
        let args: Vec<_> = std::env::args_os().skip(1).collect();
+
impl std::str::FromStr for BlockTarget {
+
    type Err = BlockTargetParseError;

-
        match Self::from_args(args) {
-
            Ok((opts, unparsed)) => {
-
                self::finish(unparsed)?;
-

-
                Ok(opts)
-
            }
-
            Err(err) => Err(err),
-
        }
+
    fn from_str(val: &str) -> Result<Self, Self::Err> {
+
        val.parse::<RepoId>()
+
            .map(BlockTarget::Repo)
+
            .or_else(|repo| {
+
                val.parse::<NodeId>()
+
                    .map(BlockTarget::Node)
+
                    .map_err(|node| BlockTargetParseError { repo, node })
+
            })
    }
-

-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)>;
}

-
pub fn parse_value<T: FromStr>(flag: &str, value: OsString) -> anyhow::Result<T>
-
where
-
    <T as FromStr>::Err: std::error::Error,
-
{
-
    value
-
        .into_string()
-
        .map_err(|_| anyhow!("the value specified for '--{}' is not valid UTF-8", flag))?
-
        .parse()
-
        .map_err(|e| anyhow!("invalid value specified for '--{}' ({})", flag, e))
-
}
-

-
pub fn format(arg: lexopt::Arg) -> OsString {
-
    match arg {
-
        lexopt::Arg::Long(flag) => format!("--{flag}").into(),
-
        lexopt::Arg::Short(flag) => format!("-{flag}").into(),
-
        lexopt::Arg::Value(val) => val,
+
impl std::fmt::Display for BlockTarget {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            Self::Node(nid) => nid.fmt(f),
+
            Self::Repo(rid) => rid.fmt(f),
+
        }
    }
}

-
pub fn finish(unparsed: Vec<OsString>) -> anyhow::Result<()> {
-
    if let Some(arg) = unparsed.first() {
-
        anyhow::bail!("unexpected argument `{}`", arg.to_string_lossy())
-
    }
-
    Ok(())
+
#[derive(Debug, thiserror::Error)]
+
#[error("invalid Node ID specified (Node ID parsing failed with: '{nid}', DID parsing failed with: '{did}'))")]
+
pub(crate) struct NodeIdParseError {
+
    did: radicle::identity::did::DidError,
+
    nid: radicle::crypto::PublicKeyError,
}

-
pub fn refstring(flag: &str, value: OsString) -> anyhow::Result<RefString> {
-
    RefString::try_from(
+
pub(crate) fn parse_nid(value: &str) -> Result<NodeId, NodeIdParseError> {
+
    value.parse::<Did>().map(NodeId::from).or_else(|did| {
        value
-
            .into_string()
-
            .map_err(|_| anyhow!("the value specified for '--{}' is not valid UTF-8", flag))?,
-
    )
-
    .map_err(|_| {
-
        anyhow!(
-
            "the value specified for '--{}' is not a valid ref string",
-
            flag
-
        )
+
            .parse::<NodeId>()
+
            .map_err(|nid| NodeIdParseError { nid, did })
    })
}

-
pub fn did(val: &OsString) -> anyhow::Result<Did> {
-
    let val = val.to_string_lossy();
-
    let Ok(peer) = Did::from_str(&val) else {
-
        if crypto::PublicKey::from_str(&val).is_ok() {
-
            return Err(anyhow!("expected DID, did you mean 'did:key:{val}'?"));
-
        } else {
-
            return Err(anyhow!("invalid DID '{}', expected 'did:key'", val));
-
        }
-
    };
-
    Ok(peer)
-
}
-

-
pub fn nid(val: &OsString) -> anyhow::Result<NodeId> {
-
    let val = val.to_string_lossy();
-
    NodeId::from_str(&val).map_err(|_| anyhow!("invalid Node ID '{}'", val))
-
}
-

-
pub fn rid(val: &OsString) -> anyhow::Result<RepoId> {
-
    let val = val.to_string_lossy();
-
    RepoId::from_str(&val).map_err(|_| anyhow!("invalid Repository ID '{}'", val))
-
}
+
#[derive(Clone, Debug)]
+
pub(crate) struct ScopeParser;

-
pub fn pubkey(val: &OsString) -> anyhow::Result<NodeId> {
-
    let Ok(did) = did(val) else {
-
        let nid = nid(val)?;
-
        return Ok(nid);
-
    };
-
    Ok(did.as_key().to_owned())
-
}
-

-
pub fn socket_addr(val: &OsString) -> anyhow::Result<SocketAddr> {
-
    let val = val.to_string_lossy();
-
    SocketAddr::from_str(&val).map_err(|_| anyhow!("invalid socket address '{}'", val))
-
}
-

-
pub fn addr(val: &OsString) -> anyhow::Result<Address> {
-
    let val = val.to_string_lossy();
-
    Address::from_str(&val).map_err(|_| anyhow!("invalid address '{}'", val))
-
}
+
impl TypedValueParser for ScopeParser {
+
    type Value = Scope;

-
pub fn number(val: &OsString) -> anyhow::Result<usize> {
-
    let val = val.to_string_lossy();
-
    usize::from_str(&val).map_err(|_| anyhow!("invalid number '{}'", val))
-
}
-

-
pub fn seconds(val: &OsString) -> anyhow::Result<time::Duration> {
-
    let val = val.to_string_lossy();
-
    let secs = u64::from_str(&val).map_err(|_| anyhow!("invalid number of seconds '{}'", val))?;
-

-
    Ok(time::Duration::from_secs(secs))
-
}
-

-
pub fn milliseconds(val: &OsString) -> anyhow::Result<time::Duration> {
-
    let val = val.to_string_lossy();
-
    let secs =
-
        u64::from_str(&val).map_err(|_| anyhow!("invalid number of milliseconds '{}'", val))?;
-

-
    Ok(time::Duration::from_millis(secs))
-
}
-

-
pub fn string(val: &OsString) -> String {
-
    val.to_string_lossy().to_string()
-
}
-

-
pub fn rev(val: &OsString) -> anyhow::Result<Rev> {
-
    let s = val.to_str().ok_or(anyhow!("invalid git rev {val:?}"))?;
-
    Ok(Rev::from(s.to_owned()))
-
}
-

-
pub fn oid(val: &OsString) -> anyhow::Result<Oid> {
-
    let s = string(val);
-
    let o = radicle::git::Oid::from_str(&s).map_err(|_| anyhow!("invalid git oid '{s}'"))?;
+
    fn parse_ref(
+
        &self,
+
        cmd: &clap::Command,
+
        arg: Option<&clap::Arg>,
+
        value: &std::ffi::OsStr,
+
    ) -> Result<Self::Value, clap::Error> {
+
        <Scope as std::str::FromStr>::from_str.parse_ref(cmd, arg, value)
+
    }

-
    Ok(o)
+
    fn possible_values(
+
        &self,
+
    ) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
+
        use clap::builder::PossibleValue;
+
        Some(Box::new(
+
            [PossibleValue::new("all"), PossibleValue::new("followed")].into_iter(),
+
        ))
+
    }
}

-
pub fn alias(val: &OsString) -> anyhow::Result<Alias> {
-
    let val = val.as_os_str();
-
    let val = val
-
        .to_str()
-
        .ok_or_else(|| anyhow!("alias must be valid UTF-8"))?;
+
#[cfg(test)]
+
mod test {
+
    use std::str::FromStr;

-
    Alias::from_str(val).map_err(|e| e.into())
-
}
+
    use super::BlockTarget;
+
    use super::BlockTargetParseError;

-
pub fn issue(val: &OsString) -> anyhow::Result<issue::IssueId> {
-
    let val = val.to_string_lossy();
-
    issue::IssueId::from_str(&val).map_err(|_| anyhow!("invalid Issue ID '{}'", val))
-
}
+
    #[test]
+
    fn should_parse_nid() {
+
        let target = BlockTarget::from_str("z6MkiswaKJ85vafhffCGBu2gdBsYoDAyHVBWRxL3j297fwS9");
+
        assert!(target.is_ok())
+
    }

-
pub fn patch(val: &OsString) -> anyhow::Result<patch::PatchId> {
-
    let val = val.to_string_lossy();
-
    patch::PatchId::from_str(&val).map_err(|_| anyhow!("invalid Patch ID '{}'", val))
-
}
+
    #[test]
+
    fn should_parse_rid() {
+
        let target = BlockTarget::from_str("rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH");
+
        assert!(target.is_ok())
+
    }

-
pub fn cob(val: &OsString) -> anyhow::Result<cob::ObjectId> {
-
    let val = val.to_string_lossy();
-
    cob::ObjectId::from_str(&val).map_err(|_| anyhow!("invalid Object ID '{}'", val))
+
    #[test]
+
    fn should_not_parse() {
+
        let err = BlockTarget::from_str("bee").unwrap_err();
+
        assert!(matches!(err, BlockTargetParseError { .. }));
+
    }
}
modified crates/radicle-cli/src/terminal/io.rs
@@ -45,18 +45,23 @@ impl inquire::validator::StringValidator for PassphraseValidator {
/// Get the signer. First we try getting it from ssh-agent, otherwise we prompt the user,
/// if we're connected to a TTY.
pub fn signer(profile: &Profile) -> anyhow::Result<BoxedDevice> {
-
    if let Ok(signer) = profile.signer() {
-
        return Ok(signer);
+
    match profile.signer() {
+
        Ok(signer) => return Ok(signer),
+
        Err(err) if !err.prompt_for_passphrase() => return Err(anyhow!(err)),
+
        Err(_) => {
+
            // The error returned is potentially recoverable by prompting
+
            // the user for the correct passphrase.
+
        }
    }
+

    let validator = PassphraseValidator::new(profile.keystore.clone());
-
    let passphrase = match passphrase(validator) {
-
        Ok(p) => p,
-
        Err(inquire::InquireError::NotTTY) => {
+
    let passphrase = match passphrase(validator)? {
+
        Some(p) => p,
+
        None => {
            anyhow::bail!(
-
                "running in non-interactive mode, please set `{RAD_PASSPHRASE}` to unseal your key",
+
                "A passphrase is required to read your Radicle key. Unable to continue. Consider setting the environment variable `{RAD_PASSPHRASE}`.",
            )
        }
-
        Err(e) => return Err(e.into()),
    };
    let spinner = spinner("Unsealing key...");
    let signer = MemorySigner::load(&profile.keystore, Some(passphrase))?;
modified crates/radicle-cli/src/terminal/patch.rs
@@ -83,18 +83,19 @@ impl Message {
            placeholder.push_str(title.as_ref());
            placeholder.push('\n');
        }
-
        if let Some(description) = description {
+
        if let Some(description) = description
+
            .as_deref()
+
            .map(str::trim)
+
            .filter(|description| !description.is_empty())
+
        {
            placeholder.push('\n');
-
            placeholder.push_str(description.trim());
+
            placeholder.push_str(description);
            placeholder.push('\n');
        }
        placeholder.push_str(help);

        let output = Self::Edit.get(&placeholder)?;
-
        let (title, description) = match output.split_once("\n\n") {
-
            Some((x, y)) => (x, y),
-
            None => (output.as_str(), ""),
-
        };
+
        let (title, description) = output.split_once("\n\n").unwrap_or((output.as_str(), ""));

        let Ok(title) = Title::new(title) else {
            return Ok(None);
@@ -112,6 +113,12 @@ impl Message {
    }
}

+
impl From<String> for Message {
+
    fn from(value: String) -> Self {
+
        Message::Text(value)
+
    }
+
}
+

pub const PATCH_MSG: &str = r#"
<!--
Please enter a patch message for your changes. An empty
@@ -181,8 +188,8 @@ fn message_from_commits(name: &str, commits: Vec<git::raw::Commit>) -> Result<St
/// Return commits between the merge base and a head.
pub fn patch_commits<'a>(
    repo: &'a git::raw::Repository,
-
    base: &git::Oid,
-
    head: &git::Oid,
+
    base: &git::raw::Oid,
+
    head: &git::raw::Oid,
) -> Result<Vec<git::raw::Commit<'a>>, git::raw::Error> {
    let mut commits = Vec::new();
    let mut revwalk = repo.revwalk()?;
@@ -198,8 +205,8 @@ pub fn patch_commits<'a>(
/// The message shown in the editor when creating a `Patch`.
fn create_display_message(
    repo: &git::raw::Repository,
-
    base: &git::Oid,
-
    head: &git::Oid,
+
    base: &git::raw::Oid,
+
    head: &git::raw::Oid,
) -> Result<String, Error> {
    let commits = patch_commits(repo, base, head)?;
    if commits.is_empty() {
@@ -219,8 +226,8 @@ fn create_display_message(
pub fn get_create_message(
    message: term::patch::Message,
    repo: &git::raw::Repository,
-
    base: &git::Oid,
-
    head: &git::Oid,
+
    base: &git::raw::Oid,
+
    head: &git::raw::Oid,
) -> Result<(Title, String), Error> {
    let display_msg = create_display_message(repo, base, head)?;
    let message = message.get(&display_msg)?;
@@ -272,10 +279,10 @@ pub fn get_edit_message(
/// The message shown in the editor when updating a `Patch`.
fn update_display_message(
    repo: &git::raw::Repository,
-
    last_rev_head: &git::Oid,
-
    head: &git::Oid,
+
    last_rev_head: &git::raw::Oid,
+
    head: &git::raw::Oid,
) -> Result<String, Error> {
-
    if !repo.graph_descendant_of(**head, **last_rev_head)? {
+
    if !repo.graph_descendant_of(*head, *last_rev_head)? {
        return Ok(REVISION_MSG.trim_start().to_string());
    }

@@ -295,9 +302,9 @@ pub fn get_update_message(
    message: term::patch::Message,
    repo: &git::raw::Repository,
    latest: &patch::Revision,
-
    head: &git::Oid,
+
    head: &git::raw::Oid,
) -> Result<String, Error> {
-
    let display_msg = update_display_message(repo, &latest.head(), head)?;
+
    let display_msg = update_display_message(repo, &latest.head().into(), head)?;
    let message = message.get(&display_msg)?;
    let message = message.trim();

@@ -306,18 +313,20 @@ pub fn get_update_message(

/// List the given commits in a table.
pub fn list_commits(commits: &[git::raw::Commit]) -> anyhow::Result<()> {
-
    let mut table = term::Table::default();
-

-
    for commit in commits {
-
        let message = commit
-
            .summary_bytes()
-
            .unwrap_or_else(|| commit.message_bytes());
-
        table.push([
-
            term::format::secondary(term::format::oid(commit.id()).into()),
-
            term::format::italic(String::from_utf8_lossy(message).to_string()),
-
        ]);
-
    }
-
    table.print();
+
    commits
+
        .iter()
+
        .map(|commit| {
+
            let message = commit
+
                .summary_bytes()
+
                .unwrap_or_else(|| commit.message_bytes());
+

+
            [
+
                term::format::secondary(term::format::oid(commit.id()).into()),
+
                term::format::italic(String::from_utf8_lossy(message).to_string()),
+
            ]
+
        })
+
        .collect::<term::Table<2, _>>()
+
        .print();

    Ok(())
}
@@ -357,11 +366,8 @@ pub fn show(
    } else {
        vec![]
    };
-
    let ahead_behind = common::ahead_behind(
-
        stored.raw(),
-
        *revision.head(),
-
        *patch.target().head(stored)?,
-
    )?;
+
    let ahead_behind =
+
        common::ahead_behind(stored.raw(), revision.head(), patch.target().head(stored)?)?;
    let author = patch.author();
    let author = term::format::Author::new(author.id(), profile, verbose);
    let labels = patch.labels().map(|l| l.to_string()).collect::<Vec<_>>();
@@ -392,12 +398,10 @@ pub fn show(
        term::format::tertiary("Head".to_owned()).into(),
        term::format::secondary(revision.head().to_string()).into(),
    ]);
-
    if verbose {
-
        attrs.push([
-
            term::format::tertiary("Base".to_owned()).into(),
-
            term::format::secondary(revision.base().to_string()).into(),
-
        ]);
-
    }
+
    attrs.push([
+
        term::format::tertiary("Base".to_owned()).into(),
+
        term::format::secondary(revision.base().to_string()).into(),
+
    ]);
    if !branches.is_empty() {
        attrs.push([
            term::format::tertiary("Branches".to_owned()).into(),
@@ -436,7 +440,7 @@ pub fn show(
        .children(commits.into_iter().map(|l| l.boxed()))
        .divider();

-
    for line in timeline::timeline(profile, patch) {
+
    for line in timeline::timeline(profile, patch, verbose) {
        widget.push(line);
    }

@@ -461,7 +465,7 @@ fn patch_commit_lines(
    let (from, to) = patch.range()?;
    let mut lines = Vec::new();

-
    for commit in patch_commits(stored.raw(), &from, &to)? {
+
    for commit in patch_commits(stored.raw(), &from.into(), &to.into())? {
        lines.push(term::Line::spaced([
            term::label(term::format::secondary::<String>(
                term::format::oid(commit.id()).into(),
@@ -477,37 +481,36 @@ fn patch_commit_lines(
#[cfg(test)]
mod test {
    use super::*;
-
    use radicle::git::refname;
+
    use radicle::git::fmt::refname;
    use radicle::test::fixtures;
    use std::path;

    fn commit(
        repo: &git::raw::Repository,
-
        branch: &git::RefStr,
-
        parent: &git::Oid,
+
        branch: &git::fmt::RefStr,
+
        parent: &git::raw::Oid,
        msg: &str,
-
    ) -> git::Oid {
+
    ) -> git::raw::Oid {
        let sig = git::raw::Signature::new(
            "anonymous",
-
            "anonymous@radicle.xyz",
+
            "anonymous@radicle.example.com",
            &git::raw::Time::new(0, 0),
        )
        .unwrap();
-
        let head = repo.find_commit(**parent).unwrap();
+
        let head = repo.find_commit(*parent).unwrap();
        let tree =
            git::write_tree(path::Path::new("README"), "Hello World!\n".as_bytes(), repo).unwrap();

        let branch = git::refs::branch(branch);
        let commit = git::commit(repo, &head, &branch, msg, &sig, &tree).unwrap();

-
        commit.id().into()
+
        commit.id()
    }

    #[test]
    fn test_create_display_message() {
        let tmpdir = tempfile::tempdir().unwrap();
        let (repo, commit_0) = fixtures::repository(&tmpdir);
-
        let commit_0 = commit_0.into();
        let commit_1 = commit(
            &repo,
            &refname!("feature"),
@@ -618,7 +621,6 @@ mod test {
    fn test_update_display_message() {
        let tmpdir = tempfile::tempdir().unwrap();
        let (repo, commit_0) = fixtures::repository(&tmpdir);
-
        let commit_0 = commit_0.into();

        let commit_1 = commit(&repo, &refname!("feature"), &commit_0, "commit 1\n");
        let commit_2 = commit(&repo, &refname!("feature"), &commit_1, "commit 2\n");
modified crates/radicle-cli/src/terminal/patch/common.rs
@@ -1,7 +1,7 @@
use anyhow::anyhow;

use radicle::git;
-
use radicle::git::raw::Oid;
+
use radicle::git::Oid;
use radicle::prelude::*;
use radicle::storage::git::Repository;

@@ -9,7 +9,7 @@ use crate::terminal as term;

/// Give the oid of the branch or an appropriate error.
#[inline]
-
pub fn branch_oid(branch: &git::raw::Branch) -> anyhow::Result<git::Oid> {
+
pub fn branch_oid(branch: &git::raw::Branch) -> anyhow::Result<Oid> {
    let oid = branch
        .get()
        .target()
@@ -18,7 +18,7 @@ pub fn branch_oid(branch: &git::raw::Branch) -> anyhow::Result<git::Oid> {
}

#[inline]
-
fn get_branch(git_ref: git::Qualified) -> git::RefString {
+
fn get_branch(git_ref: git::fmt::Qualified) -> git::fmt::RefString {
    let (_, _, head, tail) = git_ref.non_empty_components();
    std::iter::once(head).chain(tail).collect()
}
@@ -28,16 +28,18 @@ fn get_branch(git_ref: git::Qualified) -> git::RefString {
pub fn get_merge_target(
    storage: &Repository,
    head_branch: &git::raw::Branch,
-
) -> anyhow::Result<(git::RefString, git::Oid)> {
+
) -> anyhow::Result<(git::fmt::RefString, git::Oid)> {
    let (qualified_ref, target_oid) = storage.canonical_head()?;
    let head_oid = branch_oid(head_branch)?;
-
    let merge_base = storage.raw().merge_base(*head_oid, *target_oid)?;
+
    let merge_base = storage
+
        .raw()
+
        .merge_base(head_oid.into(), target_oid.into())?;

-
    if head_oid == merge_base.into() {
+
    if head_oid == merge_base {
        anyhow::bail!("commits are already included in the target branch; nothing to do");
    }

-
    Ok((get_branch(qualified_ref), (*target_oid).into()))
+
    Ok((get_branch(qualified_ref), (target_oid)))
}

/// Get the diff stats between two commits.
@@ -47,8 +49,8 @@ pub fn diff_stats(
    old: &Oid,
    new: &Oid,
) -> Result<git::raw::DiffStats, git::raw::Error> {
-
    let old = repo.find_commit(*old)?;
-
    let new = repo.find_commit(*new)?;
+
    let old = repo.find_commit(old.into())?;
+
    let new = repo.find_commit(new.into())?;
    let old_tree = old.tree()?;
    let new_tree = new.tree()?;
    let mut diff = repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)?;
@@ -64,7 +66,7 @@ pub fn ahead_behind(
    revision_oid: Oid,
    head_oid: Oid,
) -> anyhow::Result<term::Line> {
-
    let (a, b) = repo.graph_ahead_behind(revision_oid, head_oid)?;
+
    let (a, b) = repo.graph_ahead_behind(revision_oid.into(), head_oid.into())?;
    if a == 0 && b == 0 {
        return Ok(term::Line::new(term::format::dim("up to date")));
    }
@@ -88,7 +90,7 @@ pub fn branches(target: &Oid, repo: &git::raw::Repository) -> anyhow::Result<Vec
            continue;
        }
        if let (Some(oid), Some(name)) = (&r.target(), &r.shorthand()) {
-
            if oid == target {
+
            if target == oid {
                branches.push(name.to_string());
            };
        };
@@ -97,7 +99,7 @@ pub fn branches(target: &Oid, repo: &git::raw::Repository) -> anyhow::Result<Vec
}

#[inline]
-
pub fn try_branch(reference: git::raw::Reference<'_>) -> anyhow::Result<git::raw::Branch> {
+
pub fn try_branch(reference: git::raw::Reference<'_>) -> anyhow::Result<git::raw::Branch<'_>> {
    let branch = if reference.is_branch() {
        git::raw::Branch::wrap(reference)
    } else {
modified crates/radicle-cli/src/terminal/patch/timeline.rs
@@ -9,158 +9,63 @@ use radicle::profile::Profile;
use crate::terminal as term;
use crate::terminal::format::Author;

-
pub fn timeline<'a>(
-
    profile: &'a Profile,
-
    patch: &'a Patch,
-
) -> impl Iterator<Item = term::Line> + 'a {
-
    Timeline::build(profile, patch).into_lines(profile)
-
}
-

/// The timeline of a [`Patch`].
///
-
/// A `Patch` will always have opened with a root revision and may
+
/// A [`Patch`] will always have opened with a root revision and may
/// have a series of revisions that update the patch.
///
-
/// The function, [`timeline`], builds a `Timeline` and converts it
-
/// into a series of [`term::Line`]s.
-
struct Timeline<'a> {
-
    opened: Opened<'a>,
-
    revisions: Vec<RevisionEntry<'a>>,
-
}
+
/// This function converts it into a series of [`term::Line`]s for
+
/// display.
+
pub fn timeline<'a>(
+
    profile: &'a Profile,
+
    patch: &'a Patch,
+
    verbose: bool,
+
) -> impl Iterator<Item = term::Line> + 'a {
+
    let mut revisions = patch
+
        .revisions()
+
        .map(|(id, revision)| {
+
            (
+
                revision.timestamp(),
+
                RevisionEntry::from_revision(patch, id, revision, profile, verbose),
+
            )
+
        })
+
        .collect::<Vec<_>>();

-
impl<'a> Timeline<'a> {
-
    fn build(profile: &Profile, patch: &'a Patch) -> Self {
-
        let opened = Opened::from_patch(patch, profile);
-
        let mut revisions = patch
-
            .revisions()
-
            .skip(1) // skip the root revision since it's handled in `Opened::from_patch`
-
            .map(|(id, revision)| {
-
                (
-
                    revision.timestamp(),
-
                    RevisionEntry::from_revision(patch, id, revision, profile),
-
                )
-
            })
-
            .collect::<Vec<_>>();
-
        revisions.sort_by_key(|(t, _)| *t);
-
        Timeline {
-
            opened,
-
            revisions: revisions.into_iter().map(|(_, e)| e).collect(),
-
        }
-
    }
+
    revisions.sort_by_key(|(t, _)| *t);

-
    fn into_lines(self, profile: &'a Profile) -> impl Iterator<Item = term::Line> + 'a {
-
        self.opened.into_lines(profile).chain(
-
            self.revisions
-
                .into_iter()
-
                .flat_map(|r| r.into_lines(profile)),
-
        )
-
    }
+
    revisions
+
        .into_iter()
+
        .map(|(_, e)| e)
+
        .flat_map(move |r| r.into_lines(profile, verbose))
}

-
/// The root `Revision` of the `Patch`.
-
struct Opened<'a> {
-
    /// The `Author` of the patch.
+
/// A revision entry in the timeline.
+
///
+
/// We do not distinguish between revisions created by the original author and
+
/// others, and also not between the initial revision and others. This tends to
+
/// confuse more than it helps.
+
struct RevisionEntry<'a> {
+
    /// Whether this entry is about the initial [`Revision`] of the patch.
+
    is_initial: bool,
+
    /// The [`Author`] that created the [`Revision`].
    author: Author<'a>,
-
    /// When the patch was created.
+
    /// When the [`Revision`] was created.
    timestamp: cob::Timestamp,
-
    /// The commit head of the `Revision`.
+
    /// The id of the [`Revision`].
+
    id: RevisionId,
+
    /// The commit head of the [`Revision`].
    head: git::Oid,
-
    /// Any updates performed on the root `Revision`.
+
    /// All [`Update`]s that occurred on the [`Revision`].
    updates: Vec<Update<'a>>,
}

-
impl<'a> Opened<'a> {
-
    fn from_patch(patch: &'a Patch, profile: &Profile) -> Self {
-
        let (root, revision) = patch.root();
-
        let mut updates = Vec::new();
-
        updates.extend(revision.reviews().map(|(_, review)| {
-
            (
-
                review.timestamp(),
-
                Update::Reviewed {
-
                    review: review.clone(),
-
                },
-
            )
-
        }));
-
        updates.extend(patch.merges().filter_map(|(nid, merge)| {
-
            if merge.revision == root {
-
                Some((
-
                    merge.timestamp,
-
                    Update::Merged {
-
                        author: Author::new(nid, profile, false),
-
                        merge: merge.clone(),
-
                    },
-
                ))
-
            } else {
-
                None
-
            }
-
        }));
-
        updates.sort_by_key(|(t, _)| *t);
-
        Opened {
-
            author: Author::new(&patch.author().id, profile, false),
-
            timestamp: patch.timestamp(),
-
            head: revision.head(),
-
            updates: updates.into_iter().map(|(_, up)| up).collect(),
-
        }
-
    }
-

-
    fn into_lines(self, profile: &'a Profile) -> impl Iterator<Item = term::Line> + 'a {
-
        iter::once(
-
            term::Line::spaced([
-
                term::format::positive("●").into(),
-
                term::format::default("opened by").into(),
-
            ])
-
            .space()
-
            .extend(self.author.line())
-
            .space()
-
            .extend(term::Line::spaced([
-
                term::format::parens(term::format::secondary(term::format::oid(self.head))).into(),
-
                term::format::dim(term::format::timestamp(self.timestamp)).into(),
-
            ])),
-
        )
-
        .chain(self.updates.into_iter().map(|up| {
-
            term::Line::spaced([term::Label::space(), term::Label::from("└─ ")])
-
                .extend(up.into_line(profile))
-
        }))
-
    }
-
}
-

-
/// A revision entry in the [`Timeline`].
-
enum RevisionEntry<'a> {
-
    /// An `Updated` entry means that the original author of the
-
    /// `Patch` created a new revision.
-
    Updated {
-
        /// When the `Revision` was created.
-
        timestamp: cob::Timestamp,
-
        /// The id of the `Revision`.
-
        id: RevisionId,
-
        /// The commit head of the `Revision`.
-
        head: git::Oid,
-
        /// All [`Update`]s that occurred on the `Revision`.
-
        updates: Vec<Update<'a>>,
-
    },
-
    /// A `Revised` entry means that an author other than the original
-
    /// author of the `Patch` created a new revision.
-
    Revised {
-
        /// The `Author` that created the `Revision` (that is not the
-
        /// `Patch` author).
-
        author: Author<'a>,
-
        /// When the `Revision` was created.
-
        timestamp: cob::Timestamp,
-
        /// The id of the `Revision`.
-
        id: RevisionId,
-
        /// The commit head of the `Revision`.
-
        head: git::Oid,
-
        /// All [`Update`]s that occurred on the `Revision`.
-
        updates: Vec<Update<'a>>,
-
    },
-
}
-

impl<'a> RevisionEntry<'a> {
    fn from_revision(
        patch: &'a Patch,
        id: RevisionId,
        revision: &'a Revision,
        profile: &Profile,
+
        verbose: bool,
    ) -> Self {
        let mut updates = Vec::new();
        updates.extend(revision.reviews().map(|(_, review)| {
@@ -176,8 +81,12 @@ impl<'a> RevisionEntry<'a> {
                Some((
                    merge.timestamp,
                    Update::Merged {
-
                        author: Author::new(nid, profile, false),
-
                        merge: merge.clone(),
+
                        author: Author::new(nid, profile, verbose),
+
                        merge: if merge.commit != revision.head() {
+
                            Some(merge.clone())
+
                        } else {
+
                            None
+
                        },
                    },
                ))
            } else {
@@ -186,84 +95,58 @@ impl<'a> RevisionEntry<'a> {
        }));
        updates.sort_by_key(|(t, _)| *t);

-
        if revision.author() == patch.author() {
-
            RevisionEntry::Updated {
-
                timestamp: revision.timestamp(),
-
                id,
-
                head: revision.head(),
-
                updates: updates.into_iter().map(|(_, up)| up).collect(),
-
            }
-
        } else {
-
            RevisionEntry::Revised {
-
                author: Author::new(&revision.author().id, profile, false),
-
                timestamp: revision.timestamp(),
-
                id,
-
                head: revision.head(),
-
                updates: updates.into_iter().map(|(_, up)| up).collect(),
-
            }
-
        }
-
    }
-

-
    fn into_lines(self, profile: &'a Profile) -> Vec<term::Line> {
-
        match self {
-
            RevisionEntry::Updated {
-
                timestamp,
-
                id,
-
                head,
-
                updates,
-
            } => Self::updated(profile, timestamp, id, head, updates).collect(),
-
            RevisionEntry::Revised {
-
                author,
-
                timestamp,
-
                id,
-
                head,
-
                updates,
-
            } => Self::revised(profile, author, timestamp, id, head, updates).collect(),
+
        RevisionEntry {
+
            is_initial: patch.root().0 == id,
+
            author: Author::new(&revision.author().id, profile, verbose),
+
            timestamp: revision.timestamp(),
+
            id,
+
            head: revision.head(),
+
            updates: updates.into_iter().map(|(_, up)| up).collect(),
        }
    }

-
    fn updated(
+
    fn into_lines(
+
        self,
        profile: &'a Profile,
-
        timestamp: cob::Timestamp,
-
        id: RevisionId,
-
        head: git::Oid,
-
        updates: Vec<Update<'a>>,
+
        verbose: bool,
    ) -> impl Iterator<Item = term::Line> + 'a {
-
        iter::once(term::Line::spaced([
-
            term::format::tertiary("↑").into(),
-
            term::format::default("updated to").into(),
-
            term::format::dim(id).into(),
-
            term::format::parens(term::format::secondary(term::format::oid(head))).into(),
-
            term::format::dim(term::format::timestamp(timestamp)).into(),
-
        ]))
-
        .chain(updates.into_iter().map(|up| {
-
            term::Line::spaced([term::Label::space(), term::Label::from("└─ ")])
-
                .extend(up.into_line(profile))
-
        }))
-
    }
+
        use term::{format::*, *};

-
    fn revised(
-
        profile: &'a Profile,
-
        author: Author<'a>,
-
        timestamp: cob::Timestamp,
-
        id: RevisionId,
-
        head: git::Oid,
-
        updates: Vec<Update<'a>>,
-
    ) -> impl Iterator<Item = term::Line> + 'a {
-
        let (alias, nid) = author.labels();
-
        iter::once(term::Line::spaced([
-
            term::format::tertiary("*").into(),
-
            term::format::default("revised by").into(),
-
            alias,
-
            nid,
-
            term::format::default("in").into(),
-
            term::format::dim(term::format::oid(id)).into(),
-
            term::format::parens(term::format::secondary(term::format::oid(head))).into(),
-
            term::format::dim(term::format::timestamp(timestamp)).into(),
-
        ]))
-
        .chain(updates.into_iter().map(|up| {
-
            term::Line::spaced([term::Label::space(), term::Label::from("└─ ")])
-
                .extend(up.into_line(profile))
+
        let id: Label = if verbose {
+
            self.id.to_string().into()
+
        } else {
+
            oid(self.id).into()
+
        };
+

+
        let icon = if self.is_initial {
+
            positive("●")
+
        } else {
+
            tertiary("↑")
+
        };
+

+
        let line = Line::spaced([icon.into(), dim("Revision").into(), id]).space();
+

+
        let line = line
+
            .item(dim(if verbose { "with head" } else { "@" }))
+
            .space();
+

+
        let line = line.item(secondary(if verbose {
+
            Paint::new(self.head.to_string())
+
        } else {
+
            oid(self.head)
+
        }));
+

+
        iter::once(
+
            line.space()
+
                .extend([dim("by").into()])
+
                .space()
+
                .extend(self.author.line())
+
                .space()
+
                .item(dim(timestamp(self.timestamp))),
+
        )
+
        .chain(self.updates.into_iter().map(move |up| {
+
            Line::spaced([Label::space(), Label::from("└─ ")])
+
                .extend(up.into_line(profile, verbose))
        }))
    }
}
@@ -273,56 +156,81 @@ enum Update<'a> {
    /// A revision of the patch was reviewed.
    Reviewed { review: Review },
    /// A revision of the patch was merged.
-
    Merged { author: Author<'a>, merge: Merge },
+
    Merged {
+
        author: Author<'a>,
+
        /// If the merge is none, this means that it was a fast-forward merge.
+
        merge: Option<Merge>,
+
    },
}

impl Update<'_> {
-
    fn timestamp(&self) -> cob::Timestamp {
-
        match self {
-
            Update::Reviewed { review } => review.timestamp(),
-
            Update::Merged { merge, .. } => merge.timestamp,
-
        }
-
    }
+
    fn into_line(self, profile: &Profile, verbose: bool) -> term::Line {
+
        use term::{format::*, *};

-
    fn into_line(self, profile: &Profile) -> term::Line {
-
        let timestamp = self.timestamp();
-
        let mut line = match self {
+
        match self {
            Update::Reviewed { review } => {
-
                let verdict = review.verdict();
-
                let verdict_symbol = match verdict {
-
                    Some(Verdict::Accept) => term::PREFIX_SUCCESS,
-
                    Some(Verdict::Reject) => term::PREFIX_ERROR,
-
                    None => term::format::dim("⋄"),
-
                };
-
                let verdict_verb = match verdict {
-
                    Some(Verdict::Accept) => term::format::default("accepted"),
-
                    Some(Verdict::Reject) => term::format::default("rejected"),
-
                    None => term::format::default("reviewed"),
+
                let by = " ".repeat(if verbose { 0 } else { 13 }) + "by";
+

+
                let (symbol, verb) = match review.verdict() {
+
                    Some(Verdict::Accept) => (PREFIX_SUCCESS, positive("accepted")),
+
                    Some(Verdict::Reject) => (PREFIX_ERROR, negative("rejected")),
+
                    None => (dim("⋄"), default("reviewed")),
                };
-
                term::Line::spaced([
-
                    verdict_symbol.into(),
-
                    verdict_verb.into(),
-
                    term::format::default("by").into(),
-
                ])
-
                .space()
-
                .extend(Author::new(&review.author().id.into(), profile, false).line())
+

+
                Line::spaced([symbol.into(), verb.into(), dim(by).into()])
+
                    .space()
+
                    .extend(Author::new(&review.author().id.into(), profile, verbose).line())
+
                    .space()
+
                    .item(dim(timestamp(review.timestamp())))
            }
            Update::Merged { author, merge } => {
+
                // The additional whitespace after makes it align, see:
+
                // - "merged  "
+
                // - "accepted"
+
                // - "rejected"
+
                // This is less noisy to look at in the terminal.
+
                const MERGED: &str = "merged  ";
+

+
                let at_commit = if !verbose { " @ " } else { " at commit " };
+

                let (alias, nid) = author.labels();
-
                term::Line::spaced([
-
                    term::PREFIX_SUCCESS.bold().into(),
-
                    term::format::default("merged by").into(),
-
                    alias,
-
                    nid,
-
                    term::format::default("at revision").into(),
-
                    term::format::dim(term::format::oid(merge.revision)).into(),
-
                    term::format::parens(term::format::secondary(term::format::oid(merge.commit)))
-
                        .into(),
-
                ])
+

+
                let (commit, timestamp) = match merge {
+
                    Some(merge) => (
+
                        Line::spaced([dim(at_commit).into(), secondary(oid(merge.commit)).into()])
+
                            .space(),
+
                        timestamp(merge.timestamp),
+
                    ),
+
                    None => {
+
                        let mut line = Line::blank();
+
                        if !verbose {
+
                            const LENGTH_OF_SHORT_COMMIT_HASH: usize = 7;
+
                            const LENGTH_OF_SPACES: usize = 2;
+
                            line.pad(
+
                                2 // alignment
+
                                    + 2 // parens
+
                                    + LENGTH_OF_SHORT_COMMIT_HASH
+
                                    + LENGTH_OF_SPACES,
+
                            );
+
                        }
+
                        (line, "".into())
+
                    }
+
                };
+

+
                Line::blank()
+
                    .item(PREFIX_SUCCESS.bold())
+
                    .space()
+
                    .item(Label::from(positive(MERGED)))
+
                    .space()
+
                    .extend(commit)
+
                    .item(dim("by"))
+
                    .space()
+
                    .item(alias)
+
                    .space()
+
                    .item(nid)
+
                    .space()
+
                    .item(timestamp)
            }
-
        };
-
        line.push(term::Label::space());
-
        line.push(term::format::dim(term::format::timestamp(timestamp)));
-
        line
+
        }
    }
}
modified crates/radicle-cli/src/warning.rs
@@ -44,3 +44,21 @@ pub(crate) fn nodes_renamed(config: &Config) -> Vec<String> {
    ));
    warnings
}
+

+
/// Prints a deprecation warning to standard error.
+
pub(crate) fn deprecated(old: impl std::fmt::Display, new: impl std::fmt::Display) {
+
    eprintln!(
+
        "{} {} The command/option `{old}` is deprecated and will be removed. Please use `{new}` instead.",
+
        radicle_term::PREFIX_WARNING,
+
        radicle_term::Paint::yellow("Deprecated:").bold(),
+
    );
+
}
+

+
/// Prints an obsoletion warning to standard error.
+
pub(crate) fn obsolete(command: impl std::fmt::Display) {
+
    eprintln!(
+
        "{} {} The command `{command}` is obsolete and will be removed. Please stop using it.",
+
        radicle_term::PREFIX_WARNING,
+
        radicle_term::Paint::yellow("Obsolete:").bold(),
+
    );
+
}
modified crates/radicle-cli/tests/commands.rs
@@ -1,6 +1,7 @@
+
use core::panic;
use std::path::Path;
use std::str::FromStr;
-
use std::{net, thread, time};
+
use std::{fs, net, thread, time};

use radicle::cob;
use radicle::git;
@@ -36,26 +37,80 @@ pub(crate) fn test<'a>(
    envs: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Result<(), Box<dyn std::error::Error>> {
    let tmp = tempfile::tempdir().unwrap();
-
    let home = if let Some(home) = home {
-
        home.path().to_path_buf()
+

+
    let (unix_home, rad_home) = if let Some(home) = home {
+
        let unix_home = home.path().to_path_buf();
+
        let unix_home = unix_home.parent().unwrap().to_path_buf();
+
        (unix_home, home.path().to_path_buf())
    } else {
-
        tmp.path().to_path_buf()
+
        let mut rad_home = tmp.path().to_path_buf();
+
        rad_home.push(".radicle");
+
        (tmp.path().to_path_buf(), rad_home)
    };

    formula(cwd.as_ref(), test)?
-
        .env("RAD_HOME", home.to_string_lossy())
+
        .env("RAD_HOME", rad_home.to_string_lossy())
+
        .env(
+
            "JJ_CONFIG",
+
            unix_home.join(".jjconfig.toml").to_string_lossy(),
+
        )
        .envs(envs)
        .run()?;

    Ok(())
}

+
/// A utility to check that some program can be executed with a `--version`
+
/// argument and exits successfully.
+
///
+
/// # Panics
+
///
+
/// If there is an error executing the program other than the program not being
+
/// found, or the program does not exit successfully.
+
fn program_reports_version(program: &str) -> bool {
+
    use std::io::ErrorKind;
+
    use std::process::{Command, Stdio};
+

+
    match Command::new(program)
+
        .arg("--version")
+
        .stdout(Stdio::null())
+
        .status()
+
    {
+
        Err(e) if e.kind() == ErrorKind::NotFound => {
+
            log::warn!(target: "test", "`{program}` not found.");
+
            false
+
        }
+
        Err(e) => panic!("failure to execute `{program}`: {e}"),
+
        Ok(status) if status.success() => true,
+
        Ok(status) => panic!("executing `{program}` resulted in status {status}"),
+
    }
+
}
+

+
#[test]
+
fn rad_help() {
+
    Environment::alice(["rad-help"]);
+
}
+

#[test]
fn rad_auth() {
    test("examples/rad-auth.md", Path::new("."), None, []).unwrap();
}

#[test]
+
fn rad_key_mismatch() {
+
    let mut environment = Environment::new();
+
    let alice = environment.profile("alice");
+
    environment.repository(&alice);
+

+
    environment.test("rad-init", &alice).unwrap();
+

+
    // Replace the public key with one that does not match the secret key anymore.
+
    fs::write(alice.home.path().join("keys").join("radicle.pub"), "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE6Ul/D+P0I/Hl1JVOWGS8Z589us9FqKQXWv8OMOpKCh snakeoil\n").unwrap();
+

+
    environment.test("rad-key-mismatch", &alice).unwrap();
+
}
+

+
#[test]
fn rad_auth_errors() {
    test("examples/rad-auth-errors.md", Path::new("."), None, []).unwrap();
}
@@ -66,6 +121,11 @@ fn rad_issue() {
}

#[test]
+
fn rad_issue_list() {
+
    Environment::alice(["rad-init", "rad-issue", "rad-issue-list"]);
+
}
+

+
#[test]
fn rad_cob_update() {
    Environment::alice(["rad-init", "rad-cob-log"]);
}
@@ -97,21 +157,11 @@ fn rad_cob_update_identity() {

#[test]
fn rad_cob_multiset() {
-
    {
-
        // `rad-cob-multiset` is a `jq` script, which requires `jq` to be installed.
-
        // We test whether `jq` is installed, and have this test succeed if it is not.
-
        // Programmatic skipping of tests is not supported as of 2024-08.
-
        use std::io::ErrorKind;
-
        use std::process::{Command, Stdio};
-

-
        match Command::new("jq").arg("-V").stdout(Stdio::null()).status() {
-
            Err(e) if e.kind() == ErrorKind::NotFound => {
-
                log::warn!(target: "test", "`jq` not found. Succeeding prematurely.");
-
                return;
-
            }
-
            Err(e) => panic!("while checking for jq: {e}"),
-
            Ok(_) => {}
-
        }
+
    // `rad-cob-multiset` is a `jq` script, which requires `jq` to be installed.
+
    // We test whether `jq` is installed, and have this test succeed if it is not.
+
    // Programmatic skipping of tests is not supported as of 2024-08.
+
    if !program_reports_version("jq") {
+
        return;
    }

    let mut environment = Environment::new();
@@ -177,6 +227,15 @@ fn rad_init() {
}

#[test]
+
fn rad_init_bare() {
+
    let mut env = Environment::new();
+
    let alice = env.profile("alice");
+
    radicle::test::fixtures::bare_repository(env.work(&alice).as_path());
+
    env.tests(["git/git-is-bare-repository", "rad-init"], &alice)
+
        .unwrap();
+
}
+

+
#[test]
fn rad_init_existing() {
    let mut environment = Environment::new();
    let mut profile = environment.node("alice");
@@ -199,6 +258,28 @@ fn rad_init_existing() {
}

#[test]
+
fn rad_init_existing_bare() {
+
    let mut environment = Environment::new();
+
    let mut profile = environment.node("alice");
+
    let working = tempfile::tempdir().unwrap();
+
    let rid = profile.project("heartwood", "Radicle Heartwood Protocol & Stack");
+

+
    test(
+
        "examples/rad-init-existing-bare.md",
+
        working.path(),
+
        Some(&profile.home),
+
        [(
+
            "URL",
+
            git::url::File::new(profile.storage.path())
+
                .rid(rid)
+
                .to_string()
+
                .as_str(),
+
        )],
+
    )
+
    .unwrap();
+
}
+

+
#[test]
fn rad_init_no_seed() {
    Environment::alice(["rad-init-no-seed"]);
}
@@ -784,7 +865,49 @@ fn rad_node() {

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

+
#[test]
+
fn rad_jj_bare() {
+
    // We test whether `jj` is installed, and have this test succeed if it is not.
+
    // Programmatic skipping of tests is not supported as of 2024-08.
+
    if !program_reports_version("jj") {
+
        return;
+
    }
+

+
    let mut environment = Environment::new();
+
    let mut profile = environment.node("alice");
+
    let rid = profile.project("heartwood", "Radicle Heartwood Protocol & Stack");
+

+
    test(
+
        "examples/rad-init-existing-bare.md",
+
        environment.work(&profile),
+
        Some(&profile.home),
+
        [(
+
            "URL",
+
            git::url::File::new(profile.storage.path())
+
                .rid(rid)
+
                .to_string()
+
                .as_str(),
+
        )],
+
    )
+
    .unwrap();
+

+
    environment
+
        .tests(["jj-config", "jj-init-bare"], &profile)
+
        .unwrap();
+
}
+

+
#[test]
+
fn rad_jj_colocated_patch() {
+
    // We test whether `jj` is installed, and have this test succeed if it is not.
+
    // Programmatic skipping of tests is not supported as of 2024-08.
+
    if !program_reports_version("jj") {
+
        return;
+
    }
+

+
    Environment::alice(["rad-init", "jj-config", "jj-init-colocate", "rad-patch-jj"])
}

#[test]
@@ -1125,6 +1248,26 @@ fn rad_clone() {
}

#[test]
+
fn rad_clone_bare() {
+
    let mut environment = Environment::new();
+
    let mut alice = environment.node("alice");
+
    let bob = environment.node("bob");
+
    let working = environment.tempdir().join("working");
+

+
    // Setup a test project.
+
    let acme = alice.project("heartwood", "Radicle Heartwood Protocol & Stack");
+

+
    let mut alice = alice.spawn();
+
    let mut bob = bob.spawn();
+
    // Prevent Alice from fetching Bob's fork, as we're not testing that and it may cause errors.
+
    alice.handle.seed(acme, Scope::Followed).unwrap();
+

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

+
    test("examples/rad-clone-bare.md", working, Some(&bob.home), []).unwrap();
+
}
+

+
#[test]
fn rad_clone_directory() {
    let mut environment = Environment::new();
    let mut alice = environment.node("alice");
@@ -1232,7 +1375,7 @@ fn rad_clone_partial_fail() {
    eve.connect(&bob);
    eve.routes_to(&[(acme, carol), (acme, bob.id), (acme, alice.id)]);
    bob.storage.repository(acme).unwrap().remove().unwrap(); // Cause the fetch from Bob to fail.
-
    bob.storage.lock_repository(acme).ok(); // Prevent repo from being re-fetched.
+
    bob.storage.temporary_repository(acme).ok(); // Prevent repo from being re-fetched.

    test(
        "examples/rad-clone-partial-fail.md",
@@ -1546,6 +1689,13 @@ fn rad_fork() {

#[test]
fn rad_diff() {
+
    if std::env::consts::OS == "macos" {
+
        // macOS's `sed` requires an argument for `-i`, which we don't provide
+
        // in the example. Providing it makes the test fail on Linux.
+
        // Since this command is deprecated anyway, we just skip macOS.
+
        return;
+
    }
+

    let tmp = tempfile::tempdir().unwrap();

    fixtures::repository(&tmp);
@@ -1561,7 +1711,7 @@ fn test_clone_without_seeds() {
    let working = environment.tempdir().join("working");
    let rid = alice.project("heartwood", "Radicle Heartwood Protocol & Stack");
    let mut alice = alice.spawn();
-
    let seeds = alice.handle.seeds(rid).unwrap();
+
    let seeds = alice.handle.seeds_for(rid, [alice.id]).unwrap();
    let connected = seeds.connected().collect::<Vec<_>>();

    assert!(connected.is_empty());
@@ -1629,7 +1779,7 @@ fn test_cob_replication() {
    // announcement, otherwise Alice will consider it stale.
    thread::sleep(time::Duration::from_millis(3));

-
    bob.handle.announce_refs(rid).unwrap();
+
    bob.handle.announce_refs_for(rid, [bob.id]).unwrap();

    // Wait for Alice to fetch the issue refs.
    events
@@ -2276,6 +2426,11 @@ fn git_push_amend() {
}

#[test]
+
fn git_push_force_with_lease() {
+
    Environment::alice(["rad-init", "git/git-push-force-with-lease"]);
+
}
+

+
#[test]
fn git_push_rollback() {
    let mut environment = Environment::new();
    let alice = environment.node("alice");
modified crates/radicle-cli/tests/util/environment.rs
@@ -5,6 +5,7 @@ use localtime::LocalTime;
use radicle::cob::cache::COBS_DB_FILE;
use radicle::crypto::ssh::{keystore::MemorySigner, Keystore};
use radicle::crypto::{KeyPair, Seed};
+
use radicle::git;
use radicle::node::policy::store as policy;
use radicle::node::{self, UserAgent};
use radicle::node::{Alias, Config, POLICIES_DB_FILE};
@@ -82,37 +83,37 @@ impl Default for Environment {

impl Environment {
    /// Create a new test environment.
-
    fn named(name: &'static str) -> Self {
+
    pub fn new() -> Self {
        Self {
-
            tempdir: tempfile::TempDir::with_prefix("radicle-".to_owned() + name).unwrap(),
+
            tempdir: tempfile::TempDir::new().unwrap(),
            users: 0,
        }
    }

-
    /// Create a new test environment.
-
    pub fn new() -> Self {
-
        Self::named("")
-
    }
-

-
    /// Return the temp directory path.
+
    /// Return the path of the temporary directory at which
+
    /// this testing environment is rooted.
    pub fn tempdir(&self) -> PathBuf {
        self.tempdir.path().into()
    }

-
    /// Path to the working directory designated for given alias.
-
    pub fn work(&self, has_alias: &impl HasAlias) -> PathBuf {
-
        self.tempdir().join("work").join(has_alias.alias().as_ref())
+
    /// Return the home directory of the user with the given alias.
+
    /// This is in analogy to a Unix home directory.
+
    pub fn unix_home(&self, has_alias: &impl HasAlias) -> PathBuf {
+
        self.tempdir().join(has_alias.alias().to_string())
    }

-
    /// We don't have `RAD_HOME` or `HOME` to rely on to compute a home as usual.
-
    pub fn home(&self, alias: &Alias) -> Home {
-
        Home::new(
-
            self.tempdir()
-
                .join("home")
-
                .join(alias.to_string())
-
                .join(".radicle"),
-
        )
-
        .unwrap()
+
    /// Return the Radicle path of the user with the given alias.
+
    /// This is in analogy to `$RAD_HOME` and always a subdirectory of
+
    /// the user's home directory (see [`Environment::unix_home`]).
+
    pub fn rad_home(&self, has_alias: &impl HasAlias) -> Home {
+
        Home::new(self.unix_home(has_alias).join(".radicle")).unwrap()
+
    }
+

+
    /// Path to the working directory of the user with the given alias.
+
    /// Tests that need to act on multiple repositories should crate
+
    /// subdirecories within this directory.
+
    pub fn work(&self, has_alias: &impl HasAlias) -> PathBuf {
+
        self.unix_home(has_alias).join("work")
    }

    /// Create a new default configuration.
@@ -135,7 +136,7 @@ impl Environment {
    /// is provided.
    pub fn profile_with(&mut self, config: profile::Config) -> Profile {
        let alias = config.alias().clone();
-
        let home = self.home(&alias);
+
        let home = self.rad_home(&alias);
        let keypair = KeyPair::from_seed(Seed::from([!(self.users as u8); 32]));
        let policies_db = home.node().join(POLICIES_DB_FILE);
        let cobs_db = home.cobs().join(COBS_DB_FILE);
@@ -224,37 +225,47 @@ impl Environment {
        self.node_with(config::seed(alias))
    }

-
    /// Convenience method for placing repository fixture.
+
    /// Convenience method for placing repository fixture into the
+
    /// directory returned by [`Environment::work`] for the user.
+
    /// Use this only in tests that act on *a single repository* only.
+
    /// For tests that need to act on multiple repositories,
+
    /// create the repositories as subdirectories of the working
+
    /// directory returned by [`Environment::work`].
    pub fn repository(
        &self,
        has_alias: &impl HasAlias,
-
    ) -> (radicle_cli::git::Repository, radicle_cli::git::Oid) {
+
    ) -> (radicle_cli::git::Repository, git::raw::Oid) {
        radicle::test::fixtures::repository(self.work(has_alias).as_path())
    }

-
    // Convenience method for exectuing a test formula with standard configuration.
+
    // Convenience method for executing a test formula with standard configuration.
    pub fn test(
        &self,
        test_file: &'static str,
-
        subject: &(impl HasAlias + HasHome),
+
        subject: &impl HasAlias,
    ) -> Result<(), Box<dyn std::error::Error>> {
        formula(
            self.work(subject).as_ref(),
            PathBuf::from("examples").join(test_file.to_owned() + ".md"),
        )?
+
        .env("USER", subject.alias().as_ref())
+
        .env("RAD_HOME", self.rad_home(subject).path().to_string_lossy())
        .env(
-
            "RAD_HOME",
-
            subject.home().path().to_path_buf().to_string_lossy(),
+
            "JJ_CONFIG",
+
            self.unix_home(subject)
+
                .join(".jjconfig.toml")
+
                .to_string_lossy(),
        )
        .run()?;

        Ok(())
    }

+
    /// Convenience method for executing multiple test formulas with standard configuration.
    pub fn tests(
        &self,
        test_files: impl IntoIterator<Item = &'static str>,
-
        subject: &(impl HasAlias + HasHome),
+
        subject: &impl HasAlias,
    ) -> Result<(), Box<dyn std::error::Error>> {
        for test_file in test_files {
            self.test(test_file, subject)?;
@@ -277,6 +288,12 @@ pub trait HasAlias {
    fn alias(&self) -> &Alias;
}

+
impl HasAlias for Alias {
+
    fn alias(&self) -> &Alias {
+
        self
+
    }
+
}
+

impl HasAlias for Node<MemorySigner> {
    fn alias(&self) -> &Alias {
        &self.config.alias
@@ -294,25 +311,3 @@ impl<G> HasAlias for NodeHandle<G> {
        &self.alias
    }
}
-

-
pub trait HasHome {
-
    fn home(&self) -> &Home;
-
}
-

-
impl HasHome for Profile {
-
    fn home(&self) -> &Home {
-
        &self.home
-
    }
-
}
-

-
impl HasHome for Node<MemorySigner> {
-
    fn home(&self) -> &Home {
-
        &self.home
-
    }
-
}
-

-
impl HasHome for NodeHandle<MemorySigner> {
-
    fn home(&self) -> &Home {
-
        &self.home
-
    }
-
}
modified crates/radicle-cli/tests/util/formula.rs
@@ -20,10 +20,14 @@ pub(crate) fn formula(
        .env("GIT_COMMITTER_DATE", "1671125284")
        .env("GIT_COMMITTER_EMAIL", "radicle@localhost")
        .env("GIT_COMMITTER_NAME", "radicle")
+
        .env("JJ_USER", "Test User")
+
        .env("JJ_EMAIL", "test.user@example.com")
+
        .env("JJ_OP_HOSTNAME", "host.example.com")
+
        .env("JJ_OP_USERNAME", "test-username")
+
        .env("JJ_TZ_OFFSET_MINS", "660")
        .env("EDITOR", "true")
        .env("TZ", "UTC")
        .env("LANG", "C")
-
        .env("USER", "alice")
        .env(env::RAD_PASSPHRASE, "radicle")
        .env(env::RAD_KEYGEN_SEED, RAD_SEED)
        .env(env::RAD_RNG_SEED, "0")