rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 heartwoodde9628e1a2be2dcbe50bcb6c523bc47953f3e60c
{
"request": "trigger",
"version": 1,
"event_type": "patch",
"repository": {
"id": "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
"name": "heartwood",
"description": "Radicle Heartwood Protocol & Stack",
"private": false,
"default_branch": "master",
"delegates": [
"did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT",
"did:key:z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW",
"did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"did:key:z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz",
"did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz"
]
},
"action": "Updated",
"patch": {
"id": "9702eda6cd9b7f8f49f9a17d976a465f5df8c193",
"author": {
"id": "did:key:z6MkihpDMDu7ZYo6ma4M3Stf4fZF7Q1Jjuysn9GaRAvoTvYG",
"alias": "LAA"
},
"title": "Multiprofile: Profile management for Radicle CLI",
"state": {
"status": "draft",
"conflicts": []
},
"before": "55cdd880bfee08124d5b6a38cc05036402c7ab6e",
"after": "de9628e1a2be2dcbe50bcb6c523bc47953f3e60c",
"commits": [
"de9628e1a2be2dcbe50bcb6c523bc47953f3e60c",
"49b6cd29ee53c608a458b02978dd959fd92d3cce",
"829b7cba1802cdcc6a1648e7894c9a7e21b0cf37",
"13fd2ed92139426d31f3973c7e3024265fc5107a"
],
"target": "352c29c23ce2560750369aa50bc9f43bf3019d3f",
"labels": [],
"assignees": [],
"revisions": [
{
"id": "9702eda6cd9b7f8f49f9a17d976a465f5df8c193",
"author": {
"id": "did:key:z6MkihpDMDu7ZYo6ma4M3Stf4fZF7Q1Jjuysn9GaRAvoTvYG",
"alias": "LAA"
},
"description": "This change introduces a first-class profile management command to the Radicle CLI, enabling clean separation and safe switching of per-user configuration and keys by copying selected profile data into the root ~/.radicle/ directory (no symlinks).\n\nSummary\n- New command: rad profile\n- Subcommands: new, switch, list, current, remove\n- new: creates profiles/<name>, activates it, and clears the root so rad auth can initialize cleanly\n- switch: saves current root back to the active profile; then copies the target profile\u2019s config.json and keys/* into ~/.radicle/\n- Replace-in-place copy semantics to avoid partial states; cross-platform behavior\n\nDocumentation\n- crates/radicle-cli/examples/rad-profile.md",
"base": "55cdd880bfee08124d5b6a38cc05036402c7ab6e",
"oid": "13fd2ed92139426d31f3973c7e3024265fc5107a",
"timestamp": 1756340885
},
{
"id": "d08d24896f8f6c87df57a4e6b2c8d2c74fcf9ecd",
"author": {
"id": "did:key:z6MkihpDMDu7ZYo6ma4M3Stf4fZF7Q1Jjuysn9GaRAvoTvYG",
"alias": "LAA"
},
"description": "cli(profile): isolated profiles; copy-on-switch; explicit default\n\nIntroduce first-class profile management for the Radicle CLI.\n\n Features\n - Command: rad profile\n - Subcommands:\n \u2022 new <name> [--from <name>] [--force]\n \u2022 switch <name|default> [--print-env]\n \u2022 list | current | remove <name> [-y]\n\n Semantics\n - Always show 'default' in listings; 'current' prints 'default' when no\n active profile is set.\n - switch <name> persists the current root back to the active profile, then\n copies the target profile's config.json and keys/* into ~/.radicle/.\n - switch default persists the current root (if any), clears ~/.radicle/\n (config.json, keys/), and unsets the active profile marker; with\n --print-env it emits 'unset RAD_PROFILE'.\n - new clears the root so 'rad auth' can initialize cleanly.\n\n Implementation\n - No symlinks; replace-in-place copy to avoid partial states (portable).\n - Robust RAD_HOME resolution (works before 'rad auth').\n - Dedicated errors with thiserror; clippy/fmt clean.\n\n Docs\n - crates/radicle-cli/examples/rad-profile.md",
"base": "55cdd880bfee08124d5b6a38cc05036402c7ab6e",
"oid": "829b7cba1802cdcc6a1648e7894c9a7e21b0cf37",
"timestamp": 1756499366
},
{
"id": "c0643bfc43c9d60111740fad4a7755bc124de02b",
"author": {
"id": "did:key:z6MkihpDMDu7ZYo6ma4M3Stf4fZF7Q1Jjuysn9GaRAvoTvYG",
"alias": "LAA"
},
"description": "Updated the code to be compatible with the current Radicle structure and made it safer with atomic-copy.",
"base": "55cdd880bfee08124d5b6a38cc05036402c7ab6e",
"oid": "de9628e1a2be2dcbe50bcb6c523bc47953f3e60c",
"timestamp": 1765679124
}
]
}
}
{
"response": "triggered",
"run_id": {
"id": "4c9caf08-59e8-4195-86cd-40f2ded32a00"
},
"info_url": "https://cci.rad.levitte.org//4c9caf08-59e8-4195-86cd-40f2ded32a00.html"
}
Started at: 2025-12-14 03:25:30.321979+01:00
Commands:
$ rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 .
✓ Creating checkout in ./...
✓ Remote cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT added
✓ Remote-tracking branch cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/master created for z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT
✓ Remote cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW added
✓ Remote-tracking branch cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW/master created for z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW
✓ Remote fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM added
✓ Remote-tracking branch fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM/master created for z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM
✓ Remote erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz added
✓ Remote-tracking branch erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz/master created for z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz
✓ Remote lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz added
✓ Remote-tracking branch lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz/master created for z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz
✓ Repository successfully cloned under /opt/radcis/ci.rad.levitte.org/cci/state/4c9caf08-59e8-4195-86cd-40f2ded32a00/w/
╭────────────────────────────────────╮
│ heartwood │
│ Radicle Heartwood Protocol & Stack │
│ 129 issues · 11 patches │
╰────────────────────────────────────╯
Run `cd ./.` to go to the repository directory.
Exit code: 0
$ rad patch checkout 9702eda6cd9b7f8f49f9a17d976a465f5df8c193
✓ Switched to branch patch/9702eda at revision c0643bf
✓ Branch patch/9702eda setup to track rad/patches/9702eda6cd9b7f8f49f9a17d976a465f5df8c193
Exit code: 0
$ git config advice.detachedHead false
Exit code: 0
$ git checkout de9628e1a2be2dcbe50bcb6c523bc47953f3e60c
HEAD is now at de9628e1 cli(profile): align with current Radicle structure; safer atomic copy
Exit code: 0
$ git show de9628e1a2be2dcbe50bcb6c523bc47953f3e60c
commit de9628e1a2be2dcbe50bcb6c523bc47953f3e60c
Author: anon <anon@anon>
Date: Sun Dec 14 05:24:10 2025 +0300
cli(profile): align with current Radicle structure; safer atomic copy
diff --git a/crates/radicle-cli/Cargo.toml b/crates/radicle-cli/Cargo.toml
index be44059e..58f0f83f 100644
--- a/crates/radicle-cli/Cargo.toml
+++ b/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 }
diff --git a/crates/radicle-cli/examples/framework/home.md b/crates/radicle-cli/examples/framework/home.md
index 66795003..7ee169eb 100644
--- a/crates/radicle-cli/examples/framework/home.md
+++ b/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
```
```
diff --git a/crates/radicle-cli/examples/git/git-is-bare-repository.md b/crates/radicle-cli/examples/git/git-is-bare-repository.md
new file mode 100644
index 00000000..e0b38721
--- /dev/null
+++ b/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
diff --git a/crates/radicle-cli/examples/git/git-push-canonical-annotated-tags.md b/crates/radicle-cli/examples/git/git-push-canonical-annotated-tags.md
index 6b01e9ef..249c56ef 100644
--- a/crates/radicle-cli/examples/git/git-push-canonical-annotated-tags.md
+++ b/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
diff --git a/crates/radicle-cli/examples/git/git-push-canonical-lightweight-tags.md b/crates/radicle-cli/examples/git/git-push-canonical-lightweight-tags.md
index 7fd323eb..42cb1490 100644
--- a/crates/radicle-cli/examples/git/git-push-canonical-lightweight-tags.md
+++ b/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
diff --git a/crates/radicle-cli/examples/git/git-push-converge.md b/crates/radicle-cli/examples/git/git-push-converge.md
index 0696c280..49e33711 100644
--- a/crates/radicle-cli/examples/git/git-push-converge.md
+++ b/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
```
diff --git a/crates/radicle-cli/examples/git/git-push-force-with-lease.md b/crates/radicle-cli/examples/git/git-push-force-with-lease.md
new file mode 100644
index 00000000..4e92588a
--- /dev/null
+++ b/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
diff --git a/crates/radicle-cli/examples/git/git-push.md b/crates/radicle-cli/examples/git/git-push.md
index 9b29e55c..2c24faaf 100644
--- a/crates/radicle-cli/examples/git/git-push.md
+++ b/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
```
diff --git a/crates/radicle-cli/examples/jj-config.md b/crates/radicle-cli/examples/jj-config.md
new file mode 100644
index 00000000..cc11e3a2
--- /dev/null
+++ b/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
diff --git a/crates/radicle-cli/examples/jj-init-bare.md b/crates/radicle-cli/examples/jj-init-bare.md
new file mode 100644
index 00000000..6668f965
--- /dev/null
+++ b/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
diff --git a/crates/radicle-cli/examples/jj-init-colocate.md b/crates/radicle-cli/examples/jj-init-colocate.md
new file mode 100644
index 00000000..8da7c473
--- /dev/null
+++ b/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
diff --git a/crates/radicle-cli/examples/rad-auth-errors.md b/crates/radicle-cli/examples/rad-auth-errors.md
index c723dec1..70026f29 100644
--- a/crates/radicle-cli/examples/rad-auth-errors.md
+++ b/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'.
```
diff --git a/crates/radicle-cli/examples/rad-clone-bare.md b/crates/radicle-cli/examples/rad-clone-bare.md
new file mode 100644
index 00000000..95c651b3
--- /dev/null
+++ b/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 │
+╰───────────────────────────────────────────────────────────────────────────────────────────────────────────╯
+```
diff --git a/crates/radicle-cli/examples/rad-cob-update.md b/crates/radicle-cli/examples/rad-cob-update.md
index 86516b8b..a06ab697 100644
--- a/crates/radicle-cli/examples/rad-cob-update.md
+++ b/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
+```
diff --git a/crates/radicle-cli/examples/rad-diff.md b/crates/radicle-cli/examples/rad-diff.md
index 64ce618a..4222fee7 100644
--- a/crates/radicle-cli/examples/rad-diff.md
+++ b/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
```
diff --git a/crates/radicle-cli/examples/rad-help.md b/crates/radicle-cli/examples/rad-help.md
new file mode 100644
index 00000000..a2589de2
--- /dev/null
+++ b/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
+```
diff --git a/crates/radicle-cli/examples/rad-id-threshold.md b/crates/radicle-cli/examples/rad-id-threshold.md
index 8860b53e..924a9005 100644
--- a/crates/radicle-cli/examples/rad-id-threshold.md
+++ b/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 │
diff --git a/crates/radicle-cli/examples/rad-id.md b/crates/radicle-cli/examples/rad-id.md
index c919e7e6..fe73bef2 100644
--- a/crates/radicle-cli/examples/rad-id.md
+++ b/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,
diff --git a/crates/radicle-cli/examples/rad-init-existing-bare.md b/crates/radicle-cli/examples/rad-init-existing-bare.md
new file mode 100644
index 00000000..ea727896
--- /dev/null
+++ b/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)
+```
diff --git a/crates/radicle-cli/examples/rad-init-existing.md b/crates/radicle-cli/examples/rad-init-existing.md
index a9bf5879..db77e809 100644
--- a/crates/radicle-cli/examples/rad-init-existing.md
+++ b/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':
diff --git a/crates/radicle-cli/examples/rad-init-no-seed.md b/crates/radicle-cli/examples/rad-init-no-seed.md
index 0597b3cc..03176c77 100644
--- a/crates/radicle-cli/examples/rad-init-no-seed.md
+++ b/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
diff --git a/crates/radicle-cli/examples/rad-init-private-clone-seed.md b/crates/radicle-cli/examples/rad-init-private-clone-seed.md
index 99c5088e..5c5685f9 100644
--- a/crates/radicle-cli/examples/rad-init-private-clone-seed.md
+++ b/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
diff --git a/crates/radicle-cli/examples/rad-init-private.md b/crates/radicle-cli/examples/rad-init-private.md
index 02654415..5783016e 100644
--- a/crates/radicle-cli/examples/rad-init-private.md
+++ b/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
diff --git a/crates/radicle-cli/examples/rad-issue-list.md b/crates/radicle-cli/examples/rad-issue-list.md
new file mode 100644
index 00000000..a3cfdf8c
--- /dev/null
+++ b/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 │
+╰────────────────────────────────────────────────────────────────────────────────────────────────────╯
+```
diff --git a/crates/radicle-cli/examples/rad-key-mismatch.md b/crates/radicle-cli/examples/rad-key-mismatch.md
new file mode 100644
index 00000000..cbf3c981
--- /dev/null
+++ b/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
diff --git a/crates/radicle-cli/examples/rad-merge-via-push.md b/crates/radicle-cli/examples/rad-merge-via-push.md
index 5e2bea06..6973cc50 100644
--- a/crates/radicle-cli/examples/rad-merge-via-push.md
+++ b/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:
diff --git a/crates/radicle-cli/examples/rad-node.md b/crates/radicle-cli/examples/rad-node.md
index f6a5406d..3be1ef59 100644
--- a/crates/radicle-cli/examples/rad-node.md
+++ b/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 [..].
```
```
diff --git a/crates/radicle-cli/examples/rad-patch-ahead-behind.md b/crates/radicle-cli/examples/rad-patch-ahead-behind.md
index b0154788..cf8c5b7b 100644
--- a/crates/radicle-cli/examples/rad-patch-ahead-behind.md
+++ b/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 │
+╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```
diff --git a/crates/radicle-cli/examples/rad-patch-change-base.md b/crates/radicle-cli/examples/rad-patch-change-base.md
index 12b295a9..0ad7732b 100644
--- a/crates/radicle-cli/examples/rad-patch-change-base.md
+++ b/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 │
+╰────────────────────────────────────────────────────╯
```
diff --git a/crates/radicle-cli/examples/rad-patch-checkout-revision.md b/crates/radicle-cli/examples/rad-patch-checkout-revision.md
index f135130f..9b350f0e 100644
--- a/crates/radicle-cli/examples/rad-patch-checkout-revision.md
+++ b/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`:
diff --git a/crates/radicle-cli/examples/rad-patch-delete.md b/crates/radicle-cli/examples/rad-patch-delete.md
index 565e1d11..67f2f2c9 100644
--- a/crates/radicle-cli/examples/rad-patch-delete.md
+++ b/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
diff --git a/crates/radicle-cli/examples/rad-patch-diff.md b/crates/radicle-cli/examples/rad-patch-diff.md
index bb00955f..6504658f 100644
--- a/crates/radicle-cli/examples/rad-patch-diff.md
+++ b/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!
```
diff --git a/crates/radicle-cli/examples/rad-patch-draft.md b/crates/radicle-cli/examples/rad-patch-draft.md
index b5167217..6d9c67bd 100644
--- a/crates/radicle-cli/examples/rad-patch-draft.md
+++ b/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 │
╰────────────────────────────────────────────────────╯
```
diff --git a/crates/radicle-cli/examples/rad-patch-edit.md b/crates/radicle-cli/examples/rad-patch-edit.md
index 53c8570b..96ef780d 100644
--- a/crates/radicle-cli/examples/rad-patch-edit.md
+++ b/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
diff --git a/crates/radicle-cli/examples/rad-patch-fetch-2.md b/crates/radicle-cli/examples/rad-patch-fetch-2.md
index a9392665..4b260917 100644
--- a/crates/radicle-cli/examples/rad-patch-fetch-2.md
+++ b/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
```
diff --git a/crates/radicle-cli/examples/rad-patch-jj.md b/crates/radicle-cli/examples/rad-patch-jj.md
new file mode 100644
index 00000000..b22e64c2
--- /dev/null
+++ b/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
diff --git a/crates/radicle-cli/examples/rad-patch-pull-update.md b/crates/radicle-cli/examples/rad-patch-pull-update.md
index 294bbdbc..fefcee86 100644
--- a/crates/radicle-cli/examples/rad-patch-pull-update.md
+++ b/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
```
diff --git a/crates/radicle-cli/examples/rad-patch-revert-merge.md b/crates/radicle-cli/examples/rad-patch-revert-merge.md
index 85499566..166e24a0 100644
--- a/crates/radicle-cli/examples/rad-patch-revert-merge.md
+++ b/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 │
╰────────────────────────────────────────────────────╯
```
diff --git a/crates/radicle-cli/examples/rad-patch-update.md b/crates/radicle-cli/examples/rad-patch-update.md
index 1b897efc..55917b4e 100644
--- a/crates/radicle-cli/examples/rad-patch-update.md
+++ b/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 │
+╰────────────────────────────────────────────────────╯
```
diff --git a/crates/radicle-cli/examples/rad-patch-via-push.md b/crates/radicle-cli/examples/rad-patch-via-push.md
index b21a8cc4..89d43d46 100644
--- a/crates/radicle-cli/examples/rad-patch-via-push.md
+++ b/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'
diff --git a/crates/radicle-cli/examples/rad-patch.md b/crates/radicle-cli/examples/rad-patch.md
index b038fa67..8946a1b9 100644
--- a/crates/radicle-cli/examples/rad-patch.md
+++ b/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 │
+╰────────────────────────────────────────────────────╯
```
diff --git a/crates/radicle-cli/examples/rad-self.md b/crates/radicle-cli/examples/rad-self.md
index a486efc8..440db13a 100644
--- a/crates/radicle-cli/examples/rad-self.md
+++ b/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
```
diff --git a/crates/radicle-cli/examples/workflow/4-patching-contributor.md b/crates/radicle-cli/examples/workflow/4-patching-contributor.md
index a0455161..8b7b8ac3 100644
--- a/crates/radicle-cli/examples/workflow/4-patching-contributor.md
+++ b/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 │
╰────────────────────────────────────────────────────╯
```
diff --git a/crates/radicle-cli/examples/workflow/5-patching-maintainer.md b/crates/radicle-cli/examples/workflow/5-patching-maintainer.md
index 61c771da..a7c3bd6b 100644
--- a/crates/radicle-cli/examples/workflow/5-patching-maintainer.md
+++ b/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:
diff --git a/crates/radicle-cli/src/commands/auth.rs b/crates/radicle-cli/src/commands/auth.rs
index 18de1652..100b9a96 100644
--- a/crates/radicle-cli/src/commands/auth.rs
+++ b/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)?;
diff --git a/crates/radicle-cli/src/commands/auth/args.rs b/crates/radicle-cli/src/commands/auth/args.rs
new file mode 100644
index 00000000..94944ba6
--- /dev/null
+++ b/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,
+}
diff --git a/crates/radicle-cli/src/commands/block.rs b/crates/radicle-cli/src/commands/block.rs
index a39f571c..f9786da4 100644
--- a/crates/radicle-cli/src/commands/block.rs
+++ b/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(())
}
diff --git a/crates/radicle-cli/src/commands/block/args.rs b/crates/radicle-cli/src/commands/block/args.rs
new file mode 100644
index 00000000..f75cdcd3
--- /dev/null
+++ b/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);
+ }
+}
diff --git a/crates/radicle-cli/src/commands/checkout.rs b/crates/radicle-cli/src/commands/checkout.rs
index 1c637479..9cc7a4dd 100644
--- a/crates/radicle-cli/src/commands/checkout.rs
+++ b/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)?;
diff --git a/crates/radicle-cli/src/commands/checkout/args.rs b/crates/radicle-cli/src/commands/checkout/args.rs
new file mode 100644
index 00000000..f6654ad2
--- /dev/null
+++ b/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,
+}
diff --git a/crates/radicle-cli/src/commands/clean.rs b/crates/radicle-cli/src/commands/clean.rs
index b2aa0ef5..4452085c 100644
--- a/crates/radicle-cli/src/commands/clean.rs
+++ b/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}");
diff --git a/crates/radicle-cli/src/commands/clean/args.rs b/crates/radicle-cli/src/commands/clean/args.rs
new file mode 100644
index 00000000..fc836ae4
--- /dev/null
+++ b/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,
+}
diff --git a/crates/radicle-cli/src/commands/clone.rs b/crates/radicle-cli/src/commands/clone.rs
index 771df420..f90a77df 100644
--- a/crates/radicle-cli/src/commands/clone.rs
+++ b/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),
)
diff --git a/crates/radicle-cli/src/commands/clone/args.rs b/crates/radicle-cli/src/commands/clone/args.rs
new file mode 100644
index 00000000..0c763bfe
--- /dev/null
+++ b/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())
+ }
+}
diff --git a/crates/radicle-cli/src/commands/cob.rs b/crates/radicle-cli/src/commands/cob.rs
index 15a37ea2..a565797c 100644
--- a/crates/radicle-cli/src/commands/cob.rs
+++ b/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"))
}
diff --git a/crates/radicle-cli/src/commands/cob/args.rs b/crates/radicle-cli/src/commands/cob/args.rs
new file mode 100644
index 00000000..a7c315b7
--- /dev/null
+++ b/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);
+ }
+}
diff --git a/crates/radicle-cli/src/commands/config.rs b/crates/radicle-cli/src/commands/config.rs
index cfd32531..a74c8409 100644
--- a/crates/radicle-cli/src/commands/config.rs
+++ b/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:?}")
}
diff --git a/crates/radicle-cli/src/commands/config/args.rs b/crates/radicle-cli/src/commands/config/args.rs
new file mode 100644
index 00000000..c80a97ff
--- /dev/null
+++ b/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,
+ },
+}
diff --git a/crates/radicle-cli/src/commands/debug.rs b/crates/radicle-cli/src/commands/debug.rs
index fb43989c..da8d1dae 100644
--- a/crates/radicle-cli/src/commands/debug.rs
+++ b/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) => {
diff --git a/crates/radicle-cli/src/commands/debug/args.rs b/crates/radicle-cli/src/commands/debug/args.rs
new file mode 100644
index 00000000..d5f8a57e
--- /dev/null
+++ b/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 {}
diff --git a/crates/radicle-cli/src/commands/diff.rs b/crates/radicle-cli/src/commands/diff.rs
index a705bdd7..5fa873df 100644
--- a/crates/radicle-cli/src/commands/diff.rs
+++ b/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));
}
diff --git a/crates/radicle-cli/src/commands/follow.rs b/crates/radicle-cli/src/commands/follow.rs
index 7b4f0ff3..851542c1 100644
--- a/crates/radicle-cli/src/commands/follow.rs
+++ b/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(())
diff --git a/crates/radicle-cli/src/commands/follow/args.rs b/crates/radicle-cli/src/commands/follow/args.rs
new file mode 100644
index 00000000..35860f5c
--- /dev/null
+++ b/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 },
+ }
+ }
+}
diff --git a/crates/radicle-cli/src/commands/fork.rs b/crates/radicle-cli/src/commands/fork.rs
index 440c9436..2ca236f3 100644
--- a/crates/radicle-cli/src/commands/fork.rs
+++ b/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
}
diff --git a/crates/radicle-cli/src/commands/fork/args.rs b/crates/radicle-cli/src/commands/fork/args.rs
new file mode 100644
index 00000000..9c195548
--- /dev/null
+++ b/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);
+ }
+}
diff --git a/crates/radicle-cli/src/commands/help.rs b/crates/radicle-cli/src/commands/help.rs
deleted file mode 100644
index 371acd7d..00000000
--- a/crates/radicle-cli/src/commands/help.rs
+++ /dev/null
@@ -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(())
-}
diff --git a/crates/radicle-cli/src/commands/id.rs b/crates/radicle-cli/src/commands/id.rs
index 93f3cf12..6bc9abbd 100644
--- a/crates/radicle-cli/src/commands/id.rs
+++ b/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, ¤t, &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, ¤t, &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, ¤t, &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
diff --git a/crates/radicle-cli/src/commands/id/args.rs b/crates/radicle-cli/src/commands/id/args.rs
new file mode 100644
index 00000000..05b802b7
--- /dev/null
+++ b/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();
+ }
+}
diff --git a/crates/radicle-cli/src/commands/inbox.rs b/crates/radicle-cli/src/commands/inbox.rs
index 25ba612b..7dd3ddbc 100644
--- a/crates/radicle-cli/src/commands/inbox.rs
+++ b/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,
- ¬ifs.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(
+ ¬ifs.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: ¬ifications::StoreReader,
+ mode: ListMode,
sort_by: SortBy,
+ reverse: bool,
show_unknown: bool,
- notifs: ¬ifications::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: ¬ifications::StoreReader,
sort_by: SortBy,
+ reverse: bool,
show_unknown: bool,
- notifs: ¬ifications::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: ¬ifications::StoreReader,
rid: RepoId,
sort_by: SortBy,
+ reverse: bool,
show_unknown: bool,
- notifs: ¬ifications::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)?;
diff --git a/crates/radicle-cli/src/commands/inbox/args.rs b/crates/radicle-cli/src/commands/inbox/args.rs
new file mode 100644
index 00000000..28de59aa
--- /dev/null
+++ b/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,
+}
diff --git a/crates/radicle-cli/src/commands/init.rs b/crates/radicle-cli/src/commands/init.rs
index 1e966e0b..c81f642b 100644
--- a/crates/radicle-cli/src/commands/init.rs
+++ b/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(())
}
diff --git a/crates/radicle-cli/src/commands/init/args.rs b/crates/radicle-cli/src/commands/init/args.rs
new file mode 100644
index 00000000..d45e3268
--- /dev/null
+++ b/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);
+ }
+}
diff --git a/crates/radicle-cli/src/commands/inspect.rs b/crates/radicle-cli/src/commands/inspect.rs
index 92e5c821..9d4fed30 100644
--- a/crates/radicle-cli/src/commands/inspect.rs
+++ b/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)?;
diff --git a/crates/radicle-cli/src/commands/inspect/args.rs b/crates/radicle-cli/src/commands/inspect/args.rs
new file mode 100644
index 00000000..e849daf9
--- /dev/null
+++ b/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,
+}
diff --git a/crates/radicle-cli/src/commands/issue.rs b/crates/radicle-cli/src/commands/issue.rs
index 8465cbae..24a28415 100644
--- a/crates/radicle-cli/src/commands/issue.rs
+++ b/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');
- }
-}
diff --git a/crates/radicle-cli/src/commands/issue/args.rs b/crates/radicle-cli/src/commands/issue/args.rs
new file mode 100644
index 00000000..de04489e
--- /dev/null
+++ b/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 },
+ }
+ }
+}
diff --git a/crates/radicle-cli/src/commands/issue/comment.rs b/crates/radicle-cli/src/commands/issue/comment.rs
new file mode 100644
index 00000000..665f0462
--- /dev/null
+++ b/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');
+ }
+}
diff --git a/crates/radicle-cli/src/commands/issue/comment.txt b/crates/radicle-cli/src/commands/issue/comment.txt
new file mode 100644
index 00000000..aa93c734
--- /dev/null
+++ b/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
diff --git a/crates/radicle-cli/src/commands/ls.rs b/crates/radicle-cli/src/commands/ls.rs
index e4398ec6..387f4c81 100644
--- a/crates/radicle-cli/src/commands/ls.rs
+++ b/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() {
diff --git a/crates/radicle-cli/src/commands/ls/args.rs b/crates/radicle-cli/src/commands/ls/args.rs
new file mode 100644
index 00000000..333db742
--- /dev/null
+++ b/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,
+}
diff --git a/crates/radicle-cli/src/commands/node.rs b/crates/radicle-cli/src/commands/node.rs
index 1b9a7da4..2d901df3 100644
--- a/crates/radicle-cli/src/commands/node.rs
+++ b/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);
}
}
diff --git a/crates/radicle-cli/src/commands/node/args.rs b/crates/radicle-cli/src/commands/node/args.rs
new file mode 100644
index 00000000..db1f025b
--- /dev/null
+++ b/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,
+ },
+}
diff --git a/crates/radicle-cli/src/commands/node/commands.rs b/crates/radicle-cli/src/commands/node/commands.rs
index c0b3daef..5c556d3a 100644
--- a/crates/radicle-cli/src/commands/node/commands.rs
+++ b/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)?;
diff --git a/crates/radicle-cli/src/commands/node/control.rs b/crates/radicle-cli/src/commands/node/control.rs
index 37ab71e4..f3c10099 100644
--- a/crates/radicle-cli/src/commands/node/control.rs
+++ b/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))
}
diff --git a/crates/radicle-cli/src/commands/patch.rs b/crates/radicle-cli/src/commands/patch.rs
index 80382675..c7666712 100644
--- a/crates/radicle-cli/src/commands/patch.rs
+++ b/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)?;
}
}
}
diff --git a/crates/radicle-cli/src/commands/patch/args.rs b/crates/radicle-cli/src/commands/patch/args.rs
new file mode 100644
index 00000000..1d0ac156
--- /dev/null
+++ b/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)
+}
diff --git a/crates/radicle-cli/src/commands/patch/checkout.rs b/crates/radicle-cli/src/commands/patch/checkout.rs
index 03c35a5f..ef1b400a 100644
--- a/crates/radicle-cli/src/commands/patch/checkout.rs
+++ b/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()),
diff --git a/crates/radicle-cli/src/commands/patch/comment.rs b/crates/radicle-cli/src/commands/patch/comment.rs
index fb7694b8..564b403d 100644
--- a/crates/radicle-cli/src/commands/patch/comment.rs
+++ b/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::*;
diff --git a/crates/radicle-cli/src/commands/patch/list.rs b/crates/radicle-cli/src/commands/patch/list.rs
index fd2c4584..ff9f2d45 100644
--- a/crates/radicle-cli/src/commands/patch/list.rs
+++ b/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() {
diff --git a/crates/radicle-cli/src/commands/patch/review.rs b/crates/radicle-cli/src/commands/patch/review.rs
index bf8436f1..5e3a398a 100644
--- a/crates/radicle-cli/src/commands/patch/review.rs
+++ b/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) {
diff --git a/crates/radicle-cli/src/commands/patch/review/builder.rs b/crates/radicle-cli/src/commands/patch/review/builder.rs
index fe7f2448..05427305 100644
--- a/crates/radicle-cli/src/commands/patch/review/builder.rs
+++ b/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)
}
}
diff --git a/crates/radicle-cli/src/commands/patch/show.rs b/crates/radicle-cli/src/commands/patch/show.rs
index 80d093fd..4e3d3dec 100644
--- a/crates/radicle-cli/src/commands/patch/show.rs
+++ b/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::*;
diff --git a/crates/radicle-cli/src/commands/patch/update.rs b/crates/radicle-cli/src/commands/patch/update.rs
index b2cc6f7e..52cac82a 100644
--- a/crates/radicle-cli/src/commands/patch/update.rs
+++ b/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);
diff --git a/crates/radicle-cli/src/commands/path.rs b/crates/radicle-cli/src/commands/path.rs
index 19bbe238..d5ab589b 100644
--- a/crates/radicle-cli/src/commands/path.rs
+++ b/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());
diff --git a/crates/radicle-cli/src/commands/path/args.rs b/crates/radicle-cli/src/commands/path/args.rs
new file mode 100644
index 00000000..81e60d02
--- /dev/null
+++ b/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 {}
diff --git a/crates/radicle-cli/src/commands/publish.rs b/crates/radicle-cli/src/commands/publish.rs
index c6244811..e2ac9f10 100644
--- a/crates/radicle-cli/src/commands/publish.rs
+++ b/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"
),
diff --git a/crates/radicle-cli/src/commands/publish/args.rs b/crates/radicle-cli/src/commands/publish/args.rs
new file mode 100644
index 00000000..ee0385af
--- /dev/null
+++ b/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);
+ }
+}
diff --git a/crates/radicle-cli/src/commands/remote.rs b/crates/radicle-cli/src/commands/remote.rs
index ec64b054..61c59ecb 100644
--- a/crates/radicle-cli/src/commands/remote.rs
+++ b/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())?;
diff --git a/crates/radicle-cli/src/commands/remote/add.rs b/crates/radicle-cli/src/commands/remote/add.rs
index 62288ff5..7634eff3 100644
--- a/crates/radicle-cli/src/commands/remote/add.rs
+++ b/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;
diff --git a/crates/radicle-cli/src/commands/remote/args.rs b/crates/radicle-cli/src/commands/remote/args.rs
new file mode 100644
index 00000000..19d70d68
--- /dev/null
+++ b/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)
+}
diff --git a/crates/radicle-cli/src/commands/remote/list.rs b/crates/radicle-cli/src/commands/remote/list.rs
index f5c40fd2..7356cdd1 100644
--- a/crates/radicle-cli/src/commands/remote/list.rs
+++ b/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();
}
diff --git a/crates/radicle-cli/src/commands/seed.rs b/crates/radicle-cli/src/commands/seed.rs
index d0cc083f..7482a835 100644
--- a/crates/radicle-cli/src/commands/seed.rs
+++ b/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(())
diff --git a/crates/radicle-cli/src/commands/seed/args.rs b/crates/radicle-cli/src/commands/seed/args.rs
new file mode 100644
index 00000000..e654d1c2
--- /dev/null
+++ b/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,
+ }
+ }
+}
diff --git a/crates/radicle-cli/src/commands/self.rs b/crates/radicle-cli/src/commands/self.rs
index e2887d4b..1feb12f5 100644
--- a/crates/radicle-cli/src/commands/self.rs
+++ b/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(),
diff --git a/crates/radicle-cli/src/commands/self/args.rs b/crates/radicle-cli/src/commands/self/args.rs
new file mode 100644
index 00000000..f4c9c8cb
--- /dev/null
+++ b/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,
+}
diff --git a/crates/radicle-cli/src/commands/stats.rs b/crates/radicle-cli/src/commands/stats.rs
index a98a2356..0465c601 100644
--- a/crates/radicle-cli/src/commands/stats.rs
+++ b/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;
diff --git a/crates/radicle-cli/src/commands/stats/args.rs b/crates/radicle-cli/src/commands/stats/args.rs
new file mode 100644
index 00000000..e5039892
--- /dev/null
+++ b/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 {}
diff --git a/crates/radicle-cli/src/commands/sync.rs b/crates/radicle-cli/src/commands/sync.rs
index 81c962c2..0653214b 100644
--- a/crates/radicle-cli/src/commands/sync.rs
+++ b/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)
diff --git a/crates/radicle-cli/src/commands/sync/args.rs b/crates/radicle-cli/src/commands/sync/args.rs
new file mode 100644
index 00000000..ee4a02f1
--- /dev/null
+++ b/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)
+}
diff --git a/crates/radicle-cli/src/commands/unblock.rs b/crates/radicle-cli/src/commands/unblock.rs
index 25f87acd..e65b75f2 100644
--- a/crates/radicle-cli/src/commands/unblock.rs
+++ b/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(())
}
diff --git a/crates/radicle-cli/src/commands/unblock/args.rs b/crates/radicle-cli/src/commands/unblock/args.rs
new file mode 100644
index 00000000..635e8d67
--- /dev/null
+++ b/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,
+}
diff --git a/crates/radicle-cli/src/commands/unfollow.rs b/crates/radicle-cli/src/commands/unfollow.rs
index d821a125..9e18555d 100644
--- a/crates/radicle-cli/src/commands/unfollow.rs
+++ b/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,
diff --git a/crates/radicle-cli/src/commands/unfollow/args.rs b/crates/radicle-cli/src/commands/unfollow/args.rs
new file mode 100644
index 00000000..57725575
--- /dev/null
+++ b/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,
+}
diff --git a/crates/radicle-cli/src/commands/unseed.rs b/crates/radicle-cli/src/commands/unseed.rs
index 04d64fc7..9bac3781 100644
--- a/crates/radicle-cli/src/commands/unseed.rs
+++ b/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)?;
}
diff --git a/crates/radicle-cli/src/commands/unseed/args.rs b/crates/radicle-cli/src/commands/unseed/args.rs
new file mode 100644
index 00000000..2c579266
--- /dev/null
+++ b/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>,
+}
diff --git a/crates/radicle-cli/src/commands/watch.rs b/crates/radicle-cli/src/commands/watch.rs
index a621ac23..bd72a33f 100644
--- a/crates/radicle-cli/src/commands/watch.rs
+++ b/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),
}
}
diff --git a/crates/radicle-cli/src/commands/watch/args.rs b/crates/radicle-cli/src/commands/watch/args.rs
new file mode 100644
index 00000000..a67f011b
--- /dev/null
+++ b/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())
+ }
+}
diff --git a/crates/radicle-cli/src/git.rs b/crates/radicle-cli/src/git.rs
index d01d20b0..aa51aea0 100644
--- a/crates/radicle-cli/src/git.rs
+++ b/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)
diff --git a/crates/radicle-cli/src/git/ddiff.rs b/crates/radicle-cli/src/git/ddiff.rs
index 6b40d716..f005ffe7 100644
--- a/crates/radicle-cli/src/git/ddiff.rs
+++ b/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
diff --git a/crates/radicle-cli/src/git/pretty_diff.rs b/crates/radicle-cli/src/git/pretty_diff.rs
index 22187398..a5319cfa 100644
--- a/crates/radicle-cli/src/git/pretty_diff.rs
+++ b/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]
diff --git a/crates/radicle-cli/src/git/unified_diff.rs b/crates/radicle-cli/src/git/unified_diff.rs
index a9d20b6f..d37cdcbf 100644
--- a/crates/radicle-cli/src/git/unified_diff.rs
+++ b/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}")
}
}
diff --git a/crates/radicle-cli/src/node.rs b/crates/radicle-cli/src/node.rs
index ffb31378..3212ecc6 100644
--- a/crates/radicle-cli/src/node.rs
+++ b/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)",
diff --git a/crates/radicle-cli/src/project.rs b/crates/radicle-cli/src/project.rs
index 37708d13..9d4cb66b 100644
--- a/crates/radicle-cli/src/project.rs
+++ b/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();
diff --git a/crates/radicle-cli/src/terminal.rs b/crates/radicle-cli/src/terminal.rs
index 86712223..6cfbf4f1 100644
--- a/crates/radicle-cli/src/terminal.rs
+++ b/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();
diff --git a/crates/radicle-cli/src/terminal/args.rs b/crates/radicle-cli/src/terminal/args.rs
index 90f74d4e..a2e17575 100644
--- a/crates/radicle-cli/src/terminal/args.rs
+++ b/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 { .. }));
+ }
}
diff --git a/crates/radicle-cli/src/terminal/io.rs b/crates/radicle-cli/src/terminal/io.rs
index 40e580d7..c3d47039 100644
--- a/crates/radicle-cli/src/terminal/io.rs
+++ b/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))?;
diff --git a/crates/radicle-cli/src/terminal/patch.rs b/crates/radicle-cli/src/terminal/patch.rs
index 5136bc6b..daf823a8 100644
--- a/crates/radicle-cli/src/terminal/patch.rs
+++ b/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");
diff --git a/crates/radicle-cli/src/terminal/patch/common.rs b/crates/radicle-cli/src/terminal/patch/common.rs
index 35e1dcf3..6768a93b 100644
--- a/crates/radicle-cli/src/terminal/patch/common.rs
+++ b/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 {
diff --git a/crates/radicle-cli/src/terminal/patch/timeline.rs b/crates/radicle-cli/src/terminal/patch/timeline.rs
index cb95eb0c..b7a8e834 100644
--- a/crates/radicle-cli/src/terminal/patch/timeline.rs
+++ b/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
+ }
}
}
diff --git a/crates/radicle-cli/src/warning.rs b/crates/radicle-cli/src/warning.rs
index 7f6703c2..926794dd 100644
--- a/crates/radicle-cli/src/warning.rs
+++ b/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(),
+ );
+}
diff --git a/crates/radicle-cli/tests/commands.rs b/crates/radicle-cli/tests/commands.rs
index 4529e615..d5278763 100644
--- a/crates/radicle-cli/tests/commands.rs
+++ b/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,25 +37,79 @@ 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();
@@ -65,6 +120,11 @@ fn rad_issue() {
Environment::alice(["rad-init", "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();
@@ -176,6 +226,15 @@ fn rad_init() {
Environment::alice(["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();
@@ -198,6 +257,28 @@ fn rad_init_existing() {
.unwrap();
}
+#[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]
@@ -1124,6 +1247,26 @@ fn rad_clone() {
test("examples/rad-clone.md", working, Some(&bob.home), []).unwrap();
}
+#[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();
@@ -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
@@ -2275,6 +2425,11 @@ fn git_push_amend() {
.unwrap();
}
+#[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();
diff --git a/crates/radicle-cli/tests/util/environment.rs b/crates/radicle-cli/tests/util/environment.rs
index d6305ef6..1f44ddc6 100644
--- a/crates/radicle-cli/tests/util/environment.rs
+++ b/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
- }
-}
diff --git a/crates/radicle-cli/tests/util/formula.rs b/crates/radicle-cli/tests/util/formula.rs
index fe44a0de..7bffda73 100644
--- a/crates/radicle-cli/tests/util/formula.rs
+++ b/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")
Exit code: 0
shell: 'cargo --version rustc --version cargo fmt --check cargo clippy --all-targets --workspace -- --deny warnings cargo build --all-targets --workspace cargo doc --workspace --no-deps cargo test --workspace --no-fail-fast '
Commands:
$ podman run --name 4c9caf08-59e8-4195-86cd-40f2ded32a00 -v /opt/radcis/ci.rad.levitte.org/cci/state/4c9caf08-59e8-4195-86cd-40f2ded32a00/s:/4c9caf08-59e8-4195-86cd-40f2ded32a00/s:ro -v /opt/radcis/ci.rad.levitte.org/cci/state/4c9caf08-59e8-4195-86cd-40f2ded32a00/w:/4c9caf08-59e8-4195-86cd-40f2ded32a00/w -w /4c9caf08-59e8-4195-86cd-40f2ded32a00/w -v /opt/radcis/ci.rad.levitte.org/.radicle:/${id}/.radicle:ro -e RAD_HOME=/${id}/.radicle rust:bookworm bash /4c9caf08-59e8-4195-86cd-40f2ded32a00/s/script.sh
+ cargo --version
info: syncing channel updates for '1.88-x86_64-unknown-linux-gnu'
info: latest update on 2025-06-26, rust version 1.88.0 (6b00bc388 2025-06-23)
info: downloading component 'cargo'
info: downloading component 'clippy'
info: downloading component 'rust-docs'
info: downloading component 'rust-src'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: downloading component 'rustfmt'
info: installing component 'cargo'
info: installing component 'clippy'
info: installing component 'rust-docs'
info: installing component 'rust-src'
info: installing component 'rust-std'
info: installing component 'rustc'
info: installing component 'rustfmt'
cargo 1.88.0 (873a06493 2025-05-10)
+ rustc --version
rustc 1.88.0 (6b00bc388 2025-06-23)
+ cargo fmt --check
`cargo metadata` exited with an error: error: failed to load manifest for workspace member `/4c9caf08-59e8-4195-86cd-40f2ded32a00/w/crates/radicle-cli`
referenced via `crates/*` by workspace at `/4c9caf08-59e8-4195-86cd-40f2ded32a00/w/Cargo.toml`
Caused by:
failed to parse manifest at `/4c9caf08-59e8-4195-86cd-40f2ded32a00/w/crates/radicle-cli/Cargo.toml`
Caused by:
error inheriting `itertools` from workspace root manifest's `workspace.dependencies.itertools`
Caused by:
`dependency.itertools` was not found in `workspace.dependencies`
This utility formats all bin and lib files of the current crate using rustfmt.
Usage: cargo fmt [OPTIONS] [-- <rustfmt_options>...]
Arguments:
[rustfmt_options]... Options passed to rustfmt
Options:
-q, --quiet
No output printed to stdout
-v, --verbose
Use verbose output
--version
Print rustfmt version and exit
-p, --package <package>...
Specify package to format
--manifest-path <manifest-path>
Specify path to Cargo.toml
--message-format <message-format>
Specify message-format: short|json|human
--all
Format all packages, and also their local path-based dependencies
--check
Run rustfmt in check mode
-h, --help
Print help
Exit code: 1
{
"response": "finished",
"result": "failure"
}